Merge branch 'v5/main' into v5/replace-axios

This commit is contained in:
Bassel Kanso 2024-04-24 09:41:49 +03:00 committed by GitHub
commit dfd5bbf8d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2166 additions and 248 deletions

View File

@ -0,0 +1,144 @@
---
title: Schema
tags:
- typescript
- type system
- type
- concepts
---
The schema is the primary data structure leveraged within the Strapi Type System, defining how content is structured and managed in the application.
It serves several key functions:
- **Representation**: At its core, a schema outlines and defines the structure of Strapi content. This is useful when dealing with features that need access to low level schema properties (_e.g. attributes, plugin options, etc..._).
- **Inference**: The schema allows inferring and configuring numerous other types. This includes entities like `ContentType` or `Component`, among others.
### Scope
Schema types represent **loaded** schemas in the context of a Strapi server application and should be used accordingly.
:::caution
Database models and raw schema definitions (_aka schemas before being loaded by the Strapi server_) are **not** the same types and can't be used interchangeably.
:::
### Sub-Types
Each box is a type that extends the base Schema interface.
In between each box is text that represents the discriminant used to differentiate the subtype from others.
```mermaid
flowchart TB;
Schema -- "<code>modelType: contentType</code>" ---- ContentTypeSchema
Schema -- "<code>modelType: component</code>" ---- ComponentSchema
ContentTypeSchema -- "<code>kind: collectionType</code>" ---- CollectionTypeSchema
ContentTypeSchema -- "<code>kind: singleType</code>" ---- SingleTypeSchema
```
### Properties
Schema types contain useful information that helps other types know how to interact with the Strapi content.
This is facilitated through multiple properties.
#### Options
A set of properties used to configure the schema. It contains information on features activation among other things.
This can be really useful to make the types adapt to a given schema.
For instance, the document service uses the `options.draftAndPublish` property to determine whether it should add publication methods to the service type.
#### Plugin Options
These options provide the ability to alter or enhance the behaviour of the system based on specific values.
If a plugin is enabled, it might bring functionality that can affect how types interact with each other.
For example, it's possible to add or remove certain entity-service filters from the query type based on whether a plugin is enabled.
#### Attributes
Strongly typed schema attributes allows the Type System to infer actual entities types based on their properties.
For instance, a string attribute will resolve to a primitive string in an entity, whereas a repeatable component attribute will resolve to an array of objects.
### Usage
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
<Tabs>
<TabItem value="public" label="Public" default>
When designing public APIs (and in most other scenarios), it's advised to use the high-level schema types found in the `Schema` namespace.
Schema definitions exported from the `Schema` namespace are targeting the dynamic types found in the public schema registries, and will dynamically adapt to the current context while extending the base Schema types.
:::info
If the public registries are empty (_e.g. types are not generated yet, not in the context of a Strapi application, ..._), schema types will fallback to their low-level definitions.
:::
```typescript
import type { Schema } from '@strapi/strapi';
declare const schema: Schema.Schema;
declare const contentType: Schema.ContentType;
declare const component: Schema.Component;
declare function processAnySchema(schema: Schema.Schema): void;
processAnySchema(schema); // ✅
processAnySchema(contentType); // ✅
processAnySchema(component); // ✅
declare function processContentTypeSchema(schema: Schema.ContentType): void;
processContentTypeSchema(schema); // ✅
processContentTypeSchema(contentType); // ✅
processContentTypeSchema(component); // ❌ Error, a component schema is not assignable to a content-type schema
declare function processComponentSchema(schema: Schema.Component): void;
processComponentSchema(schema); // ✅
processComponentSchema(contentType); // ❌ Error, a content-type schema is not assignable to a component schema
processComponentSchema(component); // ✅
```
</TabItem>
<TabItem value="internal" label="Internal">
Schema definitions exported from the `Struct` namespace defines the low level type representation of Strapi schemas.
:::caution
Those types can be useful when you want to validate other types against the base ones, but realistically, the public Schema types should almost always be preferred.
:::
```typescript
import type { Struct } from '@strapi/strapi';
declare const schema: Struct.Schema;
declare const contentType: Struct.ContentTypeSchema;
declare const component: Struct.ComponentSchema;
declare function processAnySchema(schema: Struct.Schema): void;
processAnySchema(schema); // ✅
processAnySchema(contentType); // ✅
processAnySchema(component); // ✅
declare function processContentTypeSchema(schema: Struct.ContentTypeSchema): void;
processContentTypeSchema(schema); // ✅
processContentTypeSchema(contentType); // ✅
processContentTypeSchema(component); // ❌ Error, a component schema is not assignable to a content-type schema
declare function processComponentSchema(schema: Struct.ComponentSchema): void;
processComponentSchema(schema); // ✅
processComponentSchema(contentType); // ❌ Error, a content-type schema is not assignable to a component schema
processComponentSchema(component); // ✅
```
</TabItem>
</Tabs>

View File

@ -0,0 +1,190 @@
---
title: UID
tags:
- typescript
- type system
- type
- concepts
toc_max_heading_level: 4
---
:::note
On this page, **a resource** is considered as **anything that can be identified by a UID**.
This includes (but is not limited to) controllers, schema, services, policies, middlewares, etc...
:::
In the Type System, UIDs play a crucial role in referencing various resources (such as schema and entities) by attaching a unique identifier.
To put it simply, a UID is a unique (string) literal key used to identify, locate, or access a particular resource within the system.
:::tip
This makes it the perfect tool to index type registries or to use as a type parameter for resource-centric types.
:::
### Format
A UID is composed of 3 different parts:
1. A namespace ([link](#1-namespaces))
2. A separator ([link](#2-separators))
3. A name ([link](#3-names))
#### 1. Namespaces
There are two main families of namespaces:
- Scoped (_aka parametrized_)
- Non-scoped (_aka constants_)
A third kind exists for component UIDs and is defined only by a dynamic category: `<category>`.
##### Scoped
Scoped namespaces are defined by a base name, followed by a separator (`::`) and any string.
In Strapi there are two of them:
| Name | Definition | Description |
| ------ | :---------------: | ---------------------------------------------------- |
| API | `api::<scope>` | Represent a resource present in the `<scope>` API |
| Plugin | `plugin::<scope>` | Represent a resource present in the `<scope>` plugin |
##### Non-Scoped
These namespaces are used as a simple prefix and define the origin of a resource.
Strapi uses three of them to create UIDs
| Name | Definition | Description |
| ------ | :--------: | ----------------------------------------------------------------------------- |
| Strapi | `strapi` | Represent a resource present in the core of strapi |
| Admin | `admin` | Represent a resource present in Strapi admin |
| Global | `global` | Rarely used (_e.g. policies or middlewares_), it represents a global resource |
#### 2. Separators
There are only two kind of separators:
- `.` for scoped namespaces (`api::<scope>`, `plugin::<scope>`) and components (`<category>`)
- `::` for others (`admin`, `strapi`, `global`)
#### 3. Names
UID names can be any alphanumeric string.
:::caution
A UID is unique for the kind of resource it's attached to, but **different resource can share the same UID**.
For instance, it's completely possible to have both a `service` and a `schema` identified by `api::article.article`.
Since **TypeScript is a structural type system**, it means that **different UIDs resolving to the same literal type can match each other**, thus making it possible to send a service UID to a method expecting a schema UID (if they share the same format).
:::
### Compatibility Table
The following table shows, for each kind of UID, what resource they can be associated with.
:::note
ContentType and Component are referring to both the related schema and entity.
:::
| | ContentType | Component | Middleware | Policy | Controller | Service |
| ------------------------ | :----------------: | :----------------: | :----------------: | :----------------: | :----------------: | :----------------: |
| `api::<scope>.<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| `plugin::<scope>.<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| `<category>.<name>` | :x: | :white_check_mark: | :x: | :x: | :x: | :x: |
| `strapi::<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :x: | :x: |
| `admin::<name>` | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| `global::<name>` | :x: | :x: | :white_check_mark: | :white_check_mark: | :x: | :x: |
### Usage
When referencing resource by their UID you'll need to use the `UID` namespace exported from `@strapi/types`.
```typescript
import type { UID } from '@strapi/types';
```
This namespace contains shortcuts to dynamic UID types built from the public registries so that they always adapt to the current context.
:::danger
The `UID` namespace is designed to be the main interface used by developers.
Do not use the `Internal.UID` namespace except if you know what you're doing (low level extends clause, isolated internal code, etc...).
:::
#### Basic Example
A common usage is to declare a function that takes a UID as a parameter.
For our example, let's imagine we want to fetch an entity based on the provided resource UID.
```typescript
import type { UID, Data } from '@strapi/types';
declare function fetch(uid: UID.ContentType): Data.ContentType;
```
:::tip
To find an exhaustive list of available UID types, take a look at the [related API reference](http://foo)
:::
#### Parameter Type Inference
Now let's say we want to adapt the return type of our function, so that it matches the given UID.
```typescript
fetch('api::article.article');
// ^ this should return a Data.Entity<'api::article.article'>
fetch('admin::user');
// ^ this should return a Data.Entity<'admin::user'>
```
To do that, we'll need the function to be able to provide us with the current `uid` type based on usage.
```typescript
import type { UID, Data } from '@strapi/types';
declare function fetch<T extends UID.ContentType>(uid: T): Data.ContentType<T>;
```
So what's changed here?
1. We've forced the `uid` type to be inferred upon usage and stored in a type variable called `T`.
2. We've then re-used `T` to parametrize the `Data.ContentType` type.
`fetch` will now always return the correct entity depending on which `UID` is sent.
:::caution
When writing actual code, avoid using `T` as a type variable, and always use meaningful names that will help other developers understand what the variable represents.
For instance, in our example we could use `TContentTypeUID` instead of just `T`.
:::
#### Going Further
It's completely possible to reference `T` in other generic parameters.
Let's add the possibility to select which fields we want to return for our entity.
```typescript
import type { UID, Data, Schema } from '@strapi/types';
declare function fetch<T extends UID.ContentType, F extends Schema.AttributeNames<T>>(
uid: T,
fields: F[]
): Data.ContentType<T>;
```
:::tip
You may have noticed that we're using the inferred UID type (`T`) to reference both:
- An entity (`Data.Entity<T>`)
- A schema (`Schema.AttributeNames<T>`)
This is because they share the same format and can be used interchangeably.
For more information, take a look at the [format](#format) and [compatibility table](#compatibility-table) sections.
:::

View File

@ -0,0 +1,325 @@
---
title: Public Registries
tags:
- typescript
- type system
- type
- concepts
- public
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
### Context
#### Why?
The Strapi Type System is designed to provide developers with a fully customizable experience.
It's engineered to adapt and modify its type definitions automatically according to the context of each application and associated resources.
This adaptability extends to various components such as schemas, services, controllers, and more.
:::note
See the [type system principles](../philosophy#key-principles) page for more information about the mission.
:::
#### How?
To meet this requirement, the Type System employs "public registries".
In simple terms, public registries are basic indexed interface definitions that are made publicly available by the types package.
```typescript
export interface MyRegistry {
[key: string]: GenericResourceDefinition;
}
```
Every resource comes with its own registry that has a set of rules acting as the default state (the index).
Because every registry can be augmented (publicly exported), developers have the freedom to inject their own definitions.
Doing so allows Strapi APIs to respond in a way that aligns with the developers' custom definitions.
:::info[Did you know?]
The `Schema` and `UID` namespaces rely solely on public registries to infer their type definitions.
This is why it's heavily encouraged to use them over their low level counter-parts.
:::
### How it works
#### Architecture
```mermaid
flowchart TB;
%% Main node
ContentTypeRegistry("Content-Types Registry")
%% App
subgraph Application
%% Nodes
UserApp["User Application"]
UserSchema(["Application Types"])
%% Links
UserApp -- "generates" ---> UserSchema
end
%% Type System
subgraph TypesPackage["@strapi/types"]
%% Nodes
TypeSystem["Type System"]
Types{{Types}}
Registries{{Registries}}
UID{{UID}}
Schema{{Schema}}
%% Links
TypeSystem -- "exports" ---> Types & Registries
Types -- "exports" ---> UID & Schema -- "uses" ---> ContentTypeRegistry
Registries -- "exports" ----> ContentTypeRegistry
end
%% Strapi
subgraph StrapiPackage["@strapi/strapi"]
%% Nodes
Strapi["Strapi"]
APIs[["APIs"]]
%% Links
Strapi -- "exports" --> APIs
APIs -- "uses" --> Types
end
%% This link needs to be placed last to preserve the layout
UserSchema -- "augments" -----> ContentTypeRegistry
```
#### Usage
##### 1. Registry Definition
Creating a new registry is as simple as exporting an indexed interface from the right namespace.
Let's declare the content-type schema registry.
It should accept:
- Content-type UIDs as keys
- Content-type schemas as values
```ts title="@strapi/types/public/registries.ts"
import type { Internal, Struct } from '@strapi/types';
export interface ContentTypeSchemas {
[key: Internal.UID.ContentType]: Struct.ContentTypeSchema;
}
```
:::note
We use low level types to define our index (`Internal`/`Struct`) to keep it as generic as possible.
:::
##### 2. Dynamic Type Definitions
###### UID.ContentType
To define `UID.ContentType`, we extract every key (`Internal.Registry.Keys`) from the public content-type registry (`Public.ContentTypeSchemas`) that matches with the base definition of a content-type UID (`Internal.UID.ContentType`).
```ts title="@strapi/types/uid.ts"
import type { Internal, Public } from '@strapi/types';
export type ContentType = Internal.Registry.Keys<
Public.ContentTypeSchemas,
Internal.UID.ContentType
>;
```
:::note
Only selecting keys that extend `Internal.UID.ContentType` ensures we don't end up with manually added malformed keys, and tells the type-checker we're confident about what's in our union type.
:::
###### Schema.ContentType
To declare `Schema.ContentType`, we simply query the content-type schema registry (`Public.ContentTypeSchemas`) with the provided content-type UID (`TUID`).
:::note
Since `UID.ContentType` (`TUID`) is [dynamically built based on actual keys](#uidcontenttype), we know for sure that there will be a valid corresponding schema in the registry.
:::
```ts title="@strapi/types/schema.ts"
import type { UID, Public } from '@strapi/types';
export type ContentType<TUID extends UID.ContentType> = Public.ContentTypeSchemas[TUID];
```
##### 3. API Design
To create types for a dynamic API (_aka one that reacts to its context_), simply use dynamic type definitions such as `UID`, `Data` or `Schema`.
```ts title="@strapi/core/document-service.ts"
import type { Data, UID } from '@strapi/types';
export type findOne<TUID extends UID.ContentType>(uid: TUID): Data.ContentType<TUID>;
```
:::caution
Remember to use dynamic type definitions (`UID`, `Data`, `Schema`) and not static ones (`Internal`, `Struct`).
:::
:::info[Reminder]
Registries are **indexed**, which means that:
- **When augmented** (_e.g. in users' applications_), they'll return **strongly typed values** that correspond to the defined types.
- **When empty** (_e.g. in Strapi codebase_), they'll return **generic low level types** based on their index definition.
<Tabs>
<TabItem value="augmented" label="Augmented">
```ts
import type { UID } from '@strapi/types';
const uid: UID.ContentType;
// ^ 'api::article.article' | 'admin::user'
```
</TabItem>
<TabItem value="empty" label="Empty">
```ts
import type { UID } from '@strapi/types';
const uid: UID.ContentType;
// ^ `admin::${string}` | `api::${string}.${string}` | `plugin::${string}.${string}` | `strapi::${string}`
```
</TabItem>
</Tabs>
:::
##### 4. Type Augmentation
###### Manual Augmentation
It's possible to manually augment the public registries to create tailor-made experiences.
```ts title="my-app/index.d.ts"
import type { Struct } from '@strapi/strapi';
interface ApiArticleArticle extends Struct.ContentTypeSchema {
// ...
}
declare module '@strapi/strapi' {
export module Public {
export interface ContentTypeSchemas {
'api::article.article': ApiArticleArticle;
}
}
}
```
This will force every type that depends on the `Public.ContentTypeSchemas` registry to recognize `'api::article.article'` as the only valid UID and `ApiArticleArticle` the only valid schema.
:::note
In the context of a Strapi application, developers are strongly encouraged to use types exported by `@strapi/strapi` and not `@strapi/types`.
This is to allow having both an internal (`@strapi/types`) and a public (`@strapi/strapi`) types API.
:::
###### Automated Augmentation
To ease the registries augmentation, Strapi offers an automated way of generating types and extending the registries.
The process will generate type definitions based on the user application state (`schemas`), then augment the registry using the created types.
<Tabs>
<TabItem value="manual" label="Manual Run">
Generate the types once.
```shell title="my-app/"
yarn strapi ts:generate-types
```
</TabItem>
<TabItem value="dev" label="During Development">
Start the application in dev mode, and generate types on every server restart.
Useful when working with the content-type builder.
```shell title="my-app/"
yarn develop
```
</TabItem>
</Tabs>
The generated types will automatically augment the corresponding registries.
```ts title="my-app/types/generated/contentTypes.d.ts"
import type { Struct } from '@strapi/strapi';
interface ApiArticleArticle extends Struct.ContentTypeSchema {
// ...
}
interface AdminUser extends Struct.ContentTypeSchema {
// ...
}
declare module '@strapi/strapi' {
export module Public {
export interface ContentTypeSchemas {
'api::article.article': ApiArticleArticle;
'admin::user': AdminUser;
}
}
}
```
---
When coupling everything together, the end result is a TypeScript developer experience automatically adjusted to the current context.
<!-- prettier-ignore-start -->
<Tabs>
<TabItem value="app" label="User Application">
```ts title="my-app/src/index.ts"
export default () => ({
bootstrap() {
strapi.findOne('ap');
// ^ TypeScript will autocomplete with "api::article.article"
strapi.findOne('ad');
// ^ TypeScript will autocomplete with "admin::user"
strapi.findOne('api::blog.blog');
// ^ Error, TypeScript will complain
}
})
```
</TabItem>
<TabItem value="strapi" label="Strapi Codebase">
```ts title="@strapi/strapi/document-service.ts"
import type { UID } from '@strapi/types';
export const findOne<TUID extends UID.ContentType>(uid: TUID) {
// ...
}
findOne('admin::foo');
// ^ Valid, matches 'admin::${string}'
findOne('plugin::bar.bar');
// ^ Valid, matches 'plugin::${string}.${string}'
findOne('baz');
// ^ Error, does not correspond to any content-type UID format
```
</TabItem>
</Tabs>
<!-- prettier-ignore-end -->

View File

@ -0,0 +1,15 @@
---
title: Concepts
tags:
- typescript
- type system
- type
- concepts
---
```mdx-code-block
import DocCardList from '@theme/DocCardList';
import { useCurrentSidebarCategory } from '@docusaurus/theme-common';
<DocCardList items={useCurrentSidebarCategory().items} />
```

View File

@ -17,6 +17,7 @@ const config = {
organizationName: 'strapi',
projectName: 'strapi',
trailingSlash: false,
themes: ['@docusaurus/theme-mermaid'],
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
@ -25,6 +26,9 @@ const config = {
defaultLocale: 'en',
locales: ['en'],
},
markdown: {
mermaid: true,
},
plugins: [
() => ({
name: 'resolve-react',

View File

@ -29,6 +29,7 @@
"@cmfcmf/docusaurus-search-local": "1.1.0",
"@docusaurus/core": "3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@docusaurus/theme-mermaid": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^1.1.1",
"prism-react-renderer": "^2.1.0",

File diff suppressed because it is too large Load Diff

View File

@ -155,7 +155,7 @@ export default ({ action, ability, model }: any) => {
traverseEntity(omitHiddenFields, ctx),
// Remove not allowed fields (RBAC)
traverseEntity(removeDisallowedFields(permittedFields), ctx),
// Remove roles from createdBy & updateBy fields
// Remove roles from createdBy & updatedBy fields
omitCreatorRoles
);
};

View File

@ -45,18 +45,18 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
const mainFieldValue = version.data[mainField];
const getBackLink = (): To => {
const getNextNavigation = (): To => {
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
if (collectionType === COLLECTION_TYPES) {
return {
pathname: `../${collectionType}/${version.contentType}/${version.relatedDocumentId}`,
pathname: `/content-manager/${collectionType}/${version.contentType}/${version.relatedDocumentId}`,
search: pluginsQueryParams,
};
}
return {
pathname: `../${collectionType}/${version.contentType}`,
pathname: `/content-manager/${collectionType}/${version.contentType}`,
search: pluginsQueryParams,
};
};
@ -64,16 +64,17 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
const handleRestore = async () => {
try {
const response = await restoreVersion({
documentId: version.relatedDocumentId,
collectionType,
params: {
versionId: version.id,
documentId: version.relatedDocumentId,
contentType: version.contentType,
},
body: { contentType: version.contentType },
});
if ('data' in response) {
navigate(`/content-manager/${collectionType}/${slug}/${response.data.data?.documentId}`);
navigate(getNextNavigation());
toggleNotification({
type: 'success',
@ -137,7 +138,7 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
startIcon={<ArrowLeft />}
as={NavLink}
// @ts-expect-error - types are not inferred correctly through the as prop.
to={getBackLink()}
to={getNextNavigation()}
>
{formatMessage({
id: 'global.back',

View File

@ -80,7 +80,7 @@ describe('VersionHeader', () => {
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute(
'href',
'/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr'
'/content-manager/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr'
);
});
@ -111,7 +111,7 @@ describe('VersionHeader', () => {
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute(
'href',
'/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr?plugins[i18n][locale]=en'
'/content-manager/collection-types/api::kitchensink.kitchensink/pcwmq3rlmp5w0be3cuplhnpr?plugins[i18n][locale]=en'
);
});
@ -158,7 +158,10 @@ describe('VersionHeader', () => {
expect(await screen.findByText('Test Title (homepage)')).toBeInTheDocument();
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute('href', '/single-types/api::homepage.homepage');
expect(backLink).toHaveAttribute(
'href',
'/content-manager/single-types/api::homepage.homepage'
);
});
it('should display the correct title and subtitle for a localized entry', async () => {
@ -185,7 +188,7 @@ describe('VersionHeader', () => {
const backLink = screen.getByRole('link', { name: 'Back' });
expect(backLink).toHaveAttribute(
'href',
'/single-types/api::homepage.homepage?plugins[i18n][locale]=en'
'/content-manager/single-types/api::homepage.homepage?plugins[i18n][locale]=en'
);
});
});

View File

@ -1,9 +1,17 @@
import { Data } from '@strapi/types';
import {
GetHistoryVersions,
RestoreHistoryVersion,
} from '../../../../shared/contracts/history-versions';
import { COLLECTION_TYPES } from '../../constants/collections';
import { contentManagerApi } from '../../services/api';
interface RestoreVersion extends RestoreHistoryVersion.Request {
documentId: Data.ID;
collectionType?: string;
}
const historyVersionsApi = contentManagerApi.injectEndpoints({
endpoints: (builder) => ({
getHistoryVersions: builder.query<
@ -21,23 +29,27 @@ const historyVersionsApi = contentManagerApi.injectEndpoints({
},
providesTags: ['HistoryVersion'],
}),
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreHistoryVersion.Request>(
{
query({ params, body }) {
return {
url: `/content-manager/history-versions/${params.versionId}/restore`,
method: 'PUT',
data: body,
};
},
invalidatesTags: (_res, _error, { params }) => {
return [
'HistoryVersion',
{ type: 'Document', id: `${params.contentType}_${params.documentId}` },
];
},
}
),
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreVersion>({
query({ params, body }) {
return {
url: `/content-manager/history-versions/${params.versionId}/restore`,
method: 'PUT',
data: body,
};
},
invalidatesTags: (_res, _error, { documentId, collectionType, params }) => {
return [
'HistoryVersion',
{
type: 'Document',
id:
collectionType === COLLECTION_TYPES
? `${params.contentType}_${documentId}`
: params.contentType,
},
];
},
}),
}),
});

View File

@ -1,199 +0,0 @@
import { createHistoryVersionController } from '../history-version';
const mockFindVersionsPage = jest.fn();
// History utils
jest.mock('../../utils', () => ({
getService: jest.fn((_strapi, name) => {
if (name === 'history') {
return {
findVersionsPage: mockFindVersionsPage,
};
}
}),
}));
// Content Manager utils
jest.mock('../../../utils', () => ({
getService: jest.fn((name) => {
if (name === 'permission-checker') {
return {
create: jest.fn(() => ({
cannot: {
read: jest.fn(() => false),
},
sanitizeQuery: jest.fn((query) => query),
})),
};
}
}),
}));
describe('History version controller', () => {
beforeEach(() => {
mockFindVersionsPage.mockClear();
});
describe('findMany', () => {
it('should require contentType and documentId for collection types', () => {
const ctx = {
state: {
userAbility: {},
},
query: {},
};
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'collectionType' })) },
});
// @ts-expect-error partial context
expect(historyVersionController.findMany(ctx)).rejects.toThrow(
/contentType and documentId are required/
);
expect(mockFindVersionsPage).not.toHaveBeenCalled();
});
it('should require contentType for single types', () => {
const ctx = {
state: {
userAbility: {},
},
query: {},
};
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
});
// @ts-expect-error partial context
expect(historyVersionController.findMany(ctx)).rejects.toThrow(/contentType is required/);
expect(mockFindVersionsPage).not.toHaveBeenCalled();
});
});
it('should call findVersionsPage for collection types', async () => {
const ctx = {
state: {
userAbility: {},
},
query: {
documentId: 'document-id',
contentType: 'api::test.test',
},
};
mockFindVersionsPage.mockResolvedValueOnce({
results: [{ id: 'history-version-id' }],
pagination: {
page: 1,
pageSize: 20,
pageCount: 1,
total: 0,
},
});
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'collectionType' })) },
});
// @ts-expect-error partial context
const response = await historyVersionController.findMany(ctx);
expect(mockFindVersionsPage).toHaveBeenCalled();
expect(response.data.length).toBe(1);
expect(response.meta.pagination).toBeDefined();
});
it('should call findVersionsPage for single types', async () => {
const ctx = {
state: {
userAbility: {},
},
query: {
contentType: 'api::test.test',
},
};
mockFindVersionsPage.mockResolvedValueOnce({
results: [{ id: 'history-version-id' }],
pagination: {
page: 1,
pageSize: 20,
pageCount: 1,
total: 0,
},
});
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
});
// @ts-expect-error partial context
const response = await historyVersionController.findMany(ctx);
expect(mockFindVersionsPage).toHaveBeenCalled();
expect(response.data.length).toBe(1);
expect(response.meta.pagination).toBeDefined();
});
it('applies pagination params', async () => {
const ctx = {
state: {
userAbility: {},
},
query: {
contentType: 'api::test.test',
},
};
const historyVersionController = createHistoryVersionController({
// @ts-expect-error - we're not mocking the entire strapi object
strapi: { getModel: jest.fn(() => ({ kind: 'singleType' })) },
});
/**
* Applies default pagination params
*/
mockFindVersionsPage.mockResolvedValueOnce({
results: [],
pagination: {
page: 1,
pageSize: 20,
},
});
// @ts-expect-error partial context
const mockResponse = await historyVersionController.findMany(ctx);
expect(mockFindVersionsPage).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
pageSize: 20,
})
);
expect(mockResponse.meta.pagination.page).toBe(1);
expect(mockResponse.meta.pagination.pageSize).toBe(20);
/**
* Prevents invalid pagination params
*/
mockFindVersionsPage.mockResolvedValueOnce({
results: [],
pagination: {},
});
// @ts-expect-error partial context
await historyVersionController.findMany({
...ctx,
query: { ...ctx.query, page: '-1', pageSize: '1000' },
});
expect(mockFindVersionsPage).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
pageSize: 20,
})
);
});
});

View File

@ -37,13 +37,13 @@ const createHistoryVersionController = ({ strapi }: { strapi: Core.Strapi }) =>
return {
async findMany(ctx) {
const contentTypeUid = ctx.query.contentType as UID.ContentType;
const isSingleType = strapi.getModel(contentTypeUid).kind === 'singleType';
const isSingleType = strapi.getModel(contentTypeUid)?.kind === 'singleType';
if (isSingleType && !contentTypeUid) {
throw new errors.ForbiddenError('contentType is required');
}
if (!contentTypeUid && !ctx.query.documentId) {
if (!isSingleType && (!contentTypeUid || !ctx.query.documentId)) {
throw new errors.ForbiddenError('contentType and documentId are required');
}
@ -68,7 +68,15 @@ const createHistoryVersionController = ({ strapi }: { strapi: Core.Strapi }) =>
...getValidPagination({ page: params.page, pageSize: params.pageSize }),
});
return { data: results, meta: { pagination } };
return {
data: await Promise.all(
results.map(async (result) => ({
...result,
data: await permissionChecker.sanitizeOutput(result.data),
}))
),
meta: { pagination },
};
},
async restoreVersion(ctx) {

View File

@ -47,6 +47,9 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
};
const localesService = strapi.plugin('i18n')?.service('locales');
const getDefaultLocale = async () => (localesService ? localesService.getDefaultLocale() : null);
const getLocaleDictionary = async () => {
if (!localesService) return {};
@ -163,8 +166,9 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
? { documentId: result.documentId, locale: context.params?.locale }
: { documentId: context.params.documentId, locale: context.params?.locale };
const defaultLocale = localesService ? await localesService.getDefaultLocale() : null;
const defaultLocale = await getDefaultLocale();
const locale = documentContext.locale || defaultLocale;
const document = await strapi.documents(contentTypeUid).findOne({
documentId: documentContext.documentId,
locale,
@ -251,6 +255,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
results: HistoryVersions.HistoryVersionDataResponse[];
pagination: HistoryVersions.Pagination;
}> {
const locale = params.locale || (await getDefaultLocale());
const [{ results, pagination }, localeDictionary] = await Promise.all([
query.findPage({
...params,
@ -258,7 +263,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
$and: [
{ contentType: params.contentType },
...(params.documentId ? [{ relatedDocumentId: params.documentId }] : []),
...(params.locale ? [{ locale: params.locale }] : []),
...(locale ? [{ locale }] : []),
],
},
populate: ['createdBy'],
@ -497,6 +502,7 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
const data = omit(['id', ...Object.keys(schemaDiff.removed)], dataWithoutMissingRelations);
const restoredDocument = await strapi.documents(version.contentType).update({
documentId: version.relatedDocumentId,
locale: version.locale,
data,
});

View File

@ -16,7 +16,7 @@ export interface CreateHistoryVersion {
componentsSchemas: Record<`${string}.${string}`, Struct.SchemaAttributes>;
}
interface Locale {
export interface Locale {
name: string;
code: string;
}
@ -82,7 +82,6 @@ export declare namespace RestoreHistoryVersion {
export interface Request {
params: {
versionId: Data.ID;
documentId: Data.ID;
contentType: UID.ContentType;
};
body: {

View File

@ -142,12 +142,39 @@ const objectParam_10 = [...objectParam_10_2];
strapi.entityService.findOne(...[...objectParam_10]);
Case: find, create, update, delete with entityId as first argument
strapi.entityService.findMany(uid, {
fields: ["id", "name", "description"],
populate: ["author", "comments"],
publicationState: "preview",
});
strapi.entityService.create(uid, {
data: {
name: "John Doe",
age: 30,
},
});
strapi.entityService.update(uid, entityId, {
data: {
name: "John Doe",
age: 30,
},
});
strapi.entityService.delete(uid, entityId);
strapi.entityService.findOne(uid, entityId);
*/
const movedFunctions = ['findOne', 'find', 'count', 'create', 'update', 'delete'];
const movedFunctions = ['findOne', 'findMany', 'count', 'create', 'update', 'delete'];
const functionsWithEntityId = ['findOne', 'update', 'delete'];
const transformDeclaration = (path: ASTPath<any>, name: any, j: JSCodeshift) => {
const declaration = findClosesDeclaration(path, name, j);
const declaration = findClosestDeclaration(path, name, j);
if (!declaration) {
return;
@ -222,7 +249,7 @@ const transformObjectParam = (path: ASTPath<any>, expression: ObjectExpression,
break;
}
case j.Identifier.check(prop.value): {
const declaration = findClosesDeclaration(path, prop.value.name, j);
const declaration = findClosestDeclaration(path, prop.value.name, j);
if (!declaration) {
return;
@ -253,7 +280,7 @@ const transformObjectParam = (path: ASTPath<any>, expression: ObjectExpression,
});
};
const findClosesDeclaration = (path: ASTPath<any>, name: string, j) => {
const findClosestDeclaration = (path: ASTPath<any>, name: string, j) => {
// find Identifier declaration
const scope = path.scope.lookup(name);
@ -318,7 +345,7 @@ const transform: Transform = (file, api) => {
case j.Identifier.check(arg.argument): {
const identifier = arg.argument;
const declaration = findClosesDeclaration(path, identifier.name, j);
const declaration = findClosestDeclaration(path, identifier.name, j);
if (!declaration) {
return arg;
@ -351,6 +378,42 @@ const transform: Transform = (file, api) => {
const [docUID, ...rest] = resolvedArgs;
// function with entityId as first argument
if (
j.Identifier.check(path.value.callee.property) &&
functionsWithEntityId.includes(path.value.callee.property.name)
) {
rest.splice(0, 1);
// in case no extra params are passed in the function e.g delete(uid, entityId)
if (rest.length === 0) {
rest.push(
j.objectExpression.from({
properties: [],
})
);
}
const params = rest[0];
const placeholder = j.objectProperty(j.identifier('documentId'), j.literal('__TODO__'));
// add documentId to params with a placeholder
if (j.ObjectExpression.check(params)) {
params.properties.unshift(placeholder);
} else if (j.Identifier.check(params)) {
const declaration = findClosestDeclaration(path, params.name, j);
if (!declaration) {
return;
}
if (j.ObjectExpression.check(declaration.init)) {
declaration.init.properties.unshift(placeholder);
}
}
}
path.value.arguments.forEach((arg) => {
transformElement(path, arg, j);
});

View File

@ -0,0 +1,442 @@
import { createStrapiInstance } from 'api-tests/strapi';
import { createAuthRequest } from 'api-tests/request';
import { createUtils, describeOnCondition } from 'api-tests/utils';
import { createTestBuilder } from 'api-tests/builder';
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
const collectionTypeUid = 'api::product.product';
const collectionTypeModel = {
draftAndPublish: true,
singularName: 'product',
pluralName: 'products',
displayName: 'Product',
kind: 'collectionType',
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
name: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
description: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
},
};
const singleTypeUid = 'api::homepage.homepage';
const singleTypeModel = {
draftAndPublish: true,
singularName: 'homepage',
pluralName: 'homepages',
displayName: 'Homepage',
kind: 'singleType',
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
title: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
subtitle: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
},
};
interface CreateEntryArgs {
uid: string;
data: Record<string, unknown>;
isCollectionType?: boolean;
}
interface UpdateEntryArgs extends CreateEntryArgs {
documentId?: string;
locale?: string;
}
describeOnCondition(edition === 'EE')('History API', () => {
const builder = createTestBuilder();
let strapi;
let rq;
let collectionTypeDocumentId;
let singleTypeDocumentId;
const createEntry = async ({ uid, data, isCollectionType = true }: CreateEntryArgs) => {
const type = isCollectionType ? 'collection-types' : 'single-types';
const { body } = await rq({
method: 'POST',
url: `/content-manager/${type}/${uid}`,
body: data,
});
return body;
};
const updateEntry = async ({ uid, documentId, data, locale }: UpdateEntryArgs) => {
const type = documentId ? 'collection-types' : 'single-types';
const params = documentId ? `${type}/${uid}/${documentId}` : `${type}/${uid}`;
const { body } = await rq({
method: 'PUT',
url: `/content-manager/${params}`,
body: data,
qs: { locale },
});
return body;
};
const createUserAndReq = async (
userName: string,
permissions: { action: string; subject: string }[]
) => {
const utils = createUtils(strapi);
const role = await utils.createRole({
name: `role-${userName}`,
description: `Role with restricted permissions for ${userName}`,
});
const rolePermissions = await utils.assignPermissionsToRole(role.id, permissions);
Object.assign(role, { permissions: rolePermissions });
const user = await utils.createUser({
firstname: userName,
lastname: 'User',
email: `${userName}.user@strapi.io`,
roles: [role.id],
});
const rq = await createAuthRequest({ strapi, userInfo: user });
return rq;
};
beforeAll(async () => {
await builder.addContentTypes([collectionTypeModel, singleTypeModel]).build();
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
// Create another locale
const localeService = strapi.plugin('i18n').service('locales');
await localeService.create({ code: 'fr', name: 'French' });
// Create a collection type to create an initial history version
const collectionType = await createEntry({
uid: collectionTypeUid,
data: {
name: 'Product 1',
},
});
// Update the single type to create an initial history version
const singleType = await updateEntry({
uid: singleTypeUid,
data: {
title: 'Welcome',
},
isCollectionType: false,
});
// Set the documentIds to test
collectionTypeDocumentId = collectionType.data.documentId;
singleTypeDocumentId = singleType.data.documentId;
// Update to create history versions for entries in different locales
await Promise.all([
updateEntry({
documentId: collectionTypeDocumentId,
uid: collectionTypeUid,
data: {
description: 'Hello',
},
}),
updateEntry({
documentId: collectionTypeDocumentId,
uid: collectionTypeUid,
locale: 'fr',
data: {
name: 'Produit 1',
},
}),
updateEntry({
documentId: collectionTypeDocumentId,
uid: collectionTypeUid,
locale: 'fr',
data: {
description: 'Coucou',
},
}),
updateEntry({
uid: singleTypeUid,
data: {
description: 'Wow, amazing!',
},
isCollectionType: false,
}),
updateEntry({
uid: singleTypeUid,
data: {
title: 'Bienvenue',
},
isCollectionType: false,
locale: 'fr',
}),
updateEntry({
uid: singleTypeUid,
data: {
description: 'Super',
},
isCollectionType: false,
locale: 'fr',
}),
]);
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
describe('Find many history versions', () => {
test('A collection type throws with invalid query params', async () => {
const noDocumentId = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}`,
});
const noContentTypeUid = await rq({
method: 'GET',
url: `/content-manager/history-versions/?documentId=${collectionTypeDocumentId}`,
});
expect(noDocumentId.statusCode).toBe(403);
expect(noContentTypeUid.statusCode).toBe(403);
});
test('A single type throws with invalid query params', async () => {
const singleTypeNoContentTypeUid = await rq({
method: 'GET',
url: `/content-manager/history-versions/`,
});
expect(singleTypeNoContentTypeUid.statusCode).toBe(403);
});
test('Throws without read permissions', async () => {
const restrictedRq = await createUserAndReq('restricted', []);
const res = await restrictedRq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`,
});
expect(res.statusCode).toBe(403);
});
test('A collection type finds many versions in the default locale', async () => {
const collectionType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}`,
});
expect(collectionType.statusCode).toBe(200);
expect(collectionType.body.data).toHaveLength(2);
expect(collectionType.body.data[0].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[1].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[0].locale.code).toBe('en');
expect(collectionType.body.data[1].locale.code).toBe('en');
expect(collectionType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('A collection type finds many versions in the provided locale', async () => {
const collectionType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&locale=fr`,
});
expect(collectionType.statusCode).toBe(200);
expect(collectionType.body.data).toHaveLength(2);
expect(collectionType.body.data[0].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[1].relatedDocumentId).toBe(collectionTypeDocumentId);
expect(collectionType.body.data[0].locale.code).toBe('fr');
expect(collectionType.body.data[1].locale.code).toBe('fr');
expect(collectionType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('A single type finds many versions in the default locale', async () => {
const singleType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${singleTypeUid}`,
});
expect(singleType.statusCode).toBe(200);
expect(singleType.body.data).toHaveLength(2);
expect(singleType.body.data[0].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[1].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[0].locale.code).toBe('en');
expect(singleType.body.data[1].locale.code).toBe('en');
expect(singleType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('A single type finds many versions in the provided locale', async () => {
const singleType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${singleTypeUid}&locale=fr`,
});
expect(singleType.statusCode).toBe(200);
expect(singleType.body.data).toHaveLength(2);
expect(singleType.body.data[0].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[1].relatedDocumentId).toBe(singleTypeDocumentId);
expect(singleType.body.data[0].locale.code).toBe('fr');
expect(singleType.body.data[1].locale.code).toBe('fr');
expect(singleType.body.meta.pagination).toEqual({
page: 1,
pageSize: 20,
pageCount: 1,
total: 2,
});
});
test('Applies pagination params', async () => {
const collectionType = await rq({
method: 'GET',
url: `/content-manager/history-versions/?contentType=${collectionTypeUid}&documentId=${collectionTypeDocumentId}&page=1&pageSize=1`,
});
expect(collectionType.body.data).toHaveLength(1);
expect(collectionType.body.meta.pagination).toEqual({
page: 1,
pageSize: 1,
pageCount: 2,
total: 2,
});
});
});
describe('Restore a history version', () => {
test('Throws with invalid body', async () => {
const res = await rq({
method: 'PUT',
url: `/content-manager/history-versions/1/restore`,
body: {},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
data: null,
error: {
status: 400,
name: 'ValidationError',
message: 'contentType is required',
},
});
});
test('Throws without update permissions', async () => {
const restrictedRq = await createUserAndReq('read', [
{ action: 'plugin::content-manager.explorer.read', subject: collectionTypeUid },
]);
const res = await restrictedRq({
method: 'PUT',
url: `/content-manager/history-versions/1/restore`,
body: {
contentType: collectionTypeUid,
},
});
expect(res.statusCode).toBe(403);
expect(res.body).toMatchObject({
data: null,
error: {
status: 403,
name: 'ForbiddenError',
message: 'Forbidden',
},
});
});
test('Restores a history version in the default locale', async () => {
const currentDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId });
await rq({
method: 'PUT',
url: `/content-manager/history-versions/1/restore`,
body: {
contentType: collectionTypeUid,
},
});
const restoredDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId });
expect(currentDocument.description).toBe('Hello');
expect(restoredDocument.description).toBe(null);
});
test('Restores a history version in the provided locale', async () => {
const currentDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId, locale: 'fr' });
await rq({
method: 'PUT',
url: `/content-manager/history-versions/4/restore`,
body: {
contentType: collectionTypeUid,
},
});
const restoredDocument = await strapi
.documents(collectionTypeUid)
.findOne({ documentId: collectionTypeDocumentId, locale: 'fr' });
expect(currentDocument.description).toBe('Coucou');
expect(restoredDocument.description).toBe(null);
});
});
});