@@ -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 |
@@ -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 = <T extends v.BaseSchema>(resource: Resource<T>, mainR | |||
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; | |||
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 | |||
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') { | |||
@@ -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<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({ | |||
headers: { | |||
'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '), | |||
}, | |||
headers, | |||
statusMessage: 'provideOptions', | |||
statusCode: constants.HTTP_STATUS_NO_CONTENT, | |||
res, | |||
@@ -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<typeof resource.schema>; | |||
let isCreated: boolean; | |||
try { | |||
const params = { ...body as Record<string, unknown> }; | |||
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, | |||
@@ -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) | |||
); | |||
@@ -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<MediaType['name'], MediaType>([ | |||
[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>([ | |||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | |||
@@ -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', | |||
@@ -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]; |
@@ -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<PatchContentType, CanPatchSpec> = { | |||
'application/merge-patch+json': 'merge', | |||
'application/json-patch+json': 'delta', | |||
}; | |||
type CanPatchObject = Record<CanPatchSpec, boolean>; | |||
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<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): 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<CurrentName, CurrentRouteName>; | |||
@@ -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<RelatedSchema extends BaseSchema>(resource: Resource<RelatedSchema>) { | |||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>) { | |||
resourceState.relationships.add(resource); | |||
return this; | |||
}, | |||
@@ -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<Backend['createHttpServer']>; | |||
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<Backend['createHttpServer']>; | |||
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'); | |||
}); | |||
@@ -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<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', () => { | |||
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<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({ | |||
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<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 {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<RequestOptions, 'method' | 'path' | |||
const additionalHeaders: OutgoingHttpHeaders = {}; | |||
const client = (params: ClientParams) => 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<RequestOptions, 'method' | 'path' | |||
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; | |||
} | |||
} |