From 855cc6267a5be9103104cd90e4955af6c30ea305 Mon Sep 17 00:00:00 2001 From: Gilles Rasigade Date: Thu, 15 Apr 2021 16:31:24 +0000 Subject: [PATCH] Patch entities route implementation --- src/api/models/controllers.test.ts | 226 ++++++ src/api/models/controllers.ts | 65 ++ src/api/models/index.ts | 2 + .../spec/__snapshots__/builder.test.ts.snap | 663 +++++++++++++++++- src/api/spec/builder.ts | 67 +- src/constants/events.ts | 1 + src/models/Generic.ts | 4 + src/models/schema.ts | 10 + src/sdk/Datastore.test.ts | 55 ++ src/sdk/Datastore.ts | 20 + 10 files changed, 1102 insertions(+), 11 deletions(-) diff --git a/src/api/models/controllers.test.ts b/src/api/models/controllers.test.ts index 70a5fcc..3542658 100644 --- a/src/api/models/controllers.test.ts +++ b/src/api/models/controllers.test.ts @@ -12,6 +12,7 @@ import { find, get, getEvents, + patch, restore, timetravel, update, @@ -355,6 +356,205 @@ describe('controllers/models', () => { }); }); + describe('#patch', () => { + let error; + let req; + let res; + let next; + + beforeEach(() => { + error = null; + next = jest.fn().mockImplementation((err) => (error = err)); + req = { + params: {}, + body: {}, + }; + res = {}; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('calls next if res.body is already set', async () => { + const controller = patch({ mongodb, models, telemetry }); + + res.body = {}; + await controller(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenLastCalledWith(); + }); + + it('returns an error if the model is invalid', async () => { + const controller = patch({ mongodb, models, telemetry }); + + await controller(req, res, next); + + expect(error).not.toBe(null); + expect(error.message).toEqual('Invalid Model'); + expect(error.status).toEqual(400); + }); + + it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => { + const controller = patch({ mongodb, models, telemetry }); + + req.params.model = 'users'; + req.params.correlation_id = ObjectId().toString(); + req.body = { + json_patch: [ + { + op: 'replace', + path: '/firstname', + value: 'John', + }, + ], + }; + + await controller(req, res, next); + + expect(error).not.toBe(null); + expect(error.message).toEqual('Entity must be created first'); + expect(error.status).toEqual(422); + }); + + it('returns a validation error in case invalid body schema validation', async () => { + const controller = patch({ mongodb, models, telemetry }); + + const user = models.factory(mongodb, 'users'); + + await user.create({ + firstname: 'John', + }); + + req.params.model = 'users'; + req.params.correlation_id = user.state.user_id.toString(); + req.body = { + firstname: 'John', // Must only accept `json_patch` + }; + + await controller(req, res, next); + + expect(error).not.toBe(null); + expect(error.message).toEqual('Event schema validation error'); + expect(error.details).toEqual([ + { + dataPath: '', + keyword: 'additionalProperties', + message: 'should NOT have additional properties', + params: { additionalProperty: 'firstname' }, + schemaPath: '#/additionalProperties', + }, + ]); + expect(error.status).toEqual(400); + }); + + it('returns a generic error otherwise', async () => { + const controller = patch({ mongodb, models, telemetry }); + + const user = models.factory(mongodb, 'users'); + + await user.create({ + firstname: 'John', + }); + + req.params.model = 'users'; + req.params.correlation_id = user.state.user_id.toString(); + req.body = { + json_patch: [ + { + op: 'replace', + path: '/firstname', + value: 'John', + }, + ], + }; + + next = jest + .fn() + .mockImplementationOnce(() => { + throw new Error('Oooops'); + }) + .mockImplementation((err) => (error = err)); + + await controller(req, res, next); + + expect(error).not.toBe(null); + expect(error.message).toEqual('Oooops'); + }); + + it('returns the patched entity', async () => { + const controller = patch({ mongodb, models, telemetry }); + + const user = models.factory(mongodb, 'users'); + + await user.create({ + firstname: 'John', + }); + + req.params.model = 'users'; + req.params.correlation_id = user.state.user_id.toString(); + req.body = { + json_patch: [ + { + op: 'replace', + path: '/firstname', + value: 'Jack', + }, + ], + }; + + await controller(req, res, next); + + expect(next).toHaveBeenCalledWith(); + + expect(res.body).toMatchObject({ + firstname: 'Jack', + version: 1, + is_enabled: true, + }); + + expect(res.body).toHaveProperty('created_at'); + expect(res.body).toHaveProperty('updated_at'); + expect(res.body).toHaveProperty('user_id'); + + expect(res.body.created_at).not.toEqual(res.body.updated_at); + }); + + it('returns a 409 Conflict error in case of index violation', async () => { + const controller = patch({ mongodb, models, telemetry }); + + const alice = models.factory(mongodb, 'users'); + await alice.create({ + firstname: 'John', + email: 'john@doe.org', + }); + + const user = models.factory(mongodb, 'users'); + await user.create({ firstname: 'John' }); + + req.params.model = 'users'; + req.params.correlation_id = user.state.user_id.toString(); + req.body = { + json_patch: [ + { + op: 'replace', + path: '/email', + value: 'john@doe.org', + }, + ], + }; + + await controller(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + + expect(error).not.toBe(null); + expect(error.message).toContain('E11000 duplicate key error collection'); + expect(error.status).toEqual(409); + }); + }); + describe('#apply', () => { let error; let req; @@ -998,6 +1198,32 @@ describe('controllers/models', () => { }); }); + it('returns a list of entities skipping the response validation with with-response-validation header set to false', async () => { + const controller = find({ mongodb, models, telemetry }); + + const users = [ + models.factory(mongodb, 'users'), + models.factory(mongodb, 'users'), + models.factory(mongodb, 'users'), + models.factory(mongodb, 'users'), + ]; + + await Promise.all( + users.map((user, index) => user.create({ firstname: `Joe_${index}` })), + ); + + req.params.model = 'users'; + req.headers['with-response-validation'] = 'false'; + + res.json = jest.fn(); + + await controller(req, res, next); + + expect(res.body.length).toBeGreaterThanOrEqual(4); + expect(next).toHaveBeenCalledTimes(0); + expect(res.json).toHaveBeenCalledWith(res.body); + }); + it('returns a list of entities of max page_size', async () => { const controller = find({ mongodb, models, telemetry }); diff --git a/src/api/models/controllers.ts b/src/api/models/controllers.ts index 9f3a166..12705aa 100644 --- a/src/api/models/controllers.ts +++ b/src/api/models/controllers.ts @@ -123,6 +123,71 @@ export function update(services) { }; } +export function patch(services) { + const metric = 'api.model.patch'; + const { logger, meter } = services.telemetry; + meter.counter(metric, meter.meter('api')); + + return async (req: Request, res: Response, next: NextFunction) => { + // @ts-ignore + if (res.body) { + return next(); + } + + try { + meter.add(metric, 1, { state: 'request', model: req.params.model }); + const model = services.models.factory( + services.mongodb, + req.params.model, + req.params.correlation_id, + ); + + await model.patch(req.body); + + // @ts-ignore + res.body = model.state; + + logger.debug('[api/models#patch] Entity patchd', { + model: req.params.model, + entity: model.state, + }); + + meter.add(metric, 1, { state: '200', model: req.params.model }); + next(); + } catch (err) { + if (err.message === 'Invalid Model') { + meter.add(metric, 1, { state: '400', model: req.params.model }); + + err.status = 400; + return next(err); + } + + if (err.message === 'Event schema validation error') { + meter.add(metric, 1, { state: '400', model: req.params.model }); + + err.status = 400; + return next(err); + } + + if (err.code === 11000) { + meter.add(metric, 1, { state: '409', model: req.params.model }); + + err.status = 409; + return next(err); + } + + if (err.message === 'Entity must be created first') { + meter.add(metric, 1, { state: '422', model: req.params.model }); + + err.status = 422; + return next(err); + } + + next(err); + } + }; +} + export function apply(services) { const metric = 'api.model.apply'; const { meter } = services.telemetry; diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 2e66597..0f51b06 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -8,6 +8,7 @@ import { find, get, getEvents, + patch, restore, timetravel, update, @@ -22,6 +23,7 @@ function routes(services) { .post('/decrypt', decrypt(services)) .get('/:correlation_id', get(services)) .post('/:correlation_id', update(services)) + .patch('/:correlation_id', patch(services)) .get('/:correlation_id/events', getEvents(services)) .post('/:correlation_id/snapshot', createSnapshot(services)) .get('/:correlation_id/:version', timetravel(services)) diff --git a/src/api/spec/__snapshots__/builder.test.ts.snap b/src/api/spec/__snapshots__/builder.test.ts.snap index cda544b..77f35d8 100644 --- a/src/api/spec/__snapshots__/builder.test.ts.snap +++ b/src/api/spec/__snapshots__/builder.test.ts.snap @@ -631,6 +631,131 @@ Object { "Users", ], }, + "patch": Object { + "description": "Patch an existing User already present in the database.", + "operationId": "patchUser", + "parameters": Array [ + Object { + "description": "Correlation id", + "in": "path", + "name": undefined, + "required": true, + "schema": Object { + "description": "Unique ID used as a correlation ID", + "example": "5fa962406a651140b5f9f4bf", + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "properties": Object { + "json_patch": Object { + "items": Object { + "properties": Object { + "op": Object { + "description": "JSON PATCH Operation", + "enum": Array [ + "add", + "remove", + "replace", + "copy", + "move", + ], + "type": "string", + }, + "path": Object { + "description": "JSON PATCH Operation path", + "type": "string", + }, + }, + "required": Array [ + "op", + "path", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": Array [ + "json_patch", + ], + "type": "object", + }, + }, + }, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/User", + }, + }, + }, + "description": "Entity successfully patched", + "links": Object {}, + }, + "400": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Invalid Model", + "status": 400, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Invalid Model / Event schema validation error", + }, + "409": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Conflict", + "status": 409, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Object is in conflict with another", + }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, + "default": Object { + "$ref": "#/components/responses/default", + }, + }, + "security": Array [ + Object { + "apiKey": Array [], + }, + ], + "summary": "Patch a User", + "tags": Array [ + "Users", + ], + }, "post": Object { "description": "Update an existing User already present in the database.", "operationId": "updateUser", @@ -1089,7 +1214,7 @@ Object { Object { "description": "Routes available for the model User -Events available to this model: CREATED, UPDATED, RESTORED, ROLLBACKED. +Events available to this model: CREATED, UPDATED, PATCHED, RESTORED, ROLLBACKED.
User JSON Schema @@ -1771,6 +1896,131 @@ Object { "Users", ], }, + "patch": Object { + "description": "Patch an existing User already present in the database.", + "operationId": "patchUser", + "parameters": Array [ + Object { + "description": "Correlation id", + "in": "path", + "name": undefined, + "required": true, + "schema": Object { + "description": "Unique ID used as a correlation ID", + "example": "5fa962406a651140b5f9f4bf", + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "properties": Object { + "json_patch": Object { + "items": Object { + "properties": Object { + "op": Object { + "description": "JSON PATCH Operation", + "enum": Array [ + "add", + "remove", + "replace", + "copy", + "move", + ], + "type": "string", + }, + "path": Object { + "description": "JSON PATCH Operation path", + "type": "string", + }, + }, + "required": Array [ + "op", + "path", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": Array [ + "json_patch", + ], + "type": "object", + }, + }, + }, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/User", + }, + }, + }, + "description": "Entity successfully patched", + "links": Object {}, + }, + "400": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Invalid Model", + "status": 400, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Invalid Model / Event schema validation error", + }, + "409": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Conflict", + "status": 409, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Object is in conflict with another", + }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, + "default": Object { + "$ref": "#/components/responses/default", + }, + }, + "security": Array [ + Object { + "apiKey": Array [], + }, + ], + "summary": "Patch a User", + "tags": Array [ + "Users", + ], + }, "post": Object { "description": "Update an existing User already present in the database.", "operationId": "updateUser", @@ -2047,6 +2297,20 @@ Object { }, "description": "Object is in conflict with another", }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, "default": Object { "$ref": "#/components/responses/default", }, @@ -2318,7 +2582,7 @@ Object { Object { "description": "Routes available for the model User -Events available to this model: CREATED, UPDATED, RESTORED, ROLLBACKED, FIRSTNAME_UPDATED. +Events available to this model: CREATED, UPDATED, PATCHED, RESTORED, ROLLBACKED, FIRSTNAME_UPDATED.
User JSON Schema @@ -3000,6 +3264,131 @@ Object { "Users", ], }, + "patch": Object { + "description": "Patch an existing User already present in the database.", + "operationId": "patchUser", + "parameters": Array [ + Object { + "description": "Correlation id", + "in": "path", + "name": undefined, + "required": true, + "schema": Object { + "description": "Unique ID used as a correlation ID", + "example": "5fa962406a651140b5f9f4bf", + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "properties": Object { + "json_patch": Object { + "items": Object { + "properties": Object { + "op": Object { + "description": "JSON PATCH Operation", + "enum": Array [ + "add", + "remove", + "replace", + "copy", + "move", + ], + "type": "string", + }, + "path": Object { + "description": "JSON PATCH Operation path", + "type": "string", + }, + }, + "required": Array [ + "op", + "path", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": Array [ + "json_patch", + ], + "type": "object", + }, + }, + }, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/User", + }, + }, + }, + "description": "Entity successfully patched", + "links": Object {}, + }, + "400": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Invalid Model", + "status": 400, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Invalid Model / Event schema validation error", + }, + "409": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Conflict", + "status": 409, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Object is in conflict with another", + }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, + "default": Object { + "$ref": "#/components/responses/default", + }, + }, + "security": Array [ + Object { + "apiKey": Array [], + }, + ], + "summary": "Patch a User", + "tags": Array [ + "Users", + ], + }, "post": Object { "description": "Update an existing User already present in the database.", "operationId": "updateUser", @@ -3274,6 +3663,20 @@ Object { }, "description": "Object is in conflict with another", }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, "default": Object { "$ref": "#/components/responses/default", }, @@ -3545,7 +3948,7 @@ Object { Object { "description": "Routes available for the model User -Events available to this model: CREATED, UPDATED, RESTORED, ROLLBACKED, FIRSTNAME_UPDATED. +Events available to this model: CREATED, UPDATED, PATCHED, RESTORED, ROLLBACKED, FIRSTNAME_UPDATED.
User JSON Schema @@ -4232,6 +4635,131 @@ Object { "Users", ], }, + "patch": Object { + "description": "Patch an existing User already present in the database.", + "operationId": "patchUser", + "parameters": Array [ + Object { + "description": "Correlation id", + "in": "path", + "name": undefined, + "required": true, + "schema": Object { + "description": "Unique ID used as a correlation ID", + "example": "5fa962406a651140b5f9f4bf", + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "properties": Object { + "json_patch": Object { + "items": Object { + "properties": Object { + "op": Object { + "description": "JSON PATCH Operation", + "enum": Array [ + "add", + "remove", + "replace", + "copy", + "move", + ], + "type": "string", + }, + "path": Object { + "description": "JSON PATCH Operation path", + "type": "string", + }, + }, + "required": Array [ + "op", + "path", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": Array [ + "json_patch", + ], + "type": "object", + }, + }, + }, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/User", + }, + }, + }, + "description": "Entity successfully patched", + "links": Object {}, + }, + "400": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Invalid Model", + "status": 400, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Invalid Model / Event schema validation error", + }, + "409": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Conflict", + "status": 409, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Object is in conflict with another", + }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, + "default": Object { + "$ref": "#/components/responses/default", + }, + }, + "security": Array [ + Object { + "apiKey": Array [], + }, + ], + "summary": "Patch a User", + "tags": Array [ + "Users", + ], + }, "post": Object { "description": "Update an existing User already present in the database.", "operationId": "updateUser", @@ -4690,7 +5218,7 @@ Object { Object { "description": "Routes available for the model User -Events available to this model: CREATED, UPDATED, RESTORED, ROLLBACKED. +Events available to this model: CREATED, UPDATED, PATCHED, RESTORED, ROLLBACKED.
User JSON Schema @@ -5372,6 +5900,131 @@ Object { "Users", ], }, + "patch": Object { + "description": "Patch an existing User already present in the database.", + "operationId": "patchUser", + "parameters": Array [ + Object { + "description": "Correlation id", + "in": "path", + "name": undefined, + "required": true, + "schema": Object { + "description": "Unique ID used as a correlation ID", + "example": "5fa962406a651140b5f9f4bf", + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "properties": Object { + "json_patch": Object { + "items": Object { + "properties": Object { + "op": Object { + "description": "JSON PATCH Operation", + "enum": Array [ + "add", + "remove", + "replace", + "copy", + "move", + ], + "type": "string", + }, + "path": Object { + "description": "JSON PATCH Operation path", + "type": "string", + }, + }, + "required": Array [ + "op", + "path", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": Array [ + "json_patch", + ], + "type": "object", + }, + }, + }, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/User", + }, + }, + }, + "description": "Entity successfully patched", + "links": Object {}, + }, + "400": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Invalid Model", + "status": 400, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Invalid Model / Event schema validation error", + }, + "409": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Conflict", + "status": 409, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Object is in conflict with another", + }, + "422": Object { + "content": Object { + "application/json": Object { + "example": Object { + "message": "Entity must be created first", + "status": 422, + }, + "schema": Object { + "$ref": "#/components/schemas/error", + }, + }, + }, + "description": "Entity must be created first", + }, + "default": Object { + "$ref": "#/components/responses/default", + }, + }, + "security": Array [ + Object { + "apiKey": Array [], + }, + ], + "summary": "Patch a User", + "tags": Array [ + "Users", + ], + }, "post": Object { "description": "Update an existing User already present in the database.", "operationId": "updateUser", @@ -5835,7 +6488,7 @@ Object { Object { "description": "Routes available for the model User -Events available to this model: CREATED, UPDATED, RESTORED, ROLLBACKED. +Events available to this model: CREATED, UPDATED, PATCHED, RESTORED, ROLLBACKED.
User JSON Schema diff --git a/src/api/spec/builder.ts b/src/api/spec/builder.ts index 0044d5b..edab7d9 100644 --- a/src/api/spec/builder.ts +++ b/src/api/spec/builder.ts @@ -47,7 +47,6 @@ export function findLinks(model, models) { if (k.endsWith('_id') && k !== model.getCorrelationField()) { const linkedModel = models.getModelByCorrelationField(k); if (linkedModel !== null) { - // console.log(44, model.getCollectionName(), k, p); links[k] = { path: [ ...p.filter( @@ -127,6 +126,7 @@ ${JSON.stringify(schema.model, null, 2)} [`/${collection}/{${model.getCorrelationField()}}`]: { get: getModel(model, links), post: updateModel(model, links), + patch: patchModel(model, links), }, [`/${collection}/{${model.getCorrelationField()}}/events`]: { get: getModelEvents(model, links), @@ -159,7 +159,6 @@ function getEntityName(model, singular = false) { function buildLinks(links) { const _links = {}; for (const key in links) { - // console.log(links[key]); _links[getEntityName(links[key].model, true)] = { operationId: eventNametoCamelCase( `GET_${getEntityName(links[key].model, true)}`, @@ -169,7 +168,6 @@ function buildLinks(links) { * @fixme Apply the camelCase only for GraphQL because in the current * implementation, the link on REST is invalid */ - // [key]: `$response.body#/${key}`, // Normal valid way [key]: `$response.body#/${eventNametoCamelCase(links[key].path)}`, // For GraphQL }, }; @@ -209,6 +207,7 @@ export function buildAdditionalPaths(model, links) { c.EVENT_TYPE_UPDATED, c.EVENT_TYPE_RESTORED, c.EVENT_TYPE_ROLLBACKED, + c.EVENT_TYPE_PATCHED, ].includes(eventType) ) { const eventTypeLowered = eventType.toLocaleLowerCase(); @@ -272,6 +271,7 @@ export function buildAdditionalPaths(model, links) { description: 'Invalid Model / Event schema validation error', }, 409: components.responses[409], + 422: components.responses[422], }, }), }; @@ -541,6 +541,64 @@ export function updateModel(model, links) { return routeSpec; } +export function patchModel(model, links) { + const schema = model.getSchema(); + + const event = schema.events[c.EVENT_TYPE_PATCHED]['0_0_0']; + const entityName = getEntityName(model, true); + + const routeSpec = merge({}, defaultSchema(model), { + operationId: eventNametoCamelCase(`patch_${entityName}`), + summary: event.title || `Patch a ${entityName}`, + description: + event.description || + `Patch an existing ${entityName} already present in the database.`, + parameters: [ + { + in: 'path', + name: model.getCorrelationField(), + description: 'Correlation id', + schema: c.COMPONENT_CORRELATION_ID, + required: true, + }, + ], + requestBody: { + content: { + [MIME_APPLICATION_JSON]: { + schema: { + type: 'object', + required: ['json_patch'], + properties: { + json_patch: event.properties.json_patch, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'Entity successfully patched', + content: { + [MIME_APPLICATION_JSON]: { + schema: { + $ref: `#/components/schemas/${entityName}`, + }, + }, + }, + links: buildLinks(links), + }, + 400: { + ...components.responses[400], + description: 'Invalid Model / Event schema validation error', + }, + 409: components.responses[409], + 422: components.responses[422], + }, + }); + + return routeSpec; +} + export function getModels(model, links) { const modelConfig = model.getModelConfig(); const originalSchema = model.getOriginalSchema(); @@ -632,7 +690,6 @@ export function getModels(model, links) { return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`GET_${entityName}`), - // operationId: entityName.toLowerCase(), summary: `Find ${entityName}`, description: `Find ${entityName} present in the database.`, parameters, @@ -685,7 +742,6 @@ export function getModel(model, links) { return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`GET_${entityName}`), - // operationId: entityName.toLowerCase(), summary: `Get a ${entityName}`, description: `Get a specific ${entityName} uniquely identified by its ${model.getCorrelationField()}.`, @@ -721,7 +777,6 @@ export function getModelAtVersion(model, links) { const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { - // operationId: eventNametoCamelCase(`GET_${entityName}_VERSION`), operationId: eventNametoCamelCase(`${entityName}_AT_VERSION`), summary: `Get ${entityName} at version`, description: `Get a specific version for a ${entityName}.`, diff --git a/src/constants/events.ts b/src/constants/events.ts index e174c63..ab03e54 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -2,3 +2,4 @@ export const EVENT_TYPE_CREATED: string = 'CREATED'; export const EVENT_TYPE_UPDATED: string = 'UPDATED'; export const EVENT_TYPE_RESTORED: string = 'RESTORED'; export const EVENT_TYPE_ROLLBACKED: string = 'ROLLBACKED'; +export const EVENT_TYPE_PATCHED: string = 'PATCHED'; diff --git a/src/models/Generic.ts b/src/models/Generic.ts index cd50ee0..2bd6c30 100644 --- a/src/models/Generic.ts +++ b/src/models/Generic.ts @@ -212,6 +212,10 @@ export default ( return this.apply(c.EVENT_TYPE_UPDATED, data); } + public patch(data: any): Promise { + return this.apply(c.EVENT_TYPE_PATCHED, data); + } + public async restore(version: number): Promise { const state = await this.getStateAtVersion(version); diff --git a/src/models/schema.ts b/src/models/schema.ts index 55eb660..6b17c2a 100644 --- a/src/models/schema.ts +++ b/src/models/schema.ts @@ -69,6 +69,16 @@ export default (modelConfig: ModelConfig) => { }, }, }, + [c.EVENT_TYPE_PATCHED]: { + '0_0_0': { + type: 'object', + additionalProperties: false, + required: ['json_patch'], + properties: { + json_patch: c.COMPONENT_JSON_PATCH, + }, + }, + }, [c.EVENT_TYPE_RESTORED]: { '0_0_0': { type: 'object', diff --git a/src/sdk/Datastore.test.ts b/src/sdk/Datastore.test.ts index 7d6f085..4620c9c 100644 --- a/src/sdk/Datastore.test.ts +++ b/src/sdk/Datastore.test.ts @@ -2,6 +2,7 @@ import util from 'util'; import Datastore, { ERROR_MISSING_CORRELATION_ID, + ERROR_MISSING_JSON_PATCH, ERROR_MISSING_MODEL_NAME, } from './Datastore'; @@ -530,6 +531,60 @@ describe('sdk/Datastore', () => { }); }); + describe('#patch', () => { + it('calls the patch model route', async () => { + await client.patch('users', 'user_correlation_id', [ + { + op: 'replace', + path: '/firstname', + value: 'Jack', + }, + ]); + + expect(client.axios.request).toHaveBeenLastCalledWith({ + method: 'patch', + url: '/api/users/user_correlation_id', + data: { + json_patch: [ + { + op: 'replace', + path: '/firstname', + value: 'Jack', + }, + ], + }, + }); + }); + + it('throws an error if no correlation ID is provided', async () => { + let error; + try { + await client.patch('users', undefined, [ + { + op: 'replace', + path: '/firstname', + value: 'Jack', + }, + ]); + } catch (err) { + error = err; + } + + expect(error).toEqual(ERROR_MISSING_CORRELATION_ID); + }); + + it('throws an error if no JSON Patch is provided', async () => { + let error; + try { + await client.patch('users', 'user_id'); + } catch (err) { + error = err; + } + + expect(error).toEqual(ERROR_MISSING_JSON_PATCH); + }); + }); + describe('#get', () => { it('calls the get model route', async () => { await client.get('users', 'user_correlation_id'); diff --git a/src/sdk/Datastore.ts b/src/sdk/Datastore.ts index 44eff98..912f2c6 100644 --- a/src/sdk/Datastore.ts +++ b/src/sdk/Datastore.ts @@ -8,6 +8,7 @@ import type { DatastoreImportFixture, ModelConfig } from '../../typings'; export const ERROR_MISSING_MODEL_NAME = new Error('Missing Model name'); export const ERROR_MISSING_CORRELATION_ID = new Error('Missing Correlation ID'); +export const ERROR_MISSING_JSON_PATCH = new Error('Missing JSON Patch'); function mergeWithArrays(objValue, srcValue) { if (Array.isArray(objValue)) { @@ -376,6 +377,25 @@ ${type} ${operationName}($token: String!${ }); } + patch( + modelName: string, + correlationId: string, + jsonPatch: object[], + ): Promise { + this._checkCorrelationIdExistence(correlationId); + if (!jsonPatch) { + throw ERROR_MISSING_JSON_PATCH; + } + + return this.request({ + method: 'patch', + url: this.getPath(modelName, correlationId), + data: { + json_patch: jsonPatch, + }, + }); + } + get(modelName: string, correlationId: string): Promise { this._checkCorrelationIdExistence(correlationId); -- GitLab