@@ -47,3 +47,19 @@ See [docs folder](./docs) for more details. | |||||
- RFC 9288 - Web Linking | - RFC 9288 - Web Linking | ||||
https://httpwg.org/specs/rfc8288.html | 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 |
@@ -9,7 +9,7 @@ import { | |||||
RequestContext, RequestDecorator, | RequestContext, RequestDecorator, | ||||
Response, | Response, | ||||
} from '../../common'; | } from '../../common'; | ||||
import {Resource} from '../../../common'; | |||||
import {CanPatchSpec, Resource} from '../../../common'; | |||||
import { | import { | ||||
handleGetRoot, handleOptions, | handleGetRoot, handleOptions, | ||||
} from './handlers/default'; | } from './handlers/default'; | ||||
@@ -78,15 +78,57 @@ 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; | ||||
return ( | |||||
schema.type === 'object' | |||||
? v.partial( | |||||
schema as v.ObjectSchema<any>, | |||||
(schema as v.ObjectSchema<any>).rest, | |||||
(schema as v.ObjectSchema<any>).pipe | |||||
) | |||||
: schema | |||||
); | |||||
if (schema.type !== 'object') { | |||||
return schema; | |||||
} | |||||
const schemaChoices = { | |||||
merge: v.partial( | |||||
schema as v.ObjectSchema<any>, | |||||
(schema as v.ObjectSchema<any>).rest, | |||||
(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? | |||||
}), | |||||
]) | |||||
), | |||||
} | |||||
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 | // TODO add a way to define custom middlewares | ||||
const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ | const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ | ||||
@@ -209,7 +251,7 @@ 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') | |||||
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') { | ||||
@@ -2,6 +2,7 @@ import {constants} from 'http2'; | |||||
import {AllowedMiddlewareSpecification, Middleware} from '../../../common'; | import {AllowedMiddlewareSpecification, Middleware} from '../../../common'; | ||||
import {LinkMap} from '../utils'; | import {LinkMap} from '../utils'; | ||||
import {PlainResponse, ErrorPlainResponse} from '../response'; | import {PlainResponse, ErrorPlainResponse} from '../response'; | ||||
import {PATCH_CONTENT_MAP_TYPE} from '../../../../common'; | |||||
export const handleGetRoot: Middleware = (req, res) => { | export const handleGetRoot: Middleware = (req, res) => { | ||||
const { backend, basePath } = req; | 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) { | if (middlewares.length > 0) { | ||||
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | |||||
const headers: Record<string, string> = { | |||||
'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({ | return new PlainResponse({ | ||||
headers: { | |||||
'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '), | |||||
}, | |||||
headers, | |||||
statusMessage: 'provideOptions', | statusMessage: 'provideOptions', | ||||
statusCode: constants.HTTP_STATUS_NO_CONTENT, | statusCode: constants.HTTP_STATUS_NO_CONTENT, | ||||
res, | res, | ||||
@@ -3,6 +3,12 @@ import * as v from 'valibot'; | |||||
import {Middleware} from '../../../common'; | import {Middleware} from '../../../common'; | ||||
import {ErrorPlainResponse, PlainResponse} from '../response'; | import {ErrorPlainResponse, PlainResponse} from '../response'; | ||||
import assert from 'assert'; | import assert from 'assert'; | ||||
import { | |||||
CanPatchSpec, | |||||
PATCH_CONTENT_MAP_TYPE, | |||||
PATCH_CONTENT_TYPES, | |||||
PatchContentType, | |||||
} from '../../../../common'; | |||||
export const handleGetCollection: Middleware = async (req, res) => { | export const handleGetCollection: Middleware = async (req, res) => { | ||||
const { query, resource, backend } = req; | const { query, resource, backend } = req; | ||||
@@ -46,6 +52,7 @@ const isResourceIdDefined = (resourceId?: string): resourceId is string => !( | |||||
export const handleGetItem: Middleware = async (req, res) => { | export const handleGetItem: Middleware = async (req, res) => { | ||||
const { resource, resourceId } = req; | const { resource, resourceId } = req; | ||||
assert( | assert( | ||||
isResourceIdDefined(resourceId), | isResourceIdDefined(resourceId), | ||||
new ErrorPlainResponse( | new ErrorPlainResponse( | ||||
@@ -92,15 +99,16 @@ export const handleGetItem: Middleware = async (req, res) => { | |||||
export const handleDeleteItem: Middleware = async (req, res) => { | export const handleDeleteItem: Middleware = async (req, res) => { | ||||
const { resource, resourceId, backend } = req; | const { resource, resourceId, backend } = req; | ||||
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { | |||||
throw new ErrorPlainResponse( | |||||
assert( | |||||
isResourceIdDefined(resourceId), | |||||
new ErrorPlainResponse( | |||||
'resourceIdNotGiven', | 'resourceIdNotGiven', | ||||
{ | { | ||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | statusCode: constants.HTTP_STATUS_BAD_REQUEST, | ||||
res, | res, | ||||
} | } | ||||
); | |||||
} | |||||
) | |||||
); | |||||
let existing: unknown | null; | let existing: unknown | null; | ||||
try { | try { | ||||
@@ -122,6 +130,7 @@ export const handleDeleteItem: Middleware = async (req, res) => { | |||||
try { | try { | ||||
if (existing) { | if (existing) { | ||||
// TODO should we still deal with the delete return? | |||||
await resource.dataSource.delete(resourceId); | await resource.dataSource.delete(resourceId); | ||||
} | } | ||||
} catch (cause) { | } 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) => { | 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', | 'resourceIdNotGiven', | ||||
{ | { | ||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | statusCode: constants.HTTP_STATUS_BAD_REQUEST, | ||||
res, | 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!); | ||||
@@ -269,21 +330,32 @@ export const handleCreateItem: Middleware = async (req, res) => { | |||||
export const handleEmplaceItem: Middleware = async (req, res) => { | export const handleEmplaceItem: Middleware = async (req, res) => { | ||||
const { resource, resourceId, basePath, body, backend } = req; | 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, | statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | ||||
res, | res, | ||||
}); | |||||
} | |||||
const idAttr = idAttrRaw as string; | |||||
}) | |||||
); | |||||
let newObject: v.Output<typeof resource.schema>; | let newObject: v.Output<typeof resource.schema>; | ||||
let isCreated: boolean; | let isCreated: boolean; | ||||
try { | try { | ||||
const params = { ...body as Record<string, unknown> }; | const params = { ...body as Record<string, unknown> }; | ||||
params[idAttr] = resourceId; | params[idAttr] = resourceId; | ||||
[newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params); | |||||
[newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); | |||||
} catch (cause) { | } catch (cause) { | ||||
throw new ErrorPlainResponse('unableToEmplaceResource', { | throw new ErrorPlainResponse('unableToEmplaceResource', { | ||||
cause, | cause, | ||||
@@ -1,10 +1,12 @@ | |||||
import {IncomingMessage} from 'http'; | import {IncomingMessage} from 'http'; | ||||
import {PATCH_CONTENT_TYPES} from '../../../common'; | |||||
export const isTextMediaType = (mediaType: string) => ( | export const isTextMediaType = (mediaType: string) => ( | ||||
mediaType.startsWith('text/') | mediaType.startsWith('text/') | ||||
|| [ | || [ | ||||
'application/json', | 'application/json', | ||||
'application/xml' | |||||
'application/xml', | |||||
...PATCH_CONTENT_TYPES, | |||||
].includes(mediaType) | ].includes(mediaType) | ||||
); | ); | ||||
@@ -1,6 +1,6 @@ | |||||
import {Resource} from './resource'; | import {Resource} from './resource'; | ||||
import {FALLBACK_LANGUAGE, Language} from './language'; | 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 {Charset, FALLBACK_CHARSET} from './charset'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Backend, createBackend, CreateBackendParams} from '../backend'; | import {Backend, createBackend, CreateBackendParams} from '../backend'; | ||||
@@ -55,6 +55,16 @@ export const application = (appParams: ApplicationParams): Application => { | |||||
]), | ]), | ||||
mediaTypes: new Map<MediaType['name'], MediaType>([ | mediaTypes: new Map<MediaType['name'], MediaType>([ | ||||
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], | [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<Charset['name'], Charset>([ | charsets: new Map<Charset['name'], Charset>([ | ||||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | [FALLBACK_CHARSET.name, FALLBACK_CHARSET], | ||||
@@ -31,6 +31,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||||
'patchNonExistingResource', | 'patchNonExistingResource', | ||||
'unableToPatchResource', | 'unableToPatchResource', | ||||
'invalidResourcePatch', | 'invalidResourcePatch', | ||||
'invalidResourcePatchType', | |||||
'invalidResource', | 'invalidResource', | ||||
'resourcePatched', | 'resourcePatched', | ||||
'resourceCreated', | 'resourceCreated', | ||||
@@ -89,6 +90,7 @@ export const FALLBACK_LANGUAGE = { | |||||
patchNonExistingResource: 'Patch Non-Existing $RESOURCE', | patchNonExistingResource: 'Patch Non-Existing $RESOURCE', | ||||
unableToPatchResource: 'Unable To Patch $RESOURCE', | unableToPatchResource: 'Unable To Patch $RESOURCE', | ||||
invalidResourcePatch: 'Invalid $RESOURCE Patch', | invalidResourcePatch: 'Invalid $RESOURCE Patch', | ||||
invalidResourcePatchType: 'Invalid $RESOURCE Patch Type', | |||||
invalidResource: 'Invalid $RESOURCE', | invalidResource: 'Invalid $RESOURCE', | ||||
resourcePatched: '$RESOURCE Patched', | resourcePatched: '$RESOURCE Patched', | ||||
resourceCreated: '$RESOURCE Created', | resourceCreated: '$RESOURCE Created', | ||||
@@ -9,3 +9,10 @@ export const FALLBACK_MEDIA_TYPE = { | |||||
deserialize: (str: string) => JSON.parse(str), | deserialize: (str: string) => JSON.parse(str), | ||||
name: 'application/json' as const, | name: 'application/json' as const, | ||||
} satisfies MediaType; | } 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]; |
@@ -1,5 +1,16 @@ | |||||
import * as v from 'valibot'; | 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<PatchContentType, CanPatchSpec> = { | |||||
'application/merge-patch+json': 'merge', | |||||
'application/json-patch+json': 'delta', | |||||
}; | |||||
type CanPatchObject = Record<CanPatchSpec, boolean>; | |||||
export interface ResourceState< | export interface ResourceState< | ||||
ItemName extends string = string, | ItemName extends string = string, | ||||
@@ -12,11 +23,13 @@ export interface ResourceState< | |||||
canCreate: boolean; | canCreate: boolean; | ||||
canFetchCollection: boolean; | canFetchCollection: boolean; | ||||
canFetchItem: boolean; | canFetchItem: boolean; | ||||
canPatch: boolean; | |||||
canPatch: CanPatchObject; | |||||
canEmplace: boolean; | canEmplace: boolean; | ||||
canDelete: boolean; | canDelete: boolean; | ||||
} | } | ||||
type CanPatch = boolean | CanPatchObject | CanPatchSpec[]; | |||||
export interface Resource< | export interface Resource< | ||||
Schema extends v.BaseSchema = v.BaseSchema, | Schema extends v.BaseSchema = v.BaseSchema, | ||||
CurrentName extends string = string, | CurrentName extends string = string, | ||||
@@ -29,7 +42,7 @@ export interface Resource< | |||||
canFetchCollection(b?: boolean): this; | canFetchCollection(b?: boolean): this; | ||||
canFetchItem(b?: boolean): this; | canFetchItem(b?: boolean): this; | ||||
canCreate(b?: boolean): this; | canCreate(b?: boolean): this; | ||||
canPatch(b?: boolean): this; | |||||
canPatch(b?: CanPatch): this; | |||||
canEmplace(b?: boolean): this; | canEmplace(b?: boolean): this; | ||||
canDelete(b?: boolean): this; | canDelete(b?: boolean): this; | ||||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): this; | relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): this; | ||||
@@ -46,7 +59,10 @@ export const resource = < | |||||
canCreate: false, | canCreate: false, | ||||
canFetchCollection: false, | canFetchCollection: false, | ||||
canFetchItem: false, | canFetchItem: false, | ||||
canPatch: false, | |||||
canPatch: { | |||||
merge: false, | |||||
delta: false, | |||||
}, | |||||
canEmplace: false, | canEmplace: false, | ||||
canDelete: false, | canDelete: false, | ||||
} as ResourceState<CurrentName, CurrentRouteName>; | } as ResourceState<CurrentName, CurrentRouteName>; | ||||
@@ -69,8 +85,27 @@ export const resource = < | |||||
resourceState.canCreate = b; | resourceState.canCreate = b; | ||||
return this; | 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; | return this; | ||||
}, | }, | ||||
canEmplace(b = true) { | canEmplace(b = true) { | ||||
@@ -109,7 +144,7 @@ export const resource = < | |||||
get schema() { | get schema() { | ||||
return schema; | return schema; | ||||
}, | }, | ||||
relatedTo<RelatedSchema extends BaseSchema>(resource: Resource<RelatedSchema>) { | |||||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>) { | |||||
resourceState.relationships.add(resource); | resourceState.relationships.add(resource); | ||||
return this; | return this; | ||||
}, | }, | ||||
@@ -6,23 +6,14 @@ import { | |||||
describe, | describe, | ||||
expect, | expect, | ||||
it, | it, | ||||
vi, | |||||
} from 'vitest'; | } from 'vitest'; | ||||
import { | |||||
tmpdir | |||||
} from 'os'; | |||||
import { | |||||
mkdtemp, | |||||
rm, | |||||
writeFile, | |||||
} from 'fs/promises'; | |||||
import { | |||||
join | |||||
} from 'path'; | |||||
import {constants} from 'http2'; | 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 { 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 PORT = 3000; | ||||
const HOST = '127.0.0.1'; | const HOST = '127.0.0.1'; | ||||
@@ -33,40 +24,17 @@ const ACCEPT_CHARSET = 'utf-8'; | |||||
const CONTENT_TYPE_CHARSET = 'utf-8'; | const CONTENT_TYPE_CHARSET = 'utf-8'; | ||||
const CONTENT_TYPE = ACCEPT; | 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; | let Piano: Resource; | ||||
beforeEach(() => { | |||||
let app: Application; | |||||
let dataSource: DataSource; | |||||
let backend: Backend; | |||||
let server: ReturnType<Backend['createHttpServer']>; | |||||
let client: TestClient; | |||||
beforeAll(() => { | |||||
Piano = resource(v.object( | Piano = resource(v.object( | ||||
{ | { | ||||
brand: v.string() | brand: v.string() | ||||
@@ -81,24 +49,32 @@ describe('happy path', () => { | |||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | schema: v.number(), | ||||
}); | }); | ||||
}); | |||||
let backend: Backend; | |||||
let server: ReturnType<Backend['createHttpServer']>; | |||||
beforeEach(() => { | |||||
const app = application({ | |||||
app = application({ | |||||
name: 'piano-service', | name: 'piano-service', | ||||
}) | }) | ||||
.resource(Piano); | .resource(Piano); | ||||
dataSource = new DummyDataSource(); | |||||
backend = app.createBackend({ | backend = app.createBackend({ | ||||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||||
dataSource, | |||||
}); | }); | ||||
server = backend.createHttpServer({ | server = backend.createHttpServer({ | ||||
basePath: BASE_PATH | 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) => { | return new Promise((resolve, reject) => { | ||||
server.on('error', (err) => { | server.on('error', (err) => { | ||||
reject(err); | reject(err); | ||||
@@ -114,7 +90,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
}); | }); | ||||
afterEach(() => new Promise((resolve, reject) => { | |||||
afterAll(() => new Promise((resolve, reject) => { | |||||
server.close((err) => { | server.close((err) => { | ||||
if (err) { | if (err) { | ||||
reject(err); | reject(err); | ||||
@@ -124,14 +100,19 @@ describe('happy path', () => { | |||||
}); | }); | ||||
})); | })); | ||||
afterAll(() => { | |||||
portCounter = 0; | |||||
}); | |||||
describe('serving collections', () => { | describe('serving collections', () => { | ||||
beforeEach(() => { | |||||
vi | |||||
.spyOn(DummyDataSource.prototype, 'getMultiple') | |||||
.mockResolvedValueOnce([] as never); | |||||
}); | |||||
beforeEach(() => { | beforeEach(() => { | ||||
Piano.canFetchCollection(); | Piano.canFetchCollection(); | ||||
return new Promise((resolve) => { | |||||
setTimeout(() => { | |||||
resolve(); | |||||
}); | |||||
}); | |||||
}); | }); | ||||
afterEach(() => { | afterEach(() => { | ||||
@@ -145,7 +126,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | 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)); | expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | ||||
if (typeof resData === 'undefined') { | if (typeof resData === 'undefined') { | ||||
@@ -163,6 +144,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | ||||
expect(res).toHaveProperty('statusMessage', 'Piano Collection Fetched'); | |||||
}); | }); | ||||
it('returns options', async () => { | it('returns options', async () => { | ||||
@@ -172,6 +154,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | 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()) ?? []; | const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | ||||
expect(allowedMethods).toContain('GET'); | expect(allowedMethods).toContain('GET'); | ||||
expect(allowedMethods).toContain('HEAD'); | expect(allowedMethods).toContain('HEAD'); | ||||
@@ -184,18 +167,14 @@ describe('happy path', () => { | |||||
brand: 'Yamaha' | 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(() => { | beforeEach(() => { | ||||
Piano.canFetchItem(); | Piano.canFetchItem(); | ||||
return new Promise((resolve) => { | |||||
setTimeout(() => { | |||||
resolve(); | |||||
}); | |||||
}); | |||||
}); | }); | ||||
afterEach(() => { | afterEach(() => { | ||||
@@ -209,6 +188,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | ||||
expect(res).toHaveProperty('statusMessage', 'Piano Fetched'); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | ||||
if (typeof resData === 'undefined') { | if (typeof resData === 'undefined') { | ||||
expect.fail('Response body must be defined.'); | 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('statusCode', constants.HTTP_STATUS_OK); | ||||
expect(res).toHaveProperty('statusMessage', 'Piano Fetched'); | |||||
}); | }); | ||||
it('returns options', async () => { | it('returns options', async () => { | ||||
@@ -234,6 +215,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | 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()) ?? []; | const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | ||||
expect(allowedMethods).toContain('GET'); | expect(allowedMethods).toContain('GET'); | ||||
expect(allowedMethods).toContain('HEAD'); | expect(allowedMethods).toContain('HEAD'); | ||||
@@ -241,18 +223,25 @@ describe('happy path', () => { | |||||
}); | }); | ||||
describe('creating items', () => { | describe('creating items', () => { | ||||
const existingResource = { | |||||
id: 1, | |||||
brand: 'Yamaha' | |||||
}; | |||||
const newResourceData = { | const newResourceData = { | ||||
brand: 'K. Kawai' | 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(() => { | beforeEach(() => { | ||||
@@ -271,6 +260,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | 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('content-type', expect.stringContaining(ACCEPT)); | ||||
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`); | 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('statusCode', constants.HTTP_STATUS_NO_CONTENT); | ||||
expect(res).toHaveProperty('statusMessage', 'Provide Options'); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | ||||
expect(allowedMethods).toContain('POST'); | expect(allowedMethods).toContain('POST'); | ||||
}); | }); | ||||
@@ -307,9 +298,19 @@ describe('happy path', () => { | |||||
brand: 'K. Kawai' | 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(() => { | beforeEach(() => { | ||||
@@ -325,9 +326,13 @@ describe('happy path', () => { | |||||
method: 'PATCH', | method: 'PATCH', | ||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | path: `${BASE_PATH}/pianos/${existingResource.id}`, | ||||
body: patchData, | body: patchData, | ||||
headers: { | |||||
'content-type': 'application/merge-patch+json', | |||||
}, | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | ||||
expect(res).toHaveProperty('statusMessage', 'Piano Patched'); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | ||||
if (typeof resData === 'undefined') { | if (typeof resData === 'undefined') { | ||||
@@ -348,8 +353,12 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | 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()) ?? []; | const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | ||||
expect(allowedMethods).toContain('PATCH'); | 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' | brand: 'K. Kawai' | ||||
}; | }; | ||||
beforeEach(async () => { | |||||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||||
}); | |||||
beforeEach(() => { | beforeEach(() => { | ||||
Piano.canEmplace(); | Piano.canEmplace(); | ||||
}); | }); | ||||
@@ -378,6 +382,13 @@ describe('happy path', () => { | |||||
}); | }); | ||||
it('returns data for replacement', async () => { | it('returns data for replacement', async () => { | ||||
vi | |||||
.spyOn(DummyDataSource.prototype, 'emplace') | |||||
.mockResolvedValueOnce([{ | |||||
...existingResource, | |||||
...emplaceResourceData, | |||||
}, false] as never); | |||||
const [res, resData] = await client({ | const [res, resData] = await client({ | ||||
method: 'PUT', | method: 'PUT', | ||||
path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, | path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, | ||||
@@ -385,6 +396,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | ||||
expect(res).toHaveProperty('statusMessage', 'Piano Replaced'); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | ||||
if (typeof resData === 'undefined') { | if (typeof resData === 'undefined') { | ||||
@@ -398,6 +410,14 @@ describe('happy path', () => { | |||||
it('returns data for creation', async () => { | it('returns data for creation', async () => { | ||||
const newId = 2; | const newId = 2; | ||||
vi | |||||
.spyOn(DummyDataSource.prototype, 'emplace') | |||||
.mockResolvedValueOnce([{ | |||||
...existingResource, | |||||
...emplaceResourceData, | |||||
id: newId | |||||
}, true] as never); | |||||
const [res, resData] = await client({ | const [res, resData] = await client({ | ||||
method: 'PUT', | method: 'PUT', | ||||
path: `${BASE_PATH}/pianos/${newId}`, | path: `${BASE_PATH}/pianos/${newId}`, | ||||
@@ -408,6 +428,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | 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('content-type', expect.stringContaining(ACCEPT)); | ||||
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`); | 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('statusCode', constants.HTTP_STATUS_NO_CONTENT); | ||||
expect(res).toHaveProperty('statusMessage', 'Provide Options'); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | ||||
expect(allowedMethods).toContain('PUT'); | expect(allowedMethods).toContain('PUT'); | ||||
}); | }); | ||||
@@ -440,9 +462,16 @@ describe('happy path', () => { | |||||
brand: 'Yamaha' | 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(() => { | beforeEach(() => { | ||||
@@ -460,6 +489,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | ||||
expect(res).toHaveProperty('statusMessage', 'Piano Deleted'); | |||||
expect(res.headers).not.toHaveProperty('content-type'); | expect(res.headers).not.toHaveProperty('content-type'); | ||||
expect(resData).toBeUndefined(); | expect(resData).toBeUndefined(); | ||||
}); | }); | ||||
@@ -471,6 +501,7 @@ describe('happy path', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | 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()) ?? []; | const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | ||||
expect(allowedMethods).toContain('DELETE'); | expect(allowedMethods).toContain('DELETE'); | ||||
}); | }); | ||||
@@ -20,13 +20,13 @@ import { | |||||
} from 'path'; | } from 'path'; | ||||
import {request} from 'http'; | import {request} from 'http'; | ||||
import {constants} from 'http2'; | 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 { application, resource, validation as v, Resource } from '../../../src/common'; | ||||
import { autoIncrement } from '../../fixtures'; | import { autoIncrement } from '../../fixtures'; | ||||
import {createTestClient, TestClient} from '../../utils'; | |||||
import { createTestClient, TestClient, DummyDataSource } from '../../utils'; | |||||
import {DataSource} from '../../../src/backend/data-source'; | import {DataSource} from '../../../src/backend/data-source'; | ||||
const PORT = 3001; | |||||
const PORT = 4001; | |||||
const HOST = '127.0.0.1'; | const HOST = '127.0.0.1'; | ||||
const BASE_PATH = '/api'; | const BASE_PATH = '/api'; | ||||
const ACCEPT = 'application/json'; | const ACCEPT = 'application/json'; | ||||
@@ -35,53 +35,6 @@ const ACCEPT_CHARSET = 'utf-8'; | |||||
const CONTENT_TYPE_CHARSET = 'utf-8'; | const CONTENT_TYPE_CHARSET = 'utf-8'; | ||||
const CONTENT_TYPE = ACCEPT; | const CONTENT_TYPE = ACCEPT; | ||||
class DummyDataSource implements DataSource { | |||||
private resource?: { dataSource?: unknown }; | |||||
create(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
delete(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
emplace(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
getById(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
newId(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
getMultiple(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
getSingle(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
getTotalCount(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
async initialize(): Promise<void> {} | |||||
patch(): Promise<never> { | |||||
throw new Error(); | |||||
} | |||||
prepareResource(rr: unknown) { | |||||
this.resource = rr as unknown as { dataSource: DummyDataSource }; | |||||
this.resource.dataSource = this; | |||||
} | |||||
} | |||||
describe('error handling', () => { | describe('error handling', () => { | ||||
let client: TestClient; | let client: TestClient; | ||||
beforeEach(() => { | beforeEach(() => { | ||||
@@ -116,7 +69,7 @@ describe('error handling', () => { | |||||
}); | }); | ||||
let Piano: Resource; | let Piano: Resource; | ||||
beforeEach(() => { | |||||
beforeAll(() => { | |||||
Piano = resource(v.object( | Piano = resource(v.object( | ||||
{ | { | ||||
brand: v.string() | brand: v.string() | ||||
@@ -412,7 +365,7 @@ describe('error handling', () => { | |||||
}); | }); | ||||
}); | }); | ||||
describe.skip('deleting items', () => { | |||||
describe('deleting items', () => { | |||||
const data = { | const data = { | ||||
id: 1, | id: 1, | ||||
brand: 'Yamaha' | brand: 'Yamaha' | ||||
@@ -433,331 +386,42 @@ describe('error handling', () => { | |||||
backend.throwsErrorOnDeletingNotFound(false); | backend.throwsErrorOnDeletingNotFound(false); | ||||
}); | }); | ||||
it('throws on item not found', () => { | |||||
return new Promise<void>((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<Backend['createHttpServer']>; | |||||
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({ | const [res] = await client({ | ||||
method: 'GET', | |||||
method: 'DELETE', | |||||
path: `${BASE_PATH}/pianos/2`, | 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({ | const [res] = await client({ | ||||
method: 'HEAD', | |||||
method: 'DELETE', | |||||
path: `${BASE_PATH}/pianos/2`, | path: `${BASE_PATH}/pianos/2`, | ||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | 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<void>((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<void>((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'); | |||||
}); | }); | ||||
}); | }); | ||||
}); | }); | ||||
@@ -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 {Method} from '../src/backend/common'; | ||||
import {DataSource} from '../src/backend/data-source'; | |||||
interface ClientParams { | interface ClientParams { | ||||
method: Method; | method: Method; | ||||
path: string; | path: string; | ||||
headers?: OutgoingHttpHeaders; | |||||
headers?: IncomingHttpHeaders; | |||||
body?: unknown; | body?: unknown; | ||||
} | } | ||||
@@ -23,18 +24,17 @@ export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path' | |||||
const additionalHeaders: OutgoingHttpHeaders = {}; | const additionalHeaders: OutgoingHttpHeaders = {}; | ||||
const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => { | const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => { | ||||
const { | const { | ||||
'content-type': contentTypeHeader, | |||||
...etcAdditionalHeaders | ...etcAdditionalHeaders | ||||
} = additionalHeaders; | } = additionalHeaders; | ||||
const headers: OutgoingHttpHeaders = { | const headers: OutgoingHttpHeaders = { | ||||
...(options.headers ?? {}), | ...(options.headers ?? {}), | ||||
...(params.headers ?? {}), | |||||
...etcAdditionalHeaders, | ...etcAdditionalHeaders, | ||||
}; | }; | ||||
let contentTypeHeader: string | undefined; | |||||
if (typeof params.body !== 'undefined') { | if (typeof params.body !== 'undefined') { | ||||
headers['content-type'] = contentTypeHeader; | |||||
contentTypeHeader = headers['content-type'] = params.headers?.['content-type'] ?? 'application/json'; | |||||
} | } | ||||
const req = request({ | const req = request({ | ||||
@@ -141,3 +141,52 @@ export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path' | |||||
return client; | return client; | ||||
}; | }; | ||||
export class DummyError extends Error {} | |||||
export class DummyDataSource implements DataSource { | |||||
private resource?: { dataSource?: unknown }; | |||||
create(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
delete(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
emplace(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
getById(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
newId(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
getMultiple(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
getSingle(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
getTotalCount(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
async initialize(): Promise<void> {} | |||||
patch(): Promise<never> { | |||||
throw new DummyError(); | |||||
} | |||||
prepareResource(rr: unknown) { | |||||
this.resource = rr as unknown as { dataSource: DummyDataSource }; | |||||
this.resource.dataSource = this; | |||||
} | |||||
} |