@@ -16,6 +16,7 @@ | |||||
- [X] Date/Datetime handling (endpoints should be able to accept timestamps and ISO date/datetime strings) | - [X] Date/Datetime handling (endpoints should be able to accept timestamps and ISO date/datetime strings) | ||||
- [ ] Querying items in collections | - [ ] Querying items in collections | ||||
- [ ] Better URL parsing for determining target resource/resource IDs (e.g. `/api/users/3/posts/5`, `/users/3` is a query, `posts` is the target resource, `5` is the target resource ID. Different case with `/api/users/3/posts/5/attachments`) | - [ ] Better URL parsing for determining target resource/resource IDs (e.g. `/api/users/3/posts/5`, `/users/3` is a query, `posts` is the target resource, `5` is the target resource ID. Different case with `/api/users/3/posts/5/attachments`) | ||||
- [ ] Declare relationship (e.g. `/users/3/posts`) | |||||
- [ ] Tests | - [ ] Tests | ||||
- [X] Happy path | - [X] Happy path | ||||
- [ ] Error handling | - [ ] Error handling | ||||
@@ -12,9 +12,9 @@ export interface Backend<T extends DataSource = DataSource> { | |||||
dataSource?: (resource: Resource) => T; | dataSource?: (resource: Resource) => T; | ||||
} | } | ||||
export interface CreateBackendParams { | |||||
export interface CreateBackendParams<T extends DataSource = DataSource> { | |||||
app: ApplicationState; | app: ApplicationState; | ||||
dataSource: DataSource; | |||||
dataSource: T; | |||||
} | } | ||||
export const createBackend = (params: CreateBackendParams) => { | export const createBackend = (params: CreateBackendParams) => { | ||||
@@ -279,7 +279,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
} | } | ||||
if (typeof resource.dataSource === 'undefined') { | if (typeof resource.dataSource === 'undefined') { | ||||
throw new ErrorPlainResponse('unableToInitializeResourceDataSource', { | |||||
throw new ErrorPlainResponse('unableToBindResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | ||||
res: theRes, | res: theRes, | ||||
}); | }); | ||||
@@ -362,16 +362,21 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
resourceReq.resource!.state.itemName) ?? ''; | resourceReq.resource!.state.itemName) ?? ''; | ||||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | ||||
res.end(); | res.end(); | ||||
return; | |||||
} | } | ||||
headers['Content-Type'] = [ | headers['Content-Type'] = [ | ||||
resourceReq.backend.cn.mediaType.name, | resourceReq.backend.cn.mediaType.name, | ||||
`charset=${resourceReq.backend.cn.charset.name}`, | |||||
].join('; '); | |||||
const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined; | |||||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; | |||||
res.writeHead(finalErr.statusCode, headers); | |||||
typeof serialized !== 'undefined' ? `charset=${resourceReq.backend.cn.charset.name}` : '', | |||||
] | |||||
.filter((s) => s.length > 0) | |||||
.join('; '); | |||||
res.statusMessage = resourceReq.backend.cn.language.statusMessages[ | |||||
finalErr.statusMessage ?? 'internalServerError' | |||||
]?.replace(/\$RESOURCE/g, | |||||
resourceReq.resource!.state.itemName); | |||||
res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers); | |||||
if (typeof encoded !== 'undefined') { | if (typeof encoded !== 'undefined') { | ||||
res.end(encoded); | res.end(encoded); | ||||
return; | return; | ||||
@@ -2,6 +2,7 @@ import { constants } from 'http2'; | |||||
import * as v from 'valibot'; | 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'; | |||||
export const handleGetCollection: Middleware = async (req, res) => { | export const handleGetCollection: Middleware = async (req, res) => { | ||||
const { query, resource, backend } = req; | const { query, resource, backend } = req; | ||||
@@ -39,18 +40,22 @@ export const handleGetCollection: Middleware = async (req, res) => { | |||||
}); | }); | ||||
}; | }; | ||||
const isResourceIdDefined = (resourceId?: string): resourceId is string => !( | |||||
typeof resourceId === 'undefined' || resourceId.trim().length < 1 | |||||
); | |||||
export const handleGetItem: Middleware = async (req, res) => { | export const handleGetItem: Middleware = async (req, res) => { | ||||
const { resource, resourceId } = req; | const { resource, resourceId } = 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 data: v.Output<typeof resource.schema> | null = null; | let data: v.Output<typeof resource.schema> | null = null; | ||||
try { | try { | ||||
@@ -184,17 +189,21 @@ export const handlePatchItem: Middleware = async (req, res) => { | |||||
}); | }); | ||||
}; | }; | ||||
const isIdAttributeDefined = (idAttr?: unknown): idAttr is string => ( | |||||
typeof idAttr !== 'undefined' | |||||
); | |||||
export const handleCreateItem: Middleware = async (req, res) => { | export const handleCreateItem: Middleware = async (req, res) => { | ||||
const { resource, body, backend, basePath } = req; | const { resource, body, backend, basePath } = req; | ||||
const idAttrRaw = resource.state.shared.get('idAttr'); | |||||
if (typeof idAttrRaw === 'undefined') { | |||||
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { | |||||
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 newId; | let newId; | ||||
let params: v.Output<typeof resource.schema>; | let params: v.Output<typeof resource.schema>; | ||||
@@ -203,7 +212,7 @@ export const handleCreateItem: Middleware = async (req, res) => { | |||||
params = { ...body as Record<string, unknown> }; | params = { ...body as Record<string, unknown> }; | ||||
params[idAttr] = newId; | params[idAttr] = newId; | ||||
} catch (cause) { | } catch (cause) { | ||||
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { | |||||
throw new ErrorPlainResponse('unableToAssignIdFromResourceDataSource', { | |||||
cause, | cause, | ||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | ||||
res, | res, | ||||
@@ -220,10 +229,11 @@ export const handleCreateItem: Middleware = async (req, res) => { | |||||
let totalItemCount: number | undefined; | let totalItemCount: number | undefined; | ||||
try { | try { | ||||
newObject = await resource.dataSource.create(params); | |||||
if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | ||||
totalItemCount = await resource.dataSource.getTotalCount(); | totalItemCount = await resource.dataSource.getTotalCount(); | ||||
totalItemCount += 1; | |||||
} | } | ||||
newObject = await resource.dataSource.create(params); | |||||
} catch (cause) { | } catch (cause) { | ||||
throw new ErrorPlainResponse('unableToCreateResource', { | throw new ErrorPlainResponse('unableToCreateResource', { | ||||
cause, | cause, | ||||
@@ -49,7 +49,7 @@ export interface Application< | |||||
export const application = (appParams: ApplicationParams): Application => { | export const application = (appParams: ApplicationParams): Application => { | ||||
const appState: ApplicationState = { | const appState: ApplicationState = { | ||||
name: appParams.name, | name: appParams.name, | ||||
resources: new Set<Resource<any>>(), | |||||
resources: new Set<Resource>(), | |||||
languages: new Map<Language['name'], Language>([ | languages: new Map<Language['name'], Language>([ | ||||
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | [FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | ||||
]), | ]), | ||||
@@ -17,7 +17,9 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||||
'resourceNotFound', | 'resourceNotFound', | ||||
'deleteNonExistingResource', | 'deleteNonExistingResource', | ||||
'unableToCreateResource', | 'unableToCreateResource', | ||||
'unableToBindResourceDataSource', | |||||
'unableToGenerateIdFromResourceDataSource', | 'unableToGenerateIdFromResourceDataSource', | ||||
'unableToAssignIdFromResourceDataSource', | |||||
'unableToEmplaceResource', | 'unableToEmplaceResource', | ||||
'unableToSerializeResponse', | 'unableToSerializeResponse', | ||||
'unableToEncodeResponse', | 'unableToEncodeResponse', | ||||
@@ -35,6 +37,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||||
'resourceReplaced', | 'resourceReplaced', | ||||
'notImplemented', | 'notImplemented', | ||||
'provideOptions', | 'provideOptions', | ||||
'internalServerError', | |||||
] as const; | ] as const; | ||||
export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number]; | export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number]; | ||||
@@ -62,6 +65,7 @@ export const FALLBACK_LANGUAGE = { | |||||
statusMessages: { | statusMessages: { | ||||
unableToSerializeResponse: 'Unable To Serialize Response', | unableToSerializeResponse: 'Unable To Serialize Response', | ||||
unableToEncodeResponse: 'Unable To Encode Response', | unableToEncodeResponse: 'Unable To Encode Response', | ||||
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source', | |||||
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | ||||
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | ||||
unableToFetchResource: 'Unable To Fetch $RESOURCE', | unableToFetchResource: 'Unable To Fetch $RESOURCE', | ||||
@@ -90,10 +94,12 @@ export const FALLBACK_LANGUAGE = { | |||||
resourceCreated: '$RESOURCE Created', | resourceCreated: '$RESOURCE Created', | ||||
resourceReplaced: '$RESOURCE Replaced', | resourceReplaced: '$RESOURCE Replaced', | ||||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | ||||
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source', | |||||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | ||||
resourceIdNotGiven: '$RESOURCE ID Not Given', | resourceIdNotGiven: '$RESOURCE ID Not Given', | ||||
unableToCreateResource: 'Unable To Create $RESOURCE', | unableToCreateResource: 'Unable To Create $RESOURCE', | ||||
notImplemented: 'Not Implemented' | |||||
notImplemented: 'Not Implemented', | |||||
internalServerError: 'Internal Server Error', | |||||
}, | }, | ||||
bodies: { | bodies: { | ||||
languageNotAcceptable: [], | languageNotAcceptable: [], | ||||
@@ -1,10 +1,12 @@ | |||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {BaseSchema} from 'valibot'; | |||||
export interface ResourceState< | export interface ResourceState< | ||||
ItemName extends string = string, | ItemName extends string = string, | ||||
RouteName extends string = string | RouteName extends string = string | ||||
> { | > { | ||||
shared: Map<string, unknown>; | shared: Map<string, unknown>; | ||||
relationships: Set<Resource>; | |||||
itemName: ItemName; | itemName: ItemName; | ||||
routeName: RouteName; | routeName: RouteName; | ||||
canCreate: boolean; | canCreate: boolean; | ||||
@@ -30,6 +32,7 @@ export interface Resource< | |||||
canPatch(b?: boolean): this; | canPatch(b?: boolean): 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; | |||||
} | } | ||||
export const resource = < | export const resource = < | ||||
@@ -39,6 +42,7 @@ export const resource = < | |||||
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => { | >(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => { | ||||
const resourceState = { | const resourceState = { | ||||
shared: new Map(), | shared: new Map(), | ||||
relationships: new Set<Resource>(), | |||||
canCreate: false, | canCreate: false, | ||||
canFetchCollection: false, | canFetchCollection: false, | ||||
canFetchItem: false, | canFetchItem: false, | ||||
@@ -105,6 +109,10 @@ export const resource = < | |||||
get schema() { | get schema() { | ||||
return schema; | return schema; | ||||
}, | }, | ||||
relatedTo<RelatedSchema extends BaseSchema>(resource: Resource<RelatedSchema>) { | |||||
resourceState.relationships.add(resource); | |||||
return this; | |||||
}, | |||||
} as Resource<Schema, CurrentName, CurrentRouteName>; | } as Resource<Schema, CurrentName, CurrentRouteName>; | ||||
}; | }; | ||||
@@ -0,0 +1,478 @@ | |||||
import { | |||||
beforeAll, | |||||
afterAll, | |||||
afterEach, | |||||
beforeEach, | |||||
describe, | |||||
expect, | |||||
it, | |||||
} 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 { autoIncrement } from '../../fixtures'; | |||||
import {createTestClient, TestClient} from '../../utils'; | |||||
const PORT = 3000; | |||||
const HOST = '127.0.0.1'; | |||||
const BASE_PATH = '/api'; | |||||
const ACCEPT = 'application/json'; | |||||
const ACCEPT_LANGUAGE = 'en'; | |||||
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 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('serving collections', () => { | |||||
beforeEach(() => { | |||||
Piano.canFetchCollection(); | |||||
return new Promise((resolve) => { | |||||
setTimeout(() => { | |||||
resolve(); | |||||
}); | |||||
}); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canFetchCollection(false); | |||||
}); | |||||
it('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
method: 'GET', | |||||
path: `${BASE_PATH}/pianos`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
// TODO test status messages | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
if (typeof resData === 'undefined') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual([]); | |||||
}); | |||||
it('returns data on HEAD method', async () => { | |||||
const [res] = await client({ | |||||
method: 'HEAD', | |||||
path: `${BASE_PATH}/pianos`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
}); | |||||
it('returns options', async () => { | |||||
const [res] = await client({ | |||||
method: 'OPTIONS', | |||||
path: `${BASE_PATH}/pianos`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||||
expect(allowedMethods).toContain('GET'); | |||||
expect(allowedMethods).toContain('HEAD'); | |||||
}); | |||||
}); | |||||
describe('serving items', () => { | |||||
const existingResource = { | |||||
id: 1, | |||||
brand: 'Yamaha' | |||||
}; | |||||
beforeEach(async () => { | |||||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||||
}); | |||||
beforeEach(() => { | |||||
Piano.canFetchItem(); | |||||
return new Promise((resolve) => { | |||||
setTimeout(() => { | |||||
resolve(); | |||||
}); | |||||
}); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canFetchItem(false); | |||||
}); | |||||
it('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
method: 'GET', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
if (typeof resData === 'undefined') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual(existingResource); | |||||
}); | |||||
it('returns data on HEAD method', async () => { | |||||
const [res] = await client({ | |||||
method: 'HEAD', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
}); | |||||
it('returns options', async () => { | |||||
const [res] = await client({ | |||||
method: 'OPTIONS', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||||
expect(allowedMethods).toContain('GET'); | |||||
expect(allowedMethods).toContain('HEAD'); | |||||
}); | |||||
}); | |||||
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)); | |||||
}); | |||||
beforeEach(() => { | |||||
Piano.canCreate(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canCreate(false); | |||||
}); | |||||
it('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
path: `${BASE_PATH}/pianos`, | |||||
method: 'POST', | |||||
body: newResourceData, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`); | |||||
if (typeof resData === 'undefined') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual({ | |||||
...newResourceData, | |||||
id: 2 | |||||
}); | |||||
}); | |||||
it('returns options', async () => { | |||||
const [res] = await client({ | |||||
method: 'OPTIONS', | |||||
path: `${BASE_PATH}/pianos`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||||
expect(allowedMethods).toContain('POST'); | |||||
}); | |||||
}); | |||||
describe('patching items', () => { | |||||
const existingResource = { | |||||
id: 1, | |||||
brand: 'Yamaha' | |||||
}; | |||||
const patchData = { | |||||
brand: 'K. Kawai' | |||||
}; | |||||
beforeEach(async () => { | |||||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||||
}); | |||||
beforeEach(() => { | |||||
Piano.canPatch(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canPatch(false); | |||||
}); | |||||
it('returns data', async () => { | |||||
const [res, resData] = await client({ | |||||
method: 'PATCH', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
body: patchData, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
if (typeof resData === 'undefined') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual({ | |||||
...existingResource, | |||||
...patchData, | |||||
}); | |||||
}); | |||||
it('returns options', async () => { | |||||
const [res] = await client({ | |||||
method: 'OPTIONS', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||||
expect(allowedMethods).toContain('PATCH'); | |||||
}); | |||||
}); | |||||
describe('emplacing items', () => { | |||||
const existingResource = { | |||||
id: 1, | |||||
brand: 'Yamaha' | |||||
}; | |||||
const emplaceResourceData = { | |||||
id: 1, | |||||
brand: 'K. Kawai' | |||||
}; | |||||
beforeEach(async () => { | |||||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||||
}); | |||||
beforeEach(() => { | |||||
Piano.canEmplace(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canEmplace(false); | |||||
}); | |||||
it('returns data for replacement', async () => { | |||||
const [res, resData] = await client({ | |||||
method: 'PUT', | |||||
path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, | |||||
body: emplaceResourceData, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
if (typeof resData === 'undefined') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual(emplaceResourceData); | |||||
}); | |||||
it('returns data for creation', async () => { | |||||
const newId = 2; | |||||
const [res, resData] = await client({ | |||||
method: 'PUT', | |||||
path: `${BASE_PATH}/pianos/${newId}`, | |||||
body: { | |||||
...emplaceResourceData, | |||||
id: newId, | |||||
}, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`); | |||||
if (typeof resData === 'undefined') { | |||||
expect.fail('Response body must be defined.'); | |||||
return; | |||||
} | |||||
expect(resData).toEqual({ | |||||
...emplaceResourceData, | |||||
id: newId, | |||||
}); | |||||
}); | |||||
it('returns options', async () => { | |||||
const [res] = await client({ | |||||
method: 'OPTIONS', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||||
expect(allowedMethods).toContain('PUT'); | |||||
}); | |||||
}); | |||||
describe('deleting items', () => { | |||||
const existingResource = { | |||||
id: 1, | |||||
brand: 'Yamaha' | |||||
}; | |||||
beforeEach(async () => { | |||||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||||
}); | |||||
beforeEach(() => { | |||||
Piano.canDelete(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canDelete(false); | |||||
}); | |||||
it('responds', async () => { | |||||
const [res, resData] = await client({ | |||||
method: 'DELETE', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
expect(res.headers).not.toHaveProperty('content-type'); | |||||
expect(resData).toBeUndefined(); | |||||
}); | |||||
it('returns options', async () => { | |||||
const [res] = await client({ | |||||
method: 'OPTIONS', | |||||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||||
expect(allowedMethods).toContain('DELETE'); | |||||
}); | |||||
}); | |||||
}); |
@@ -0,0 +1,764 @@ | |||||
import { | |||||
beforeAll, | |||||
afterAll, | |||||
afterEach, | |||||
beforeEach, | |||||
describe, | |||||
expect, | |||||
it, vi, | |||||
} from 'vitest'; | |||||
import { | |||||
tmpdir | |||||
} from 'os'; | |||||
import { | |||||
mkdtemp, | |||||
rm, | |||||
writeFile, | |||||
} from 'fs/promises'; | |||||
import { | |||||
join | |||||
} from 'path'; | |||||
import {request} from 'http'; | |||||
import {constants} from 'http2'; | |||||
import {Backend, dataSources} from '../../../src/backend'; | |||||
import { application, resource, validation as v, Resource } from '../../../src/common'; | |||||
import { autoIncrement } from '../../fixtures'; | |||||
import {createTestClient, TestClient} from '../../utils'; | |||||
import {DataSource} from '../../../src/backend/data-source'; | |||||
const PORT = 3001; | |||||
const HOST = '127.0.0.1'; | |||||
const BASE_PATH = '/api'; | |||||
const ACCEPT = 'application/json'; | |||||
const ACCEPT_LANGUAGE = 'en'; | |||||
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(() => { | |||||
client = createTestClient({ | |||||
host: HOST, | |||||
port: PORT, | |||||
}) | |||||
.acceptMediaType(ACCEPT) | |||||
.acceptLanguage(ACCEPT_LANGUAGE) | |||||
.acceptCharset(ACCEPT_CHARSET) | |||||
.contentType(CONTENT_TYPE) | |||||
.contentCharset(CONTENT_TYPE_CHARSET); | |||||
}); | |||||
describe('on internal 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 dataSource: DataSource; | |||||
let backend: Backend; | |||||
let server: ReturnType<Backend['createHttpServer']>; | |||||
beforeEach(() => { | |||||
const app = application({ | |||||
name: 'piano-service', | |||||
}) | |||||
.resource(Piano); | |||||
dataSource = new DummyDataSource(); | |||||
backend = app.createBackend({ | |||||
dataSource, | |||||
}); | |||||
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); | |||||
}); | |||||
it('throws on query', async () => { | |||||
const [res] = await client({ | |||||
method: 'GET', | |||||
path: `${BASE_PATH}/pianos`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection'); | |||||
}); | |||||
it('throws on HEAD method', async () => { | |||||
const [res] = await client({ | |||||
method: 'HEAD', | |||||
path: `${BASE_PATH}/pianos`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection'); | |||||
}); | |||||
}); | |||||
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 query', async () => { | |||||
const [res] = await client({ | |||||
method: 'GET', | |||||
path: `${BASE_PATH}/pianos/2`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); | |||||
}); | |||||
it('throws on HEAD method', async () => { | |||||
const [res] = await client({ | |||||
method: 'HEAD', | |||||
path: `${BASE_PATH}/pianos/2`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); | |||||
}); | |||||
it('throws on item not found', async () => { | |||||
const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); | |||||
getById.mockResolvedValueOnce(null as never); | |||||
const [res] = await client({ | |||||
method: 'GET', | |||||
path: `${BASE_PATH}/pianos/2`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||||
}); | |||||
it('throws on item not found on HEAD method', async () => { | |||||
const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); | |||||
getById.mockResolvedValueOnce(null as never); | |||||
const [res] = await client({ | |||||
method: 'HEAD', | |||||
path: `${BASE_PATH}/pianos/2`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||||
}); | |||||
}); | |||||
describe('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); | |||||
}); | |||||
it('throws on error assigning ID', async () => { | |||||
const [res] = await client({ | |||||
method: 'POST', | |||||
path: `${BASE_PATH}/pianos`, | |||||
body: newData, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||||
expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source'); | |||||
}); | |||||
it('throws on error creating resource', async () => { | |||||
const getById = vi.spyOn(DummyDataSource.prototype, 'newId'); | |||||
getById.mockResolvedValueOnce(data.id as never); | |||||
const [res] = await client({ | |||||
method: 'POST', | |||||
path: `${BASE_PATH}/pianos`, | |||||
body: newData, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||||
expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano'); | |||||
}); | |||||
}); | |||||
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); | |||||
}); | |||||
req.write(JSON.stringify(newData)); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
}); | |||||
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(); | |||||
}); | |||||
}); | |||||
}); | |||||
}); | |||||
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 () => { | |||||
const [res] = await client({ | |||||
method: 'GET', | |||||
path: `${BASE_PATH}/pianos/2`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||||
}); | |||||
it('throws on item not found on HEAD method', async () => { | |||||
const [res] = await client({ | |||||
method: 'HEAD', | |||||
path: `${BASE_PATH}/pianos/2`, | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||||
}); | |||||
}); | |||||
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); | |||||
}); | |||||
req.write(JSON.stringify(newData)); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
}); | |||||
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(); | |||||
}); | |||||
}); | |||||
}); | |||||
}); | |||||
}); |
@@ -0,0 +1,143 @@ | |||||
import {IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http'; | |||||
import {Method} from '../src/backend/common'; | |||||
interface ClientParams { | |||||
method: Method; | |||||
path: string; | |||||
headers?: OutgoingHttpHeaders; | |||||
body?: unknown; | |||||
} | |||||
type ResponseBody = Buffer | string | object; | |||||
export interface TestClient { | |||||
(params: ClientParams): Promise<[IncomingMessage, ResponseBody?]>; | |||||
acceptMediaType(mediaType: string): this; | |||||
acceptLanguage(language: string): this; | |||||
acceptCharset(charset: string): this; | |||||
contentType(mediaType: string): this; | |||||
contentCharset(charset: string): this; | |||||
} | |||||
export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'>): TestClient => { | |||||
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, | |||||
}; | |||||
if (typeof params.body !== 'undefined') { | |||||
headers['content-type'] = contentTypeHeader; | |||||
} | |||||
const req = request({ | |||||
...options, | |||||
method: params.method, | |||||
path: params.path, | |||||
headers, | |||||
}); | |||||
req.on('response', (res) => { | |||||
res.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
let resBuffer: Buffer | undefined; | |||||
res.on('data', (c) => { | |||||
resBuffer = ( | |||||
typeof resBuffer === 'undefined' | |||||
? Buffer.from(c) | |||||
: Buffer.concat([resBuffer, c]) | |||||
); | |||||
}); | |||||
res.on('close', () => { | |||||
const acceptHeader = Array.isArray(headers['accept']) ? headers['accept'].join('; ') : headers['accept']; | |||||
const contentTypeBase = acceptHeader ?? 'application/octet-stream'; | |||||
const [type, subtype] = contentTypeBase.split('/'); | |||||
const allSubtypes = subtype.split('+'); | |||||
if (typeof resBuffer !== 'undefined') { | |||||
if (allSubtypes.includes('json')) { | |||||
const acceptCharset = ( | |||||
Array.isArray(headers['accept-charset']) | |||||
? headers['accept-charset'].join('; ') | |||||
: headers['accept-charset'] | |||||
) as BufferEncoding | undefined; | |||||
resolve([res, JSON.parse(resBuffer.toString(acceptCharset ?? 'utf-8'))]); | |||||
return; | |||||
} | |||||
if (type === 'text') { | |||||
const acceptCharset = ( | |||||
Array.isArray(headers['accept-charset']) | |||||
? headers['accept-charset'].join('; ') | |||||
: headers['accept-charset'] | |||||
) as BufferEncoding | undefined; | |||||
resolve([res, resBuffer.toString(acceptCharset ?? 'utf-8')]); | |||||
return; | |||||
} | |||||
resolve([res, resBuffer]); | |||||
return; | |||||
} | |||||
resolve([res]); | |||||
}); | |||||
}); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}) | |||||
if (typeof params.body !== 'undefined') { | |||||
const theContentTypeHeader = Array.isArray(contentTypeHeader) ? contentTypeHeader.join('; ') : contentTypeHeader?.toString(); | |||||
const contentTypeAll = theContentTypeHeader ?? 'application/octet-stream'; | |||||
const [contentTypeBase, ...contentTypeParams] = contentTypeAll.split(';').map((s) => s.replace(/\s+/g, '').trim()); | |||||
const charsetParam = contentTypeParams.find((s) => s.startsWith('charset=')); | |||||
const charset = charsetParam?.split('=')?.[1] as BufferEncoding | undefined; | |||||
const [, subtype] = contentTypeBase.split('/'); | |||||
const allSubtypes = subtype.split('+'); | |||||
req.write( | |||||
allSubtypes.includes('json') | |||||
? JSON.stringify(params.body) | |||||
: Buffer.from(params.body?.toString() ?? '', contentTypeBase === 'text' ? charset : undefined) | |||||
); | |||||
} | |||||
req.end(); | |||||
}); | |||||
client.acceptMediaType = function acceptMediaType(mediaType: string) { | |||||
additionalHeaders['accept'] = mediaType; | |||||
return this; | |||||
}; | |||||
client.acceptLanguage = function acceptLanguage(language: string) { | |||||
additionalHeaders['accept-language'] = language; | |||||
return this; | |||||
}; | |||||
client.acceptCharset = function acceptCharset(charset: string) { | |||||
additionalHeaders['accept-charset'] = charset; | |||||
return this; | |||||
}; | |||||
client.contentType = function contentType(mediaType: string) { | |||||
additionalHeaders['content-type'] = mediaType; | |||||
return this; | |||||
}; | |||||
client.contentCharset = function contentCharset(charset: string) { | |||||
additionalHeaders['content-type'] = `${additionalHeaders['content-type']}; charset="${charset}"`; | |||||
return this; | |||||
}; | |||||
return client; | |||||
}; |