diff --git a/README.md b/README.md index b917679..36ecf1b 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,19 @@ See [docs folder](./docs) for more details. - RFC 9288 - Web Linking https://httpwg.org/specs/rfc8288.html + +- JSON Patch and JSON Merge Patch + + https://erosb.github.io/post/json-patch-vs-merge-patch/ + +- Patch vs merge-patch which is appropriate? + + https://stackoverflow.com/questions/56030328/patch-vs-merge-patch-which-is-appropriate + +- PATCH Method for HTTP + + https://www.rfc-editor.org/rfc/rfc5789 + +- JavaScript Object Notation (JSON) Patch + + https://www.rfc-editor.org/rfc/rfc6902 diff --git a/src/backend/servers/http/core.ts b/src/backend/servers/http/core.ts index cb67404..5617d80 100644 --- a/src/backend/servers/http/core.ts +++ b/src/backend/servers/http/core.ts @@ -9,7 +9,7 @@ import { RequestContext, RequestDecorator, Response, } from '../../common'; -import {Resource} from '../../../common'; +import {CanPatchSpec, Resource} from '../../../common'; import { handleGetRoot, handleOptions, } from './handlers/default'; @@ -78,15 +78,57 @@ const constructPutSchema = (resource: Resource, mainR const constructPatchSchema = (resource: Resource) => { const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; - return ( - schema.type === 'object' - ? v.partial( - schema as v.ObjectSchema, - (schema as v.ObjectSchema).rest, - (schema as v.ObjectSchema).pipe - ) - : schema - ); + + if (schema.type !== 'object') { + return schema; + } + + const schemaChoices = { + merge: v.partial( + schema as v.ObjectSchema, + (schema as v.ObjectSchema).rest, + (schema as v.ObjectSchema).pipe + ), + delta: v.array( + v.union([ + v.object({ + op: v.literal('add'), + path: v.string(), // todo validate if valid path? + value: v.any() // todo validate if valid value? + }), + v.object({ + op: v.literal('remove'), + path: v.string(), + }), + v.object({ + op: v.literal('replace'), + path: v.string(), // todo validate if valid path? + value: v.any() // todo validate if valid value? + }), + v.object({ + op: v.literal('move'), + path: v.string(), + from: v.string(), + }), + v.object({ + op: v.literal('copy'), + path: v.string(), + from: v.string(), + }), + v.object({ + op: v.literal('test'), + path: v.string(), // todo validate if valid path? + value: v.any() // todo validate if valid value? + }), + ]) + ), + } + + const selectedSchemaChoices = Object.entries(schemaChoices) + .filter(([key]) => resource.state.canPatch[key as CanPatchSpec]) + .map(([, value]) => value); + + return v.union(selectedSchemaChoices); }; // TODO add a way to define custom middlewares const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ @@ -209,7 +251,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ) ? charsetRaw.slice(1, -1).trim() : charsetRaw.trim() - ) + ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary') const theBodyBuffer = await getBody(req); const encodingPair = req.backend.app.charsets.get(charset); if (typeof encodingPair === 'undefined') { diff --git a/src/backend/servers/http/handlers/default.ts b/src/backend/servers/http/handlers/default.ts index ac0f53c..1d2737c 100644 --- a/src/backend/servers/http/handlers/default.ts +++ b/src/backend/servers/http/handlers/default.ts @@ -2,6 +2,7 @@ import {constants} from 'http2'; import {AllowedMiddlewareSpecification, Middleware} from '../../../common'; import {LinkMap} from '../utils'; import {PlainResponse, ErrorPlainResponse} from '../response'; +import {PATCH_CONTENT_MAP_TYPE} from '../../../../common'; export const handleGetRoot: Middleware = (req, res) => { const { backend, basePath } = req; @@ -40,12 +41,27 @@ export const handleGetRoot: Middleware = (req, res) => { }); }; -export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => (_req, res) => { +export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => (req, res) => { if (middlewares.length > 0) { + const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); + const headers: Record = { + 'Allow': allowedMethods.join(', '), + }; + if (allowedMethods.includes('PATCH')) { + const validPatchTypes = Object.entries(req.resource.state.canPatch) + .filter(([, allowed]) => allowed) + .map(([patchType]) => patchType); + + const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE) + .filter(([, patchType]) => validPatchTypes.includes(patchType)) + .map(([contentType ]) => contentType); + + if (validPatchContentTypes.length > 0) { + headers['Accept-Patch'] = validPatchContentTypes.join(', '); + } + } return new PlainResponse({ - headers: { - 'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '), - }, + headers, statusMessage: 'provideOptions', statusCode: constants.HTTP_STATUS_NO_CONTENT, res, diff --git a/src/backend/servers/http/handlers/resource.ts b/src/backend/servers/http/handlers/resource.ts index 46c4471..a1122b0 100644 --- a/src/backend/servers/http/handlers/resource.ts +++ b/src/backend/servers/http/handlers/resource.ts @@ -3,6 +3,12 @@ import * as v from 'valibot'; import {Middleware} from '../../../common'; import {ErrorPlainResponse, PlainResponse} from '../response'; import assert from 'assert'; +import { + CanPatchSpec, + PATCH_CONTENT_MAP_TYPE, + PATCH_CONTENT_TYPES, + PatchContentType, +} from '../../../../common'; export const handleGetCollection: Middleware = async (req, res) => { const { query, resource, backend } = req; @@ -46,6 +52,7 @@ const isResourceIdDefined = (resourceId?: string): resourceId is string => !( export const handleGetItem: Middleware = async (req, res) => { const { resource, resourceId } = req; + assert( isResourceIdDefined(resourceId), new ErrorPlainResponse( @@ -92,15 +99,16 @@ export const handleGetItem: Middleware = async (req, res) => { export const handleDeleteItem: Middleware = async (req, res) => { const { resource, resourceId, backend } = req; - if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { - throw new ErrorPlainResponse( + assert( + isResourceIdDefined(resourceId), + new ErrorPlainResponse( 'resourceIdNotGiven', { statusCode: constants.HTTP_STATUS_BAD_REQUEST, res, } - ); - } + ) + ); let existing: unknown | null; try { @@ -122,6 +130,7 @@ export const handleDeleteItem: Middleware = async (req, res) => { try { if (existing) { + // TODO should we still deal with the delete return? await resource.dataSource.delete(resourceId); } } catch (cause) { @@ -139,19 +148,71 @@ export const handleDeleteItem: Middleware = async (req, res) => { }); }; +const isValidPatch = (s: string): s is PatchContentType => { + return PATCH_CONTENT_TYPES.includes(s as PatchContentType); +}; + export const handlePatchItem: Middleware = async (req, res) => { - const { resource, resourceId, body } = req; + const { + resource, + resourceId, + body, + headers, + } = req; - if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { - throw new ErrorPlainResponse( + assert( + isResourceIdDefined(resourceId), + new ErrorPlainResponse( 'resourceIdNotGiven', { statusCode: constants.HTTP_STATUS_BAD_REQUEST, res, } - ); + ) + ); + + const validPatchTypes = Object.entries(resource.state.canPatch) + .filter(([, allowed]) => allowed) + .map(([patchType]) => patchType); + + const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE) + .filter(([ patchType]) => validPatchTypes.includes(patchType)) + .map(([contentType ]) => contentType); + + const requestContentType = headers['content-type']; + if (typeof requestContentType !== 'string') { + throw new ErrorPlainResponse('invalidResourcePatchType', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + res, + headers: { + 'Accept-Patch': validPatchContentTypes.join(', '), + }, + }); + } + + const [patchMimeTypeAll, ...patchParams] = requestContentType.replace(/\s+/g, '').split(';') as [PatchContentType, ...string[]]; + assert(isValidPatch(patchMimeTypeAll), new ErrorPlainResponse('invalidResourcePatchType', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + res, + headers: { + 'Accept-Patch': validPatchContentTypes.join(', '), + }, + })); + + const isPatchEnabled = resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[patchMimeTypeAll]]; + if (!isPatchEnabled) { + throw new ErrorPlainResponse('invalidResourcePatchType', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + res, + headers: { + 'Accept-Patch': validPatchContentTypes.join(', '), + }, + }); } + const charsetParam = (patchParams.find((s) => s.startsWith('charset=')) ?? 'charset=utf-8'); + const [, charset] = charsetParam.split('=') as [never, BufferEncoding]; + let existing: unknown | null; try { existing = await resource.dataSource.getById(resourceId!); @@ -269,21 +330,32 @@ export const handleCreateItem: Middleware = async (req, res) => { export const handleEmplaceItem: Middleware = async (req, res) => { const { resource, resourceId, basePath, body, backend } = req; - const idAttrRaw = resource.state.shared.get('idAttr'); - if (typeof idAttrRaw === 'undefined') { - throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { + assert( + isResourceIdDefined(resourceId), + new ErrorPlainResponse( + 'resourceIdNotGiven', + { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + res, + } + ) + ); + + const idAttr = resource.state.shared.get('idAttr'); + assert( + isIdAttributeDefined(idAttr), + new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, res, - }); - } - const idAttr = idAttrRaw as string; + }) + ); let newObject: v.Output; let isCreated: boolean; try { const params = { ...body as Record }; params[idAttr] = resourceId; - [newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params); + [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); } catch (cause) { throw new ErrorPlainResponse('unableToEmplaceResource', { cause, diff --git a/src/backend/servers/http/utils.ts b/src/backend/servers/http/utils.ts index 68a5d04..c3cef82 100644 --- a/src/backend/servers/http/utils.ts +++ b/src/backend/servers/http/utils.ts @@ -1,10 +1,12 @@ import {IncomingMessage} from 'http'; +import {PATCH_CONTENT_TYPES} from '../../../common'; export const isTextMediaType = (mediaType: string) => ( mediaType.startsWith('text/') || [ 'application/json', - 'application/xml' + 'application/xml', + ...PATCH_CONTENT_TYPES, ].includes(mediaType) ); diff --git a/src/common/app.ts b/src/common/app.ts index f4c7e31..beecd69 100644 --- a/src/common/app.ts +++ b/src/common/app.ts @@ -1,6 +1,6 @@ import {Resource} from './resource'; import {FALLBACK_LANGUAGE, Language} from './language'; -import {FALLBACK_MEDIA_TYPE, MediaType} from './media-type'; +import {FALLBACK_MEDIA_TYPE, MediaType, PATCH_CONTENT_TYPES} from './media-type'; import {Charset, FALLBACK_CHARSET} from './charset'; import * as v from 'valibot'; import {Backend, createBackend, CreateBackendParams} from '../backend'; @@ -55,6 +55,16 @@ export const application = (appParams: ApplicationParams): Application => { ]), mediaTypes: new Map([ [FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], + ...( + PATCH_CONTENT_TYPES.map((name) => [ + name as MediaType['name'], + { + serialize: (s: unknown) => JSON.stringify(s), + deserialize: (s: string) => JSON.parse(s), + name: name as MediaType['name'], + } satisfies MediaType + ] as [MediaType['name'], MediaType]) + ), ]), charsets: new Map([ [FALLBACK_CHARSET.name, FALLBACK_CHARSET], diff --git a/src/common/language.ts b/src/common/language.ts index 4595929..371cdf5 100644 --- a/src/common/language.ts +++ b/src/common/language.ts @@ -31,6 +31,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ 'patchNonExistingResource', 'unableToPatchResource', 'invalidResourcePatch', + 'invalidResourcePatchType', 'invalidResource', 'resourcePatched', 'resourceCreated', @@ -89,6 +90,7 @@ export const FALLBACK_LANGUAGE = { patchNonExistingResource: 'Patch Non-Existing $RESOURCE', unableToPatchResource: 'Unable To Patch $RESOURCE', invalidResourcePatch: 'Invalid $RESOURCE Patch', + invalidResourcePatchType: 'Invalid $RESOURCE Patch Type', invalidResource: 'Invalid $RESOURCE', resourcePatched: '$RESOURCE Patched', resourceCreated: '$RESOURCE Created', diff --git a/src/common/media-type.ts b/src/common/media-type.ts index 5d0b1f1..7d66754 100644 --- a/src/common/media-type.ts +++ b/src/common/media-type.ts @@ -9,3 +9,10 @@ export const FALLBACK_MEDIA_TYPE = { deserialize: (str: string) => JSON.parse(str), name: 'application/json' as const, } satisfies MediaType; + +export const PATCH_CONTENT_TYPES = [ + 'application/merge-patch+json', + 'application/json-patch+json', +] as const; + +export type PatchContentType = typeof PATCH_CONTENT_TYPES[number]; diff --git a/src/common/resource.ts b/src/common/resource.ts index d837607..823d9b7 100644 --- a/src/common/resource.ts +++ b/src/common/resource.ts @@ -1,5 +1,16 @@ import * as v from 'valibot'; -import {BaseSchema} from 'valibot'; +import {PatchContentType} from './media-type'; + +export const CAN_PATCH_VALID_VALUES = ['merge', 'delta'] as const; + +export type CanPatchSpec = typeof CAN_PATCH_VALID_VALUES[number]; + +export const PATCH_CONTENT_MAP_TYPE: Record = { + 'application/merge-patch+json': 'merge', + 'application/json-patch+json': 'delta', +}; + +type CanPatchObject = Record; export interface ResourceState< ItemName extends string = string, @@ -12,11 +23,13 @@ export interface ResourceState< canCreate: boolean; canFetchCollection: boolean; canFetchItem: boolean; - canPatch: boolean; + canPatch: CanPatchObject; canEmplace: boolean; canDelete: boolean; } +type CanPatch = boolean | CanPatchObject | CanPatchSpec[]; + export interface Resource< Schema extends v.BaseSchema = v.BaseSchema, CurrentName extends string = string, @@ -29,7 +42,7 @@ export interface Resource< canFetchCollection(b?: boolean): this; canFetchItem(b?: boolean): this; canCreate(b?: boolean): this; - canPatch(b?: boolean): this; + canPatch(b?: CanPatch): this; canEmplace(b?: boolean): this; canDelete(b?: boolean): this; relatedTo(resource: Resource): this; @@ -46,7 +59,10 @@ export const resource = < canCreate: false, canFetchCollection: false, canFetchItem: false, - canPatch: false, + canPatch: { + merge: false, + delta: false, + }, canEmplace: false, canDelete: false, } as ResourceState; @@ -69,8 +85,27 @@ export const resource = < resourceState.canCreate = b; return this; }, - canPatch(b = true) { - resourceState.canPatch = b; + canPatch(b = true as CanPatch) { + if (typeof b === 'boolean') { + resourceState.canPatch.merge = b; + resourceState.canPatch.delta = b; + return this; + } + + if (typeof b === 'object') { + if (Array.isArray(b)) { + CAN_PATCH_VALID_VALUES.forEach((p) => { + resourceState.canPatch[p] = b.includes(p); + }); + return this; + } + if (b !== null) { + CAN_PATCH_VALID_VALUES.forEach((p) => { + resourceState.canPatch[p] = b[p]; + }); + } + } + return this; }, canEmplace(b = true) { @@ -109,7 +144,7 @@ export const resource = < get schema() { return schema; }, - relatedTo(resource: Resource) { + relatedTo(resource: Resource) { resourceState.relationships.add(resource); return this; }, diff --git a/test/e2e/http/default.test.ts b/test/e2e/http/default.test.ts index c123031..8e6101a 100644 --- a/test/e2e/http/default.test.ts +++ b/test/e2e/http/default.test.ts @@ -6,23 +6,14 @@ import { describe, expect, it, + vi, } from 'vitest'; -import { - tmpdir -} from 'os'; -import { - mkdtemp, - rm, - writeFile, -} from 'fs/promises'; -import { - join -} from 'path'; import {constants} from 'http2'; -import {Backend, dataSources} from '../../../src/backend'; -import { application, resource, validation as v, Resource } from '../../../src/common'; +import {Backend} from '../../../src/backend'; +import {application, resource, validation as v, Resource, Application} from '../../../src/common'; import { autoIncrement } from '../../fixtures'; -import {createTestClient, TestClient} from '../../utils'; +import {createTestClient, DummyDataSource, TestClient} from '../../utils'; +import {DataSource} from '../../../src/backend/data-source'; const PORT = 3000; const HOST = '127.0.0.1'; @@ -33,40 +24,17 @@ const ACCEPT_CHARSET = 'utf-8'; const CONTENT_TYPE_CHARSET = 'utf-8'; const CONTENT_TYPE = ACCEPT; -describe('happy path', () => { - let client: TestClient; - beforeEach(() => { - client = createTestClient({ - host: HOST, - port: PORT, - }) - .acceptMediaType(ACCEPT) - .acceptLanguage(ACCEPT_LANGUAGE) - .acceptCharset(ACCEPT_CHARSET) - .contentType(CONTENT_TYPE) - .contentCharset(CONTENT_TYPE_CHARSET); - }); - - let baseDir: string; - beforeAll(async () => { - try { - baseDir = await mkdtemp(join(tmpdir(), 'yasumi-')); - } catch { - // noop - } - }); - afterAll(async () => { - try { - await rm(baseDir, { - recursive: true, - }); - } catch { - // noop - } - }); +let portCounter = 0; +describe.only('happy path', () => { let Piano: Resource; - beforeEach(() => { + let app: Application; + let dataSource: DataSource; + let backend: Backend; + let server: ReturnType; + let client: TestClient; + + beforeAll(() => { Piano = resource(v.object( { brand: v.string() @@ -81,24 +49,32 @@ describe('happy path', () => { deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, schema: v.number(), }); - }); - let backend: Backend; - let server: ReturnType; - beforeEach(() => { - const app = application({ + app = application({ name: 'piano-service', }) .resource(Piano); + dataSource = new DummyDataSource(); + backend = app.createBackend({ - dataSource: new dataSources.jsonlFile.DataSource(baseDir), + dataSource, }); server = backend.createHttpServer({ basePath: BASE_PATH }); + client = createTestClient({ + host: HOST, + port: PORT + portCounter, + }) + .acceptMediaType(ACCEPT) + .acceptLanguage(ACCEPT_LANGUAGE) + .acceptCharset(ACCEPT_CHARSET) + .contentType(CONTENT_TYPE) + .contentCharset(CONTENT_TYPE_CHARSET); + return new Promise((resolve, reject) => { server.on('error', (err) => { reject(err); @@ -114,7 +90,7 @@ describe('happy path', () => { }); }); - afterEach(() => new Promise((resolve, reject) => { + afterAll(() => new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); @@ -124,14 +100,19 @@ describe('happy path', () => { }); })); + afterAll(() => { + portCounter = 0; + }); + describe('serving collections', () => { + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'getMultiple') + .mockResolvedValueOnce([] as never); + }); + beforeEach(() => { Piano.canFetchCollection(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); }); afterEach(() => { @@ -145,7 +126,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - // TODO test status messages + expect(res).toHaveProperty('statusMessage', 'Piano Collection Fetched'); expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); if (typeof resData === 'undefined') { @@ -163,6 +144,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Collection Fetched'); }); it('returns options', async () => { @@ -172,6 +154,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Provide Options'); const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; expect(allowedMethods).toContain('GET'); expect(allowedMethods).toContain('HEAD'); @@ -184,18 +167,14 @@ describe('happy path', () => { brand: 'Yamaha' }; - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(existingResource as never); }); beforeEach(() => { Piano.canFetchItem(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); }); afterEach(() => { @@ -209,6 +188,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Fetched'); expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); if (typeof resData === 'undefined') { expect.fail('Response body must be defined.'); @@ -225,6 +205,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Fetched'); }); it('returns options', async () => { @@ -234,6 +215,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Provide Options'); const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; expect(allowedMethods).toContain('GET'); expect(allowedMethods).toContain('HEAD'); @@ -241,18 +223,25 @@ describe('happy path', () => { }); describe('creating items', () => { - const existingResource = { - id: 1, - brand: 'Yamaha' - }; - const newResourceData = { brand: 'K. Kawai' }; - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); + const responseData = { + id: 2, + ...newResourceData, + }; + + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'newId') + .mockResolvedValueOnce(responseData.id as never); + }); + + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'create') + .mockResolvedValueOnce(responseData as never); }); beforeEach(() => { @@ -271,6 +260,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); + expect(res).toHaveProperty('statusMessage', 'Piano Created'); expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`); @@ -292,6 +282,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Provide Options'); const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; expect(allowedMethods).toContain('POST'); }); @@ -307,9 +298,19 @@ describe('happy path', () => { brand: 'K. Kawai' }; - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(existingResource as never); + }); + + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'patch') + .mockResolvedValueOnce({ + ...existingResource, + ...patchData, + } as never); }); beforeEach(() => { @@ -325,9 +326,13 @@ describe('happy path', () => { method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: patchData, + headers: { + 'content-type': 'application/merge-patch+json', + }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Patched'); expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); if (typeof resData === 'undefined') { @@ -348,8 +353,12 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Provide Options'); const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; expect(allowedMethods).toContain('PATCH'); + const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? []; + expect(acceptPatch).toContain('application/json-patch+json'); + expect(acceptPatch).toContain('application/merge-patch+json'); }); }); @@ -364,11 +373,6 @@ describe('happy path', () => { brand: 'K. Kawai' }; - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); - }); - beforeEach(() => { Piano.canEmplace(); }); @@ -378,6 +382,13 @@ describe('happy path', () => { }); it('returns data for replacement', async () => { + vi + .spyOn(DummyDataSource.prototype, 'emplace') + .mockResolvedValueOnce([{ + ...existingResource, + ...emplaceResourceData, + }, false] as never); + const [res, resData] = await client({ method: 'PUT', path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, @@ -385,6 +396,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Replaced'); expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); if (typeof resData === 'undefined') { @@ -398,6 +410,14 @@ describe('happy path', () => { it('returns data for creation', async () => { const newId = 2; + vi + .spyOn(DummyDataSource.prototype, 'emplace') + .mockResolvedValueOnce([{ + ...existingResource, + ...emplaceResourceData, + id: newId + }, true] as never); + const [res, resData] = await client({ method: 'PUT', path: `${BASE_PATH}/pianos/${newId}`, @@ -408,6 +428,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); + expect(res).toHaveProperty('statusMessage', 'Piano Created'); expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`); @@ -429,6 +450,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Provide Options'); const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; expect(allowedMethods).toContain('PUT'); }); @@ -440,9 +462,16 @@ describe('happy path', () => { brand: 'Yamaha' }; - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(existingResource as never); + }); + + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'delete') + .mockReturnValueOnce(Promise.resolve() as never); }); beforeEach(() => { @@ -460,6 +489,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Piano Deleted'); expect(res.headers).not.toHaveProperty('content-type'); expect(resData).toBeUndefined(); }); @@ -471,6 +501,7 @@ describe('happy path', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res).toHaveProperty('statusMessage', 'Provide Options'); const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; expect(allowedMethods).toContain('DELETE'); }); diff --git a/test/e2e/http/error-handling.test.ts b/test/e2e/http/error-handling.test.ts index ecaa321..519214a 100644 --- a/test/e2e/http/error-handling.test.ts +++ b/test/e2e/http/error-handling.test.ts @@ -20,13 +20,13 @@ import { } from 'path'; import {request} from 'http'; import {constants} from 'http2'; -import {Backend, dataSources} from '../../../src/backend'; +import {Backend} from '../../../src/backend'; import { application, resource, validation as v, Resource } from '../../../src/common'; import { autoIncrement } from '../../fixtures'; -import {createTestClient, TestClient} from '../../utils'; +import { createTestClient, TestClient, DummyDataSource } from '../../utils'; import {DataSource} from '../../../src/backend/data-source'; -const PORT = 3001; +const PORT = 4001; const HOST = '127.0.0.1'; const BASE_PATH = '/api'; const ACCEPT = 'application/json'; @@ -35,53 +35,6 @@ const ACCEPT_CHARSET = 'utf-8'; const CONTENT_TYPE_CHARSET = 'utf-8'; const CONTENT_TYPE = ACCEPT; -class DummyDataSource implements DataSource { - private resource?: { dataSource?: unknown }; - - create(): Promise { - throw new Error(); - } - - delete(): Promise { - throw new Error(); - } - - emplace(): Promise { - throw new Error(); - } - - getById(): Promise { - throw new Error(); - } - - newId(): Promise { - throw new Error(); - } - - getMultiple(): Promise { - throw new Error(); - } - - getSingle(): Promise { - throw new Error(); - } - - getTotalCount(): Promise { - throw new Error(); - } - - async initialize(): Promise {} - - patch(): Promise { - throw new Error(); - } - - prepareResource(rr: unknown) { - this.resource = rr as unknown as { dataSource: DummyDataSource }; - this.resource.dataSource = this; - } -} - describe('error handling', () => { let client: TestClient; beforeEach(() => { @@ -116,7 +69,7 @@ describe('error handling', () => { }); let Piano: Resource; - beforeEach(() => { + beforeAll(() => { Piano = resource(v.object( { brand: v.string() @@ -412,7 +365,7 @@ describe('error handling', () => { }); }); - describe.skip('deleting items', () => { + describe('deleting items', () => { const data = { id: 1, brand: 'Yamaha' @@ -433,331 +386,42 @@ describe('error handling', () => { backend.throwsErrorOnDeletingNotFound(false); }); - it('throws on item not found', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/2`, - method: 'DELETE', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - }); - - describe('on data source errors', () => { - let baseDir: string; - beforeAll(async () => { - try { - baseDir = await mkdtemp(join(tmpdir(), 'yasumi-')); - } catch { - // noop - } - }); - afterAll(async () => { - try { - await rm(baseDir, { - recursive: true, - }); - } catch { - // noop - } - }); - - let Piano: Resource; - beforeEach(() => { - Piano = resource(v.object( - { - brand: v.string() - }, - v.never() - )) - .name('Piano' as const) - .route('pianos' as const) - .id('id' as const, { - generationStrategy: autoIncrement, - serialize: (id) => id?.toString() ?? '0', - deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, - schema: v.number(), - }); - }); - - let backend: Backend; - let server: ReturnType; - beforeEach(() => { - const app = application({ - name: 'piano-service', - }) - .resource(Piano); - - backend = app.createBackend({ - dataSource: new dataSources.jsonlFile.DataSource(baseDir), - }); - - server = backend.createHttpServer({ - basePath: BASE_PATH - }); - - return new Promise((resolve, reject) => { - server.on('error', (err) => { - reject(err); - }); - - server.on('listening', () => { - resolve(); - }); - - server.listen({ - port: PORT - }); - }); - }); - - afterEach(() => new Promise((resolve, reject) => { - server.close((err) => { - if (err) { - reject(err); - } - - resolve(); - }); - })); - - describe.skip('serving collections', () => { - beforeEach(() => { - Piano.canFetchCollection(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); - }); - - afterEach(() => { - Piano.canFetchCollection(false); - }); - }); - - describe('serving items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); - - beforeEach(() => { - Piano.canFetchItem(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); - }); - - afterEach(() => { - Piano.canFetchItem(false); - }); - - it('throws on item not found', async () => { + it('throws on unable to check if item exists', async () => { const [res] = await client({ - method: 'GET', + method: 'DELETE', path: `${BASE_PATH}/pianos/2`, }); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); }); - it('throws on item not found on HEAD method', async () => { + it('throws on item not found', async () => { + const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); + getById.mockResolvedValueOnce(null as never); + const [res] = await client({ - method: 'HEAD', + method: 'DELETE', path: `${BASE_PATH}/pianos/2`, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano'); }); - }); - describe.skip('creating items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; - - const newData = { - brand: 'K. Kawai' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); - - beforeEach(() => { - Piano.canCreate(); - }); - - afterEach(() => { - Piano.canCreate(false); - }); - }); - - describe.skip('patching items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; - - const newData = { - brand: 'K. Kawai' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); - - beforeEach(() => { - Piano.canPatch(); - }); - - afterEach(() => { - Piano.canPatch(false); - }); - - it('throws on item to patch not found', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/2`, - method: 'PATCH', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - 'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); + it('throws on unable to delete item', async () => { + const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); + getById.mockResolvedValueOnce({ + id: 2 + } as never); - req.write(JSON.stringify(newData)); - req.end(); + const [res] = await client({ + method: 'DELETE', + path: `${BASE_PATH}/pianos/2`, }); - }); - }); - describe.skip('emplacing items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; - - const newData = { - id: 1, - brand: 'K. Kawai' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); - - beforeEach(() => { - Piano.canEmplace(); - }); - - afterEach(() => { - Piano.canEmplace(false); - }); - }); - - describe.skip('deleting items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); - - beforeEach(() => { - Piano.canDelete(); - backend.throwsErrorOnDeletingNotFound(); - }); - - afterEach(() => { - Piano.canDelete(false); - backend.throwsErrorOnDeletingNotFound(false); - }); - - it('throws on item not found', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/2`, - method: 'DELETE', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano'); }); }); }); diff --git a/test/utils.ts b/test/utils.ts index aeed614..15ea0b2 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,10 +1,11 @@ -import {IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http'; +import {IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http'; import {Method} from '../src/backend/common'; +import {DataSource} from '../src/backend/data-source'; interface ClientParams { method: Method; path: string; - headers?: OutgoingHttpHeaders; + headers?: IncomingHttpHeaders; body?: unknown; } @@ -23,18 +24,17 @@ export const createTestClient = (options: Omit new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => { const { - 'content-type': contentTypeHeader, ...etcAdditionalHeaders } = additionalHeaders; const headers: OutgoingHttpHeaders = { ...(options.headers ?? {}), - ...(params.headers ?? {}), ...etcAdditionalHeaders, }; + let contentTypeHeader: string | undefined; if (typeof params.body !== 'undefined') { - headers['content-type'] = contentTypeHeader; + contentTypeHeader = headers['content-type'] = params.headers?.['content-type'] ?? 'application/json'; } const req = request({ @@ -141,3 +141,52 @@ export const createTestClient = (options: Omit { + throw new DummyError(); + } + + delete(): Promise { + throw new DummyError(); + } + + emplace(): Promise { + throw new DummyError(); + } + + getById(): Promise { + throw new DummyError(); + } + + newId(): Promise { + throw new DummyError(); + } + + getMultiple(): Promise { + throw new DummyError(); + } + + getSingle(): Promise { + throw new DummyError(); + } + + getTotalCount(): Promise { + throw new DummyError(); + } + + async initialize(): Promise {} + + patch(): Promise { + throw new DummyError(); + } + + prepareResource(rr: unknown) { + this.resource = rr as unknown as { dataSource: DummyDataSource }; + this.resource.dataSource = this; + } +}