Single type routes

Signed-off-by: Alexandre Bodin <bodin.alex@gmail.com>
This commit is contained in:
Alexandre Bodin 2020-10-28 18:08:06 +01:00
parent 7509a16152
commit 2bbd47ba2c
11 changed files with 546 additions and 16 deletions

View File

@ -86,12 +86,83 @@
}
},
{
"method": "GET",
"path": "/single-types/:model",
"handler": "single-types.find",
"config": {
"policies": [
"routing",
"admin::isAuthenticatedAdmin",
["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.read"]]
]
}
},
{
"method": "PUT",
"path": "/single-types/:model",
"handler": "single-types.createOrUpdate",
"config": {
"policies": [
"routing",
"admin::isAuthenticatedAdmin",
[
"plugins::content-manager.hasPermissions",
[
"plugins::content-manager.explorer.create",
"plugins::content-manager.explorer.update"
],
{ "hasAtLeastOne": true }
]
]
}
},
{
"method": "DELETE",
"path": "/single-types/:model",
"handler": "single-types.delete",
"config": {
"policies": [
"routing",
"admin::isAuthenticatedAdmin",
["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.delete"]]
]
}
},
{
"method": "POST",
"path": "/single-types/:model/actions/publish",
"handler": "single-types.publish",
"config": {
"policies": [
"routing",
"plugins::content-manager.has-draft-and-publish",
"admin::isAuthenticatedAdmin",
["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.publish"]]
]
}
},
{
"method": "POST",
"path": "/single-types/:model/actions/unpublish",
"handler": "single-types.unpublish",
"config": {
"policies": [
"routing",
"plugins::content-manager.has-draft-and-publish",
"admin::isAuthenticatedAdmin",
["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.publish"]]
]
}
},
{
"method": "GET",
"path": "/explorer/:model",
"handler": "ContentManager.find",
"config": {
"policies": ["routing", "admin::isAuthenticatedAdmin"]
}
},
{

View File

@ -0,0 +1,61 @@
'use strict';
const createContext = require('../../../../test/helpers/create-context');
const singleTypes = require('../single-types');
const { ACTIONS } = require('../constants');
describe('Single Types', () => {
test('find', async () => {
const state = {
userAbility: {
can: jest.fn(),
cannot: jest.fn(() => false),
},
};
const notFound = jest.fn();
const createPermissionsManager = jest.fn(() => ({
ability: state.userAbility,
}));
global.strapi = {
admin: {
services: {
permission: {
createPermissionsManager,
},
},
},
plugins: {
'content-manager': {
services: {
'single-types': {
fetchEntitiyWithCreatorRoles() {
return null;
},
},
},
},
},
entityService: {
find: jest.fn(),
},
};
const modelUid = 'test-model';
const ctx = createContext(
{
params: {
model: modelUid,
},
},
{ state, notFound }
);
await singleTypes.find(ctx);
expect(state.userAbility.cannot).toHaveBeenCalledWith(ACTIONS.create, modelUid);
expect(notFound).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,13 @@
'use strict';
const ACTIONS = {
read: 'plugins::content-manager.explorer.read',
create: 'plugins::content-manager.explorer.create',
edit: 'plugins::content-manager.explorer.update',
delete: 'plugins::content-manager.explorer.delete',
publish: 'plugins::content-manager.explorer.publish',
};
module.exports = {
ACTIONS,
};

View File

@ -0,0 +1,214 @@
'use strict';
const { prop, pipe, assoc, assign } = require('lodash/fp');
const { contentTypes: contentTypesUtils } = require('strapi-utils');
const { getService } = require('../utils');
const parseBody = require('../utils/parse-body');
const { ACTIONS } = require('./constants');
const {
CREATED_BY_ATTRIBUTE,
UPDATED_BY_ATTRIBUTE,
PUBLISHED_AT_ATTRIBUTE,
} = contentTypesUtils.constants;
const pickPermittedFields = ({ pm, action, model }) => data => {
return pm.pickPermittedFieldsOf(data, { action, subject: model });
};
const setCreatorFields = ({ user, isEdition = false }) => data => {
if (isEdition) {
return assoc(UPDATED_BY_ATTRIBUTE, user.id, data);
}
return assign(data, {
[CREATED_BY_ATTRIBUTE]: user.id,
[UPDATED_BY_ATTRIBUTE]: user.id,
});
};
module.exports = {
async find(ctx) {
const { userAbility } = ctx.state;
const { model } = ctx.params;
const pm = strapi.admin.services.permission.createPermissionsManager(
userAbility,
ACTIONS.read,
model
);
const singleTypeService = getService('single-types');
const entity = await singleTypeService.fetchEntitiyWithCreatorRoles(model);
// allow user with create permission to know a single type is not created
if (!entity) {
if (pm.ability.cannot(ACTIONS.create, model)) {
return ctx.forbidden();
}
return ctx.notFound();
}
if (pm.ability.cannot(ACTIONS.read, pm.toSubject(entity))) {
return ctx.forbidden();
}
ctx.body = pm.sanitize(entity, { action: ACTIONS.read });
},
async createOrUpdate(ctx) {
const { user, userAbility } = ctx.state;
const { model } = ctx.params;
const { data, files } = parseBody(ctx);
const singleTypeService = getService('single-types');
const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model);
try {
if (!existingEntity) {
const pm = strapi.admin.services.permission.createPermissionsManager(
userAbility,
ACTIONS.create,
model
);
const sanitizedData = pipe([
pickPermittedFields({ pm, action: ACTIONS.create, model }),
setCreatorFields({ user }),
])(data);
const entity = await singleTypeService.create({ data: sanitizedData, files }, { model });
ctx.body = pm.sanitize(entity, { action: ACTIONS.read });
return;
}
const pm = strapi.admin.services.permission.createPermissionsManager(
userAbility,
ACTIONS.edit,
model
);
if (pm.ability.cannot(ACTIONS.edit, pm.toSubject(existingEntity))) {
return strapi.errors.forbidden();
}
const sanitizedData = pipe([
pickPermittedFields({ pm, action: ACTIONS.edit, model: pm.toSubject(existingEntity) }),
setCreatorFields({ user, isEdition: true }),
])(data);
const entity = await singleTypeService.update(
existingEntity,
{ data: sanitizedData, files },
{ model }
);
ctx.body = pm.sanitize(entity, { action: ACTIONS.read });
} catch (error) {
strapi.log.error(error);
ctx.badRequest(null, [
{
messages: [{ id: error.message, message: error.message, field: error.field }],
errors: prop('data.errors', error),
},
]);
}
},
async delete(ctx) {
const { userAbility } = ctx.state;
const { model } = ctx.params;
const singleTypeService = getService('single-types');
const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model);
const pm = strapi.admin.services.permission.createPermissionsManager(
userAbility,
ACTIONS.delete,
model
);
if (pm.ability.cannot(ACTIONS.delete, pm.toSubject(existingEntity))) {
return strapi.errors.forbidden();
}
const deletedEntity = await singleTypeService.delete(existingEntity, { userAbility, model });
ctx.body = pm.sanitize(deletedEntity, { action: ACTIONS.read });
},
async publish(ctx) {
const { userAbility } = ctx.state;
const { model } = ctx.params;
const singleTypeService = getService('single-types');
const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model);
if (!existingEntity) {
return ctx.notFound();
}
const pm = strapi.admin.services.permission.createPermissionsManager(
userAbility,
ACTIONS.publish,
model
);
if (pm.ability.cannot(ACTIONS.publish, pm.toSubject(existingEntity))) {
return ctx.forbidden();
}
await strapi.entityValidator.validateEntityCreation(strapi.getModel(model), existingEntity);
if (existingEntity[PUBLISHED_AT_ATTRIBUTE]) {
return ctx.badRequest('Already published');
}
const publishedEntry = await getService('contentmanager').publish(
{ id: existingEntity.id },
model
);
ctx.body = pm.sanitize(publishedEntry, { action: ACTIONS.read });
},
async unpublish(ctx) {
const { userAbility } = ctx.state;
const { model } = ctx.params;
const singleTypeService = getService('single-types');
const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model);
if (!existingEntity) {
return ctx.notFound();
}
const pm = strapi.admin.services.permission.createPermissionsManager(
userAbility,
ACTIONS.publish,
model
);
if (pm.ability.cannot(ACTIONS.publish, pm.toSubject(existingEntity))) {
return ctx.forbidden();
}
if (!existingEntity[PUBLISHED_AT_ATTRIBUTE]) {
return ctx.badRequest('Already a draft');
}
const unpublishedEntry = await getService('contentmanager').unpublish(
{ id: existingEntity.id },
model
);
ctx.body = pm.sanitize(unpublishedEntry, { action: ACTIONS.read });
},
};

View File

@ -305,9 +305,7 @@ paths:
type: object
properties:
id:
oneOf:
- type: string
- type: integer
$ref: '#/components/schemas/id'
'[primaryKey]':
oneOf:
- type: string
@ -338,47 +336,129 @@ paths:
tags:
- Collection Types content management
description: Get one entry
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
put:
tags:
- Collection Types content management
description: Update one entry
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
delete:
tags:
- Collection Types content management
description: Delete one entry
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
/content-manager/collection-type/{model}/{id}/actions/publish:
post:
tags:
- Collection Types content management
description: Publish one entry
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
/content-manager/collection-type/{model}/{id}/actions/unpublish:
post:
tags:
- Collection Types content management
description: Unpublish one entry
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
# Single type
/content-manager/single-type/{model}:
/content-manager/single-types/{model}:
get:
tags:
- Single Types content management
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
put:
tags:
- Single Types content management
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
delete:
tags:
- Single Types content management
/content-manager/single-type/{model}/actions/publish:
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
/content-manager/single-types/{model}/actions/publish:
post:
tags:
- Single Types content management
/content-manager/single-type/{model}/actions/unpublish:
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
/content-manager/single-types/{model}/actions/unpublish:
post:
tags:
- Single Types content management
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/entity'
components:
schemas:
id:
oneOf:
- type: string
- type: integer
entity:
type: object
required:
- id
- created_by
- updated_by
properties:
id:
$ref: '#/components/schemas/id'
created_by:
$ref: '#/components/schemas/user'
updated_by:
$ref: '#/components/schemas/user'
additionalProperties:
type: any
contentType:
type: object
properties:
@ -521,6 +601,20 @@ components:
- singleType
- collectionType
user:
type: object
properties:
id:
oneOf:
- type: integer
- type: string
firstname:
type: string
lastname:
type: string
email:
type: string
examples:
restaurant:
uid: application::restaurant.restaurant

View File

@ -13,7 +13,7 @@ const { ENTRY_PUBLISH, ENTRY_UNPUBLISH } = webhookUtils.webhookEvents;
* A set of functions called "actions" for `ContentManager`
*/
module.exports = {
fetchAll(model, query) {
fetchAll(model, query = {}) {
const { query: request, populate, ...filters } = query;
const queryFilter = !_.isEmpty(request)
@ -113,6 +113,7 @@ module.exports = {
{ params, data: { [PUBLISHED_AT_ATTRIBUTE]: null } },
{ model }
);
strapi.eventHub.emit(ENTRY_UNPUBLISH, {
model: modelDef.modelName,
entry: sanitizeEntity(unpublishedEntry, { model: modelDef }),

View File

@ -0,0 +1,66 @@
'use strict';
const { omit, prop, has, assoc } = require('lodash/fp');
const { contentTypes: contentTypesUtils } = require('strapi-utils');
const { getService } = require('../utils');
const { CREATED_BY_ATTRIBUTE } = contentTypesUtils.constants;
const pickWritableFields = ({ model }) => {
return omit(contentTypesUtils.getNonWritableAttributes(strapi.getModel(model)));
};
const fetchCreatorRoles = entity => {
const createdByPath = `${CREATED_BY_ATTRIBUTE}.id`;
if (has(createdByPath, entity)) {
const creatorId = prop(createdByPath, entity);
return strapi.query('role', 'admin').find({ 'users.id': creatorId }, []);
}
return [];
};
module.exports = {
async fetchEntitiyWithCreatorRoles(model) {
const entity = await getService('contentmanager').fetchAll(model);
if (!entity) {
return entity;
}
const roles = await fetchCreatorRoles(entity);
return assoc(`${CREATED_BY_ATTRIBUTE}.roles`, roles, entity);
},
async create(body, { model }) {
const { files } = body;
const data = pickWritableFields({ model })(body.data);
const entity = await getService('contentmanager').create({ data, files }, { model });
await strapi.telemetry.send('didCreateFirstContentTypeEntry', { model });
return entity;
},
async update(existingEntity, body, { model }) {
const { files } = body;
const data = pickWritableFields({ model })(body.data);
const entity = await getService('contentmanager').edit(
{ id: existingEntity.id },
{ data, files },
{ model }
);
return entity;
},
async delete(existingEntity, { model }) {
return getService('contentmanager').delete(model, {
id: existingEntity.id,
});
},
};

View File

@ -31,7 +31,7 @@ describe('Content Manager single types', () => {
test('Label is not pluralized', async () => {
const res = await rq({
url: `/content-manager/schemas/content-types?kind=singleType`,
url: `/content-manager/content-types?kind=singleType`,
method: 'GET',
});
@ -39,7 +39,9 @@ describe('Content Manager single types', () => {
expect(res.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: 'Single-type-model',
info: expect.objectContaining({
label: 'Single-type-model',
}),
}),
])
);
@ -47,7 +49,7 @@ describe('Content Manager single types', () => {
test('find single type content returns 404 when not created', async () => {
const res = await rq({
url: `/content-manager/explorer/${uid}`,
url: `/content-manager/single-types/${uid}`,
method: 'GET',
});
@ -56,8 +58,8 @@ describe('Content Manager single types', () => {
test('Create content', async () => {
const res = await rq({
url: `/content-manager/explorer/${uid}`,
method: 'POST',
url: `/content-manager/single-types/${uid}`,
method: 'PUT',
body: {
title: 'Title',
},
@ -72,7 +74,7 @@ describe('Content Manager single types', () => {
test('find single type content returns an object ', async () => {
const res = await rq({
url: `/content-manager/explorer/${uid}`,
url: `/content-manager/single-types/${uid}`,
method: 'GET',
});

View File

@ -3,7 +3,7 @@
const { prop } = require('lodash/fp');
module.exports = {
// retrieve a local service from the contet manager plugin to make the code more readable
// retrieve a local service
getService(name) {
return prop(`content-manager.services.${name}`, strapi.plugins);
},

View File

@ -0,0 +1,8 @@
'use strict';
const parseMultipartBody = require('./parse-multipart');
module.exports = ctx => {
const { body } = ctx.request;
return ctx.is('multipart') ? parseMultipartBody(ctx) : { data: body };
};

View File

@ -20,8 +20,8 @@
nav-accent-color=""
primary-color=""
theme="dark"
schema-style="table"
default-schema-tab="example"
>
</rapi-doc>
</body>