@@ -9,7 +9,7 @@ import { | |||||
RequestContext, RequestDecorator, | RequestContext, RequestDecorator, | ||||
Response, | Response, | ||||
} from '../../common'; | } from '../../common'; | ||||
import {CanPatchSpec, Resource} from '../../../common'; | |||||
import {CanPatchSpec, DELTA_SCHEMA, PATCH_CONTENT_MAP_TYPE, PatchContentType, Resource} from '../../../common'; | |||||
import { | import { | ||||
handleGetRoot, handleOptions, | handleGetRoot, handleOptions, | ||||
} from './handlers/default'; | } from './handlers/default'; | ||||
@@ -79,8 +79,8 @@ const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<T>, mainR | |||||
const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | ||||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | ||||
if (schema.type !== 'object') { | |||||
return schema; | |||||
if (resource.schema.type !== 'object') { | |||||
return resource.schema; | |||||
} | } | ||||
const schemaChoices = { | const schemaChoices = { | ||||
@@ -89,39 +89,7 @@ const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => | |||||
(schema as v.ObjectSchema<any>).rest, | (schema as v.ObjectSchema<any>).rest, | ||||
(schema as v.ObjectSchema<any>).pipe | (schema as v.ObjectSchema<any>).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? | |||||
}), | |||||
]) | |||||
), | |||||
delta: v.array(DELTA_SCHEMA), | |||||
} | } | ||||
const selectedSchemaChoices = Object.entries(schemaChoices) | const selectedSchemaChoices = Object.entries(schemaChoices) | ||||
@@ -161,7 +129,7 @@ const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [ | |||||
method: 'PATCH', | method: 'PATCH', | ||||
middleware: handlePatchItem, | middleware: handlePatchItem, | ||||
constructBodySchema: constructPatchSchema, | constructBodySchema: constructPatchSchema, | ||||
allowed: (resource) => resource.state.canPatch, | |||||
allowed: (resource) => resource.state.canPatch.merge || resource.state.canPatch.delta, | |||||
}, | }, | ||||
{ | { | ||||
method: 'DELETE', | method: 'DELETE', | ||||
@@ -251,7 +219,29 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
) | ) | ||||
? charsetRaw.slice(1, -1).trim() | ? charsetRaw.slice(1, -1).trim() | ||||
: charsetRaw.trim() | : charsetRaw.trim() | ||||
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary') | |||||
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); | |||||
if (effectiveMethod === '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); | |||||
const isPatchEnabled = req.resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[mediaType as PatchContentType]]; | |||||
if (!isPatchEnabled) { | |||||
throw new ErrorPlainResponse('invalidResourcePatchType', { | |||||
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, | |||||
res: theRes, | |||||
headers: { | |||||
'Accept-Patch': validPatchContentTypes.join(', '), | |||||
}, | |||||
}); | |||||
} | |||||
} | |||||
const theBodyBuffer = await getBody(req); | const theBodyBuffer = await getBody(req); | ||||
const encodingPair = req.backend.app.charsets.get(charset); | const encodingPair = req.backend.app.charsets.get(charset); | ||||
if (typeof encodingPair === 'undefined') { | if (typeof encodingPair === 'undefined') { | ||||
@@ -277,6 +267,16 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
// TODO better error reporting, localizable messages | // TODO better error reporting, localizable messages | ||||
// TODO handle error handlers' errors | // TODO handle error handlers' errors | ||||
if (Array.isArray(err.issues)) { | if (Array.isArray(err.issues)) { | ||||
if (req.method === 'PATCH' && req.headers['content-type']?.startsWith('application/json-patch+json')) { | |||||
throw new ErrorPlainResponse('invalidResourcePatch', { | |||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||||
body: err.issues.map((i) => ( | |||||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||||
)), | |||||
res: theRes, | |||||
}); | |||||
} | |||||
throw new ErrorPlainResponse('invalidResource', { | throw new ErrorPlainResponse('invalidResource', { | ||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | statusCode: constants.HTTP_STATUS_BAD_REQUEST, | ||||
body: err.issues.map((i) => ( | body: err.issues.map((i) => ( | ||||
@@ -4,9 +4,9 @@ import {Middleware} from '../../../common'; | |||||
import {ErrorPlainResponse, PlainResponse} from '../response'; | import {ErrorPlainResponse, PlainResponse} from '../response'; | ||||
import assert from 'assert'; | import assert from 'assert'; | ||||
import { | import { | ||||
CanPatchSpec, | |||||
applyDelta, | |||||
Delta, | |||||
PATCH_CONTENT_MAP_TYPE, | PATCH_CONTENT_MAP_TYPE, | ||||
PATCH_CONTENT_TYPES, | |||||
PatchContentType, | PatchContentType, | ||||
} from '../../../../common'; | } from '../../../../common'; | ||||
@@ -148,10 +148,6 @@ 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) => { | export const handlePatchItem: Middleware = async (req, res) => { | ||||
const { | const { | ||||
resource, | resource, | ||||
@@ -171,51 +167,9 @@ export const handlePatchItem: Middleware = async (req, 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; | let existing: unknown | null; | ||||
try { | try { | ||||
existing = await resource.dataSource.getById(resourceId!); | |||||
existing = await resource.dataSource.getById(resourceId); | |||||
} catch (cause) { | } catch (cause) { | ||||
throw new ErrorPlainResponse('unableToFetchResource', { | throw new ErrorPlainResponse('unableToFetchResource', { | ||||
cause, | cause, | ||||
@@ -232,14 +186,53 @@ export const handlePatchItem: Middleware = async (req, res) => { | |||||
} | } | ||||
let newObject: v.Output<typeof resource.schema> | null; | let newObject: v.Output<typeof resource.schema> | null; | ||||
try { | |||||
newObject = await resource.dataSource.patch(resourceId!, body as object); | |||||
} catch (cause) { | |||||
throw new ErrorPlainResponse('unableToPatchResource', { | |||||
cause, | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
res, | |||||
}); | |||||
const patchType = PATCH_CONTENT_MAP_TYPE[headers['content-type'] as PatchContentType]; | |||||
switch (patchType) { | |||||
case 'merge': { | |||||
try { | |||||
newObject = await resource.dataSource.patch(resourceId, body as object); | |||||
} catch (cause) { | |||||
throw new ErrorPlainResponse('unableToPatchResource', { | |||||
cause, | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
res, | |||||
}); | |||||
} | |||||
break; | |||||
} | |||||
case 'delta': { | |||||
let modifiedObject: Record<string, unknown>; | |||||
try { | |||||
modifiedObject = await applyDelta( | |||||
resource.schema, | |||||
existing as Record<string, unknown>, | |||||
body as Delta[] | |||||
); | |||||
} catch (cause) { | |||||
throw new ErrorPlainResponse('invalidResourcePatch', { | |||||
cause, | |||||
statusCode: constants.HTTP_STATUS_UNPROCESSABLE_ENTITY, | |||||
res, | |||||
}); | |||||
} | |||||
try { | |||||
newObject = await resource.dataSource.patch(resourceId, modifiedObject); | |||||
} catch (cause) { | |||||
throw new ErrorPlainResponse('unableToPatchResource', { | |||||
cause, | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
res, | |||||
}); | |||||
} | |||||
break; | |||||
} | |||||
default: | |||||
throw new ErrorPlainResponse('invalidResourcePatchType', { | |||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||||
res, | |||||
}); | |||||
} | } | ||||
return new PlainResponse({ | return new PlainResponse({ | ||||
@@ -0,0 +1,125 @@ | |||||
import * as v from 'valibot'; | |||||
import {getObjectSchema} from './utils'; | |||||
import { | |||||
InvalidOperationError, | |||||
InvalidPathValueError, | |||||
InvalidSchemaInPathError, | |||||
PathValueTestFailedError, | |||||
} from './error'; | |||||
import {append, get, remove, set} from './object'; | |||||
export const DELTA_SCHEMA = v.union([ | |||||
v.object({ | |||||
op: v.literal('add'), | |||||
path: v.string(), // todo validate if valid path? | |||||
value: v.unknown() // 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.unknown() // 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.unknown() // todo validate if valid value? | |||||
}), | |||||
]); | |||||
export type Delta = v.Output<typeof DELTA_SCHEMA>; | |||||
export const applyDelta = async <T extends v.BaseSchema = v.BaseSchema>( | |||||
resourceSchema: T, | |||||
existing: Record<string, unknown>, | |||||
deltaCollection: Delta[], | |||||
) => { | |||||
return await deltaCollection.reduce( | |||||
async (resultObject, deltaItem) => { | |||||
const mutablePreviousObject = await resultObject; | |||||
if (resourceSchema.type !== 'object') { | |||||
return mutablePreviousObject; | |||||
} | |||||
const resourceObjectSchema = resourceSchema as unknown as v.ObjectSchema<any>; | |||||
const pathSchema = getObjectSchema(resourceObjectSchema, deltaItem.path); | |||||
if (typeof pathSchema === 'undefined') { | |||||
throw new InvalidSchemaInPathError(); | |||||
} | |||||
switch (deltaItem.op) { | |||||
case 'replace': { | |||||
if (!v.is(pathSchema, deltaItem.value)) { | |||||
throw new InvalidPathValueError(); | |||||
} | |||||
set(mutablePreviousObject, deltaItem.path, deltaItem.value); | |||||
return mutablePreviousObject; | |||||
} | |||||
case 'add': { | |||||
if (pathSchema.type !== 'array') { | |||||
throw new InvalidOperationError(); | |||||
} | |||||
const arraySchema = pathSchema as v.ArraySchema<any>; | |||||
if (!v.is(arraySchema.item, deltaItem.value)) { | |||||
throw new InvalidPathValueError(); | |||||
} | |||||
return append(mutablePreviousObject, deltaItem.path, deltaItem.value); | |||||
} | |||||
case 'remove': { | |||||
return remove(mutablePreviousObject, deltaItem.path); | |||||
} | |||||
case 'copy': { | |||||
const value = get(mutablePreviousObject, deltaItem.from); | |||||
if (!v.is(pathSchema, value)) { | |||||
throw new InvalidPathValueError(); | |||||
} | |||||
return set(mutablePreviousObject, deltaItem.path, value); | |||||
} | |||||
case 'move': { | |||||
const value = get(mutablePreviousObject, deltaItem.from); | |||||
if (!v.is(pathSchema, value)) { | |||||
throw new InvalidPathValueError(); | |||||
} | |||||
remove(mutablePreviousObject, deltaItem.from) | |||||
return set(mutablePreviousObject, deltaItem.path, value); | |||||
} | |||||
case 'test': { | |||||
const value = get(mutablePreviousObject, deltaItem.path); | |||||
if (value !== deltaItem.value) { | |||||
throw new PathValueTestFailedError(); | |||||
} | |||||
return mutablePreviousObject; | |||||
} | |||||
default: | |||||
break; | |||||
} | |||||
if (!v.is(resourceObjectSchema, mutablePreviousObject)) { | |||||
throw new InvalidOperationError(); | |||||
} | |||||
return mutablePreviousObject; | |||||
}, | |||||
Promise.resolve(existing), | |||||
); | |||||
}; |
@@ -0,0 +1,8 @@ | |||||
export class InvalidSchemaInPathError extends Error {} | |||||
export class InvalidPathValueError extends Error {} | |||||
export class InvalidOperationError extends Error {} | |||||
export class PathValueTestFailedError extends Error {} |
@@ -0,0 +1,2 @@ | |||||
export * from './error'; | |||||
export * from './core'; |
@@ -0,0 +1,77 @@ | |||||
import {tokenizePath} from './utils'; | |||||
export const set = (origObject: Record<string, unknown>, path: string, value: unknown) => { | |||||
if (path.length <= 0) { | |||||
return origObject; | |||||
} | |||||
const pathFragments = tokenizePath(path); | |||||
let cursor = origObject; | |||||
let thisPath = pathFragments.shift() as string; | |||||
while (pathFragments.length > 0) { | |||||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||||
thisPath = pathFragments.shift() as string; | |||||
} | |||||
if (typeof cursor === 'undefined') { | |||||
throw new Error(`Could not set path: ${path}`); | |||||
} | |||||
cursor[thisPath] = value; | |||||
return origObject; | |||||
}; | |||||
export const get = (origObject: Record<string, unknown>, path: string) => { | |||||
if (path.length <= 0) { | |||||
return origObject; | |||||
} | |||||
const pathFragments = tokenizePath(path); | |||||
let cursor = origObject; | |||||
let thisPath = pathFragments.shift() as string; | |||||
while (pathFragments.length > 0) { | |||||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||||
thisPath = pathFragments.shift() as string; | |||||
} | |||||
return cursor?.[thisPath]; | |||||
}; | |||||
export const remove = (origObject: Record<string, unknown>, path: string) => { | |||||
if (path.length <= 0) { | |||||
return origObject; | |||||
} | |||||
const pathFragments = tokenizePath(path); | |||||
let cursor = origObject; | |||||
let thisPath = pathFragments.shift() as string; | |||||
while (pathFragments.length > 0) { | |||||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||||
thisPath = pathFragments.shift() as string; | |||||
} | |||||
if (typeof cursor === 'undefined') { | |||||
throw new Error(`Could not remove on path: ${path}`); | |||||
} | |||||
delete cursor[thisPath]; | |||||
return origObject; | |||||
}; | |||||
export const append = (origObject: Record<string, unknown>, path: string, value: unknown) => { | |||||
if (path.length <= 0) { | |||||
return origObject; | |||||
} | |||||
const pathFragments = tokenizePath(path); | |||||
let cursor = origObject; | |||||
let thisPath = pathFragments.shift() as string; | |||||
while (pathFragments.length > 0) { | |||||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||||
thisPath = pathFragments.shift() as string; | |||||
} | |||||
if (!Array.isArray(cursor?.[thisPath])) { | |||||
throw new Error(`Could not append on path: ${path}`); | |||||
} | |||||
(cursor[thisPath] as unknown[]).push(value); | |||||
return origObject; | |||||
}; |
@@ -0,0 +1,23 @@ | |||||
import * as v from 'valibot'; | |||||
export const DELTA_PATH_SEPARATOR = '/' as const; | |||||
export const tokenizePath = (path: string) => { | |||||
return path.split(DELTA_PATH_SEPARATOR); | |||||
}; | |||||
export const combinePathFragments = (pathFragments: string[]) => { | |||||
return pathFragments.join(DELTA_PATH_SEPARATOR); | |||||
}; | |||||
export const getObjectSchema = (schema?: v.ObjectSchema<any>, path?: string): v.BaseSchema => { | |||||
if (typeof path !== 'string') { | |||||
return schema as v.BaseSchema; | |||||
} | |||||
if (path.length <= 0) { | |||||
return schema as v.BaseSchema; | |||||
} | |||||
const pathFragments = tokenizePath(path); | |||||
const thisPath = pathFragments.shift() as string; | |||||
return getObjectSchema(schema?.entries?.[thisPath], combinePathFragments(pathFragments)); | |||||
}; |
@@ -4,6 +4,7 @@ import {MediaType} from './media-type'; | |||||
export * from './app'; | export * from './app'; | ||||
export * from './charset'; | export * from './charset'; | ||||
export * from './delta'; | |||||
export * from './media-type'; | export * from './media-type'; | ||||
export * from './resource'; | export * from './resource'; | ||||
export * from './language'; | export * from './language'; | ||||
@@ -28,7 +28,7 @@ export interface ResourceState< | |||||
canDelete: boolean; | canDelete: boolean; | ||||
} | } | ||||
type CanPatch = boolean | CanPatchObject | CanPatchSpec[]; | |||||
type CanPatch = boolean | Partial<CanPatchObject> | CanPatchSpec[]; | |||||
export interface Resource< | export interface Resource< | ||||
Schema extends v.BaseSchema = v.BaseSchema, | Schema extends v.BaseSchema = v.BaseSchema, | ||||
@@ -101,7 +101,7 @@ export const resource = < | |||||
} | } | ||||
if (b !== null) { | if (b !== null) { | ||||
CAN_PATCH_VALID_VALUES.forEach((p) => { | CAN_PATCH_VALID_VALUES.forEach((p) => { | ||||
resourceState.canPatch[p] = b[p]; | |||||
resourceState.canPatch[p] = b[p] ?? false; | |||||
}); | }); | ||||
} | } | ||||
} | } | ||||
@@ -282,7 +282,7 @@ describe.only('happy path', () => { | |||||
}); | }); | ||||
}); | }); | ||||
describe('patching items', () => { | |||||
describe.only('patching items', () => { | |||||
const existingResource = { | const existingResource = { | ||||
id: 1, | id: 1, | ||||
brand: 'Yamaha' | brand: 'Yamaha' | ||||
@@ -315,31 +315,6 @@ describe.only('happy path', () => { | |||||
Piano.canPatch(false); | Piano.canPatch(false); | ||||
}); | }); | ||||
it('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
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') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual({ | |||||
...existingResource, | |||||
...patchData, | |||||
}); | |||||
}); | |||||
it('returns options', async () => { | it('returns options', async () => { | ||||
const [res] = await client({ | const [res] = await client({ | ||||
method: 'OPTIONS', | method: 'OPTIONS', | ||||
@@ -354,6 +329,74 @@ describe.only('happy path', () => { | |||||
expect(acceptPatch).toContain('application/json-patch+json'); | expect(acceptPatch).toContain('application/json-patch+json'); | ||||
expect(acceptPatch).toContain('application/merge-patch+json'); | expect(acceptPatch).toContain('application/merge-patch+json'); | ||||
}); | }); | ||||
describe('on merge', () => { | |||||
beforeEach(() => { | |||||
Piano.canPatch(false).canPatch(['merge']); | |||||
}); | |||||
it('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
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') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual({ | |||||
...existingResource, | |||||
...patchData, | |||||
}); | |||||
}); | |||||
}); | |||||
describe('on delta', () => { | |||||
beforeEach(() => { | |||||
Piano.canPatch(false).canPatch(['delta']); | |||||
}); | |||||
it.only('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: [ | |||||
{ | |||||
op: 'replace', | |||||
path: 'brand', | |||||
value: patchData.brand, | |||||
}, | |||||
], | |||||
headers: { | |||||
'content-type': 'application/json-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') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual({ | |||||
...existingResource, | |||||
...patchData, | |||||
}); | |||||
}); | |||||
}); | |||||
}); | }); | ||||
describe('emplacing items', () => { | describe('emplacing items', () => { | ||||
@@ -7,10 +7,9 @@ import { | |||||
expect, | expect, | ||||
it, vi, | it, vi, | ||||
} from 'vitest'; | } from 'vitest'; | ||||
import {request} from 'http'; | |||||
import {constants} from 'http2'; | import {constants} from 'http2'; | ||||
import {Backend} from '../../../src/backend'; | import {Backend} from '../../../src/backend'; | ||||
import {application, resource, validation as v, Resource, Application} from '../../../src/common'; | |||||
import {application, resource, validation as v, Resource, Application, Delta} from '../../../src/common'; | |||||
import { autoIncrement } from '../../fixtures'; | import { autoIncrement } from '../../fixtures'; | ||||
import {createTestClient, TestClient, DummyDataSource, DummyError} from '../../utils'; | import {createTestClient, TestClient, DummyDataSource, DummyError} from '../../utils'; | ||||
import {DataSource} from '../../../src/backend/data-source'; | import {DataSource} from '../../../src/backend/data-source'; | ||||
@@ -262,17 +261,10 @@ describe('error handling', () => { | |||||
brand: 'K. Kawai' | brand: 'K. Kawai' | ||||
}; | }; | ||||
beforeEach(() => { | |||||
Piano.canPatch(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canPatch(false); | |||||
}); | |||||
// TODO add more tests | // TODO add more tests | ||||
it('throws on unable to fetch existing item', async () => { | it('throws on unable to fetch existing item', async () => { | ||||
Piano.canPatch(); | |||||
vi | vi | ||||
.spyOn(DummyDataSource.prototype, 'getById') | .spyOn(DummyDataSource.prototype, 'getById') | ||||
.mockImplementationOnce(() => { throw new DummyError() }); | .mockImplementationOnce(() => { throw new DummyError() }); | ||||
@@ -288,9 +280,11 @@ describe('error handling', () => { | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | ||||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); | expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); | ||||
Piano.canPatch(false); | |||||
}); | }); | ||||
it('throws on item to patch not found', async () => { | it('throws on item to patch not found', async () => { | ||||
Piano.canPatch(); | |||||
vi | vi | ||||
.spyOn(DummyDataSource.prototype, 'getById') | .spyOn(DummyDataSource.prototype, 'getById') | ||||
.mockResolvedValueOnce(null as never); | .mockResolvedValueOnce(null as never); | ||||
@@ -306,6 +300,119 @@ describe('error handling', () => { | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | ||||
expect(res).toHaveProperty('statusMessage', 'Patch Non-Existing Piano'); | expect(res).toHaveProperty('statusMessage', 'Patch Non-Existing Piano'); | ||||
Piano.canPatch(false); | |||||
}); | |||||
describe('on merge patch', () => { | |||||
const newMergeData = { | |||||
brand: 'K. Kawai' | |||||
}; | |||||
beforeEach(() => { | |||||
Piano.canPatch(['merge']); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canPatch(false); | |||||
}); | |||||
it('throws on attempting to request a delta patch', async () => { | |||||
const [res] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: newMergeData, | |||||
headers: { | |||||
'content-type': 'application/json-patch+json', | |||||
}, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); | |||||
expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch Type'); | |||||
}); | |||||
}); | |||||
describe('on delta patch', () => { | |||||
beforeEach(() => { | |||||
Piano.canPatch(['delta']); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canPatch(false); | |||||
}); | |||||
it('throws on attempting to request a merge patch', async () => { | |||||
const [res] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: { brand: 'Hello' }, | |||||
headers: { | |||||
'content-type': 'application/merge-patch+json', | |||||
}, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); | |||||
expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch Type'); | |||||
}); | |||||
it('throws on operating with a delta to an attribute outside the schema', async () => { | |||||
const [res] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: [ | |||||
{ | |||||
op: 'replace', | |||||
path: 'brandUnknown', | |||||
value: 'K. Kawai', | |||||
}, | |||||
] satisfies Delta[], | |||||
headers: { | |||||
'content-type': 'application/json-patch+json', | |||||
}, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); | |||||
expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch'); | |||||
}); | |||||
it('throws on operating a delta with mismatched value type', async () => { | |||||
const [res] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: [ | |||||
{ | |||||
op: 'replace', | |||||
path: 'brand', | |||||
value: 5, | |||||
}, | |||||
] satisfies Delta[], | |||||
headers: { | |||||
'content-type': 'application/json-patch+json', | |||||
}, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); | |||||
expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch'); | |||||
}); | |||||
it.skip('throws on performing an invalid delta', async () => { | |||||
const [res] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: [ | |||||
{ | |||||
op: 'add', | |||||
path: 'brand', | |||||
value: 5, | |||||
}, | |||||
] satisfies Delta[], | |||||
headers: { | |||||
'content-type': 'application/json-patch+json', | |||||
}, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); | |||||
expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch'); | |||||
}); | |||||
}); | }); | ||||
}); | }); | ||||