feat(content-releases): add findOne endpoint (#18821)

This commit is contained in:
markkaylor 2023-11-21 09:18:12 +01:00 committed by GitHub
parent f9fb2e7c49
commit 30e8a6321d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 292 additions and 164 deletions

View File

@ -56,6 +56,11 @@ packages/core/content-releases/server/src/routes/release.ts
}
```
**Get a single release**
- method: `GET`
- endpoint: `/content-releases/:id`
**Create a release**:
- method: `POST`
@ -72,11 +77,10 @@ packages/core/content-releases/server/src/routes/release.ts
**Create a release action**
- method: `POST`
- endpoint: `/content-releases/release-actions/`
- endpoint: `/content-releases/:releaseId/actions`
- body:
```ts
{
releaseId: number,
entry: {
id: number,
contentType: string

View File

@ -46,6 +46,7 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
});
if ('data' in response) {
// When the response returns an object with 'data', handle success
toggleNotification({
type: 'success',
message: formatMessage({
@ -53,13 +54,16 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
defaultMessage: 'Release created.',
}),
});
push(`/plugins/content-releases/${response.data.data.id}`);
} else if (isErrorAxiosError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'warning',
message: formatAPIError(response.error),
});
} else {
// Otherwise, the response returns an object with 'error', handle a generic error
toggleNotification({
type: 'warning',
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }),

View File

@ -3,7 +3,11 @@ import { createApi } from '@reduxjs/toolkit/query/react';
import { pluginId } from '../pluginId';
import { axiosBaseQuery } from '../utils/data';
import type { CreateRelease, GetAllReleases } from '../../../shared/contracts/releases';
import type {
CreateRelease,
GetReleases,
ReleaseDataResponse,
} from '../../../shared/contracts/releases';
const releaseApi = createApi({
reducerPath: pluginId,
@ -11,7 +15,7 @@ const releaseApi = createApi({
tagTypes: ['Releases'],
endpoints: (build) => {
return {
getRelease: build.query<GetAllReleases.Response, undefined>({
getRelease: build.query<GetReleases.Response, undefined>({
query() {
return {
url: '/content-releases',
@ -20,7 +24,7 @@ const releaseApi = createApi({
},
providesTags: ['Releases'],
}),
createRelease: build.mutation<CreateRelease.Response, CreateRelease.Request['body']>({
createRelease: build.mutation<{ data: ReleaseDataResponse }, CreateRelease.Request['body']>({
query(data) {
return {
url: '/content-releases',

View File

@ -20,22 +20,22 @@ const axiosBaseQuery = async <TData = any, TSend = any>({
if (method === 'POST') {
const res = await post<TData, AxiosResponse<TData>, TSend>(url, data, config);
return res;
return { data: res.data };
}
if (method === 'DELETE') {
const res = await del<TData, AxiosResponse<TData>, TSend>(url, config);
return res;
return { data: res.data };
}
if (method === 'PUT') {
const res = await put<TData, AxiosResponse<TData>, TSend>(url, data, config);
return res;
return { data: res.data };
}
/**
* Default is GET.
*/
const res = await get<TData, AxiosResponse<TData>, TSend>(url, config);
return res;
return { data: res.data };
} catch (error) {
const err = error as AxiosError;

View File

@ -1,9 +1,16 @@
{
"extends": "./admin/tsconfig.json",
"include": ["./admin/src", "./admin/custom.d.ts", "./shared"],
"exclude": ["tests", "**/*.test.*"],
"include": [
"./admin/src",
"./admin/custom.d.ts",
"./shared"
],
"exclude": [
"tests",
"**/*.test.*"
],
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist"
}
}
}

View File

@ -7,6 +7,6 @@
"@tests/*": ["./tests/*"]
}
},
"include": ["src", "../shared", "tests", "custom.d.ts"],
"include": ["./src", "../shared", "tests", "custom.d.ts"],
"exclude": ["node_modules"]
}

View File

@ -1,6 +1,6 @@
import releaseActionController from '../release-action';
describe('release-action controller', () => {
describe('Release Action controller', () => {
describe('create', () => {
beforeEach(() => {
global.strapi = {
@ -48,10 +48,12 @@ describe('release-action controller', () => {
state: {
user: {},
},
params: {
id: 1,
},
request: {
// Mock missing type property
body: {
releaseId: 1,
entry: {
id: 1,
contentType: 'api::category.category',

View File

@ -1,17 +1,17 @@
import type Koa from 'koa';
import { validateReleaseActionCreateSchema } from './validation/release-action';
import { ReleaseActionCreateArgs } from '../../../shared/types';
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
import { getService } from '../utils';
const releaseActionController = {
async create(ctx: Koa.Context) {
const releaseActionArgs: ReleaseActionCreateArgs = ctx.request.body;
const releaseId: CreateReleaseAction.Request['params']['releaseId'] = ctx.params.releaseId;
const releaseActionArgs: CreateReleaseAction.Request['body'] = ctx.request.body;
await validateReleaseActionCreateSchema(releaseActionArgs);
const releaseService = getService('release', { strapi });
const { releaseId, ...action } = releaseActionArgs;
const releaseAction = await releaseService.createAction(releaseId, action);
const releaseAction = await releaseService.createAction(releaseId, releaseActionArgs);
ctx.body = {
data: releaseAction,

View File

@ -1,9 +1,13 @@
import type Koa from 'koa';
import { errors } from '@strapi/utils';
import { RELEASE_MODEL_UID } from '../constants';
import { validateCreateRelease } from './validation/release';
import { ReleaseCreateArgs, UserInfo } from '../../../shared/types';
import type { CreateRelease, GetRelease, Release } from '../../../shared/contracts/releases';
import type { UserInfo } from '../../../shared/types';
import { getService } from '../utils';
type ReleaseWithPopulatedActions = Release & { actions: { count: number } };
const releaseController = {
async findMany(ctx: Koa.Context) {
const permissionsManager = strapi.admin.services.permission.createPermissionsManager({
@ -14,12 +18,54 @@ const releaseController = {
await permissionsManager.validateQuery(ctx.query);
const query = await permissionsManager.sanitizeQuery(ctx.query);
ctx.body = await getService('release', { strapi }).findMany(query);
const { results, pagination } = await getService('release', { strapi }).findMany(query);
// Format the data object
const data = results.map((release: ReleaseWithPopulatedActions) => {
const { actions, ...releaseData } = release;
return {
...releaseData,
actions: {
meta: {
count: actions.count,
},
},
};
});
ctx.body = { data, pagination };
},
async findOne(ctx: Koa.Context) {
const id: GetRelease.Request['params']['id'] = ctx.params.id;
const result = (await getService('release', { strapi }).findOne(
Number(id)
)) as ReleaseWithPopulatedActions | null;
if (!result) {
throw new errors.NotFoundError(`Release not found for id: ${id}`);
}
const { actions, ...release } = result;
// Format the data object
const data = {
...release,
actions: {
meta: {
count: actions.count,
},
},
};
ctx.body = { data };
},
async create(ctx: Koa.Context) {
const user: UserInfo = ctx.state.user;
const releaseArgs: ReleaseCreateArgs = ctx.request.body;
const releaseArgs: CreateRelease.Request['body'] = ctx.request.body;
await validateCreateRelease(releaseArgs);

View File

@ -1,7 +1,6 @@
import { yup, validateYupSchema } from '@strapi/utils';
const releaseActionCreateSchema = yup.object().shape({
releaseId: yup.number().required(),
entry: yup
.object()
.shape({

View File

@ -3,7 +3,7 @@ export default {
routes: [
{
method: 'POST',
path: '/release-actions',
path: '/:releaseId/actions',
handler: 'release-action.create',
config: {
policies: [

View File

@ -33,5 +33,21 @@ export default {
],
},
},
{
method: 'GET',
path: '/:id',
handler: 'release.findOne',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::content-releases.read'],
},
},
],
},
},
],
};

View File

@ -1,4 +1,4 @@
import { ReleaseActionCreateArgs } from '../../../../shared/types';
import type { CreateReleaseAction } from '../../../../shared/contracts/release-actions';
import createReleaseValidationService from '../validation';
const baseStrapiMock = {
@ -10,22 +10,13 @@ const baseStrapiMock = {
contentType: jest.fn(),
};
describe('releaseValidation service', () => {
describe('Release Validation service', () => {
describe('validateEntryContentType', () => {
it('throws an error if the content type does not exist', () => {
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: baseStrapiMock });
const mockReleaseAction: ReleaseActionCreateArgs = {
releaseId: 1,
entry: {
id: 1,
contentType: 'api::plop.plop',
},
type: 'publish',
};
expect(() => releaseValidationService.validateEntryContentType(mockReleaseAction)).toThrow(
expect(() => releaseValidationService.validateEntryContentType('api::plop.plop')).toThrow(
'No content type found for uid api::plop.plop'
);
});
@ -40,16 +31,9 @@ describe('releaseValidation service', () => {
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: strapiMock });
const mockReleaseAction: ReleaseActionCreateArgs = {
releaseId: 1,
entry: {
id: 1,
contentType: 'api::category.category',
},
type: 'publish',
};
expect(() => releaseValidationService.validateEntryContentType(mockReleaseAction)).toThrow(
expect(() =>
releaseValidationService.validateEntryContentType('api::category.category')
).toThrow(
'Content type with uid api::category.category does not have draftAndPublish enabled'
);
});
@ -71,8 +55,7 @@ describe('releaseValidation service', () => {
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: strapiMock });
const mockReleaseAction: ReleaseActionCreateArgs = {
releaseId: 1,
const mockReleaseAction: CreateReleaseAction.Request['body'] = {
entry: {
id: 1,
contentType: 'api::category.category',
@ -80,9 +63,9 @@ describe('releaseValidation service', () => {
type: 'publish',
};
expect(() => releaseValidationService.validateUniqueEntry(mockReleaseAction)).rejects.toThrow(
'No release found for id 1'
);
expect(() =>
releaseValidationService.validateUniqueEntry(1, mockReleaseAction)
).rejects.toThrow('No release found for id 1');
});
it('throws an error if a contentType entry already exists in the release', () => {
@ -109,8 +92,7 @@ describe('releaseValidation service', () => {
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: strapiMock });
const mockReleaseAction: ReleaseActionCreateArgs = {
releaseId: 1,
const mockReleaseAction: CreateReleaseAction.Request['body'] = {
entry: {
id: 1,
contentType: 'api::category.category',
@ -118,7 +100,9 @@ describe('releaseValidation service', () => {
type: 'publish',
};
expect(() => releaseValidationService.validateUniqueEntry(mockReleaseAction)).rejects.toThrow(
expect(() =>
releaseValidationService.validateUniqueEntry(1, mockReleaseAction)
).rejects.toThrow(
'Entry with id 1 and contentType api::category.category already exists in release with id 1'
);
});

View File

@ -1,21 +1,21 @@
import { setCreatorFields } from '@strapi/utils';
import type { LoadedStrapi } from '@strapi/types';
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants';
import type { ReleaseCreateArgs, UserInfo, ReleaseActionCreateArgs } from '../../../shared/types';
import type { GetReleases, CreateRelease, GetRelease } from '../../../shared/contracts/releases';
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
import type { UserInfo } from '../../../shared/types';
import { getService } from '../utils';
const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
async create(releaseData: ReleaseCreateArgs, { user }: { user: UserInfo }) {
async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) {
const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData);
const release = await strapi.entityService.create(RELEASE_MODEL_UID, {
return strapi.entityService.create(RELEASE_MODEL_UID, {
data: releaseWithCreatorFields,
});
return release;
},
async findMany(query: Record<string, unknown>) {
const { results, pagination } = await strapi.entityService.findPage(RELEASE_MODEL_UID, {
findMany(query?: GetReleases.Request['query']) {
return strapi.entityService.findPage(RELEASE_MODEL_UID, {
...query,
populate: {
actions: {
@ -24,23 +24,28 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
},
},
});
return {
data: results,
pagination,
};
},
findOne(id: GetRelease.Request['params']['id']) {
return strapi.entityService.findOne(RELEASE_MODEL_UID, id, {
populate: {
actions: {
// @ts-expect-error TS error on populate, is not considering count
count: true,
},
},
});
},
async createAction(
releaseId: ReleaseActionCreateArgs['releaseId'],
action: Pick<ReleaseActionCreateArgs, 'type' | 'entry'>
releaseId: CreateReleaseAction.Request['params']['releaseId'],
action: Pick<CreateReleaseAction.Request['body'], 'type' | 'entry'>
) {
const { validateEntryContentType, validateUniqueEntry } = getService('release-validation', {
strapi,
});
await Promise.all([
validateEntryContentType({ releaseId, ...action }),
validateUniqueEntry({ releaseId, ...action }),
validateEntryContentType(action.entry.contentType),
validateUniqueEntry(releaseId, action),
]);
const { entry, type } = action;

View File

@ -1,24 +1,24 @@
import { errors } from '@strapi/utils';
import { LoadedStrapi } from '@strapi/types';
import { Release, ReleaseActionCreateArgs } from '../../../shared/types';
import type { Release } from '../../../shared/contracts/releases';
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
import { RELEASE_MODEL_UID } from '../constants';
const createReleaseValidationService = ({ strapi }: { strapi: LoadedStrapi }) => ({
async validateUniqueEntry(releaseActionArgs: ReleaseActionCreateArgs) {
async validateUniqueEntry(
releaseId: CreateReleaseAction.Request['params']['releaseId'],
releaseActionArgs: CreateReleaseAction.Request['body']
) {
/**
* Asserting the type, otherwise TS complains: 'release.actions' is of type 'unknown', even though the types come through for non-populated fields...
* Possibly related to the comment on GetValues: https://github.com/strapi/strapi/blob/main/packages/core/types/src/modules/entity-service/result.ts
*/
const release = (await strapi.entityService.findOne(
RELEASE_MODEL_UID,
releaseActionArgs.releaseId,
{
populate: { actions: { populate: { entry: { fields: ['id'] } } } },
}
)) as Release | null;
const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
populate: { actions: { populate: { entry: { fields: ['id'] } } } },
})) as Release | null;
if (!release) {
throw new errors.ValidationError(`No release found for id ${releaseActionArgs.releaseId}`);
throw new errors.NotFoundError(`No release found for id ${releaseId}`);
}
const isEntryInRelease = release.actions.some(
@ -29,23 +29,23 @@ const createReleaseValidationService = ({ strapi }: { strapi: LoadedStrapi }) =>
if (isEntryInRelease) {
throw new errors.ValidationError(
`Entry with id ${releaseActionArgs.entry.id} and contentType ${releaseActionArgs.entry.contentType} already exists in release with id ${releaseActionArgs.releaseId}`
`Entry with id ${releaseActionArgs.entry.id} and contentType ${releaseActionArgs.entry.contentType} already exists in release with id ${releaseId}`
);
}
},
validateEntryContentType(releaseActionArgs: ReleaseActionCreateArgs) {
const contentType = strapi.contentType(releaseActionArgs.entry.contentType);
validateEntryContentType(
contentTypeUid: CreateReleaseAction.Request['body']['entry']['contentType']
) {
const contentType = strapi.contentType(contentTypeUid);
if (!contentType) {
throw new errors.ValidationError(
`No content type found for uid ${releaseActionArgs.entry.contentType}`
);
throw new errors.NotFoundError(`No content type found for uid ${contentTypeUid}`);
}
// TODO: V5 migration - All contentType will have draftAndPublish enabled
if (!contentType.options?.draftAndPublish) {
throw new errors.ValidationError(
`Content type with uid ${releaseActionArgs.entry.contentType} does not have draftAndPublish enabled`
`Content type with uid ${contentTypeUid} does not have draftAndPublish enabled`
);
}
},

View File

@ -1,9 +1,9 @@
{
"extends": "./tsconfig.json",
"include": ["./src"],
"exclude": ["./src/**/*.test.ts"],
"extends": "./server/tsconfig.json",
"include": ["./server/src", "./shared"],
"exclude": ["./server/src/**/*.test.ts"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist/server"
"rootDir": ".",
"outDir": "./dist"
}
}

View File

@ -1,8 +1,12 @@
{
"extends": "tsconfig/base.json",
"include": ["src", "../shared/types.ts"],
"exclude": ["node_modules"],
"include": [
"src"
],
"exclude": [
"node_modules"
],
"compilerOptions": {
"esModuleInterop": true
},
}
}

View File

@ -0,0 +1,44 @@
import { Attribute, Common } from '@strapi/types';
import type { Release } from './releases';
import type { Entity } from '../types';
import type { errors } from '@strapi/utils';
type ReleaseActionEntry = Entity & {
// Entity attributes
[key: string]: Attribute.Any;
};
export interface ReleaseAction {
type: 'publish' | 'unpublish';
entry: ReleaseActionEntry;
contentType: Common.UID.ContentType;
release: Release;
}
/**
* POST /content-releases/:id/actions - Create a release action
*/
export declare namespace CreateReleaseAction {
export interface Request {
params: {
releaseId: Release['id'];
};
body: {
type: ReleaseAction['type'];
entry: {
id: ReleaseActionEntry['id'];
contentType: Common.UID.ContentType;
};
};
}
export interface Response {
data:
| ReleaseAction
| {
data: null;
error: errors.ApplicationError | errors.ValidationError | errors.NotFoundError;
};
}
}

View File

@ -1,48 +1,84 @@
import { Entity as StrapiEntity } from '@strapi/types';
import type { Entity } from '../types';
import type { ReleaseAction } from './release-actions';
import type { UserInfo } from '../types';
import { errors } from '@strapi/utils';
export interface Entity {
id: StrapiEntity.ID;
createdAt: string;
updatedAt: string;
}
export interface Release extends Entity {
name: string;
releasedAt: string;
actions: ReleaseAction[];
}
type Pagination = {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
export interface ReleaseDataResponse extends Omit<Release, 'actions'> {
actions: { meta: { count: number } };
}
/**
* POST /content-releases - Create a single release
* GET /content-releases/ - Get all releases
*/
export declare namespace GetReleases {
export interface Request {
state: {
userAbility: {};
};
query?: Partial<Pick<Pagination, 'page' | 'pageSize'>>;
}
export type Response =
| {
data: ReleaseDataResponse[];
pagination: Pagination;
}
| {
data: null;
error: errors.ApplicationError;
};
}
/**
* GET /content-releases/:id - Get a single release
*/
export declare namespace GetRelease {
export interface Request {
state: {
userAbility: {};
};
params: {
id: Release['id'];
};
}
export type Response =
| {
data: ReleaseDataResponse;
}
| {
data: null;
error: errors.ApplicationError | errors.NotFoundError;
};
}
/**
* POST /content-releases/ - Create a release
*/
export declare namespace CreateRelease {
export interface Request {
query: {};
body: Omit<Release, keyof Entity>;
state: {
user: UserInfo;
};
body: {
name: string;
};
}
export interface Response {
data: Release;
/**
* TODO: check if we also could recieve errors.YupValidationError
*/
error?: errors.ApplicationError | errors.YupValidationError | errors.UnauthorizedError;
}
}
/**
* GET /content-releases - Get all the release
*/
export declare namespace GetAllReleases {
export interface Request {
query: {};
body: {};
}
/**
* TODO: Validate this with BE
*/
export interface Response {
data: Release[];
error?: errors.ApplicationError;
}
export type Response =
| { data: ReleaseDataResponse }
| { data: null; error: errors.ApplicationError | errors.ValidationError };
}

View File

@ -1,16 +1,14 @@
import type { Entity, Common } from '@strapi/types';
import type { Entity as StrapiEntity } from '@strapi/types';
// @TODO: Probably user & role types should be imported from a common package
interface RoleInfo {
id: Entity.ID;
interface RoleInfo extends Omit<Entity, 'createdAt' | 'updatedAt'> {
name: string;
code: string;
description?: string;
usersCount?: number;
}
export interface UserInfo {
id: Entity.ID;
export interface UserInfo extends Entity {
firstname: string;
lastname?: string;
username?: null | string;
@ -19,35 +17,10 @@ export interface UserInfo {
blocked: boolean;
preferedLanguage: null | string;
roles: RoleInfo[];
}
export interface Entity {
id: StrapiEntity.ID;
createdAt: string;
updatedAt: string;
}
interface ReleaseActionEntry {
id: Entity.ID;
[key: string]: unknown;
}
export interface ReleaseAction {
type: 'publish' | 'unpublish';
entry: ReleaseActionEntry;
contentType: Common.UID.ContentType;
release: Release;
}
export interface Release {
id: Entity.ID;
name: string;
releasedAt: Date;
actions: ReleaseAction[];
}
export type ReleaseCreateArgs = Pick<Release, 'name'>;
export interface ReleaseActionCreateArgs extends Pick<ReleaseAction, 'type'> {
releaseId: Entity.ID;
entry: {
id: Entity.ID;
contentType: Common.UID.ContentType;
};
}