Remove duplicate checks and add OPTIONS method handler for allowed methods.master
@@ -1,5 +1,7 @@ | |||||
import {ApplicationState, ContentNegotiation} from '../common'; | |||||
import {ApplicationState, ContentNegotiation, Resource} from '../common'; | |||||
import {DataSource} from './data-source'; | import {DataSource} from './data-source'; | ||||
import {BaseSchema} from 'valibot'; | |||||
import {Middleware} from './http/server'; | |||||
export interface BackendState { | export interface BackendState { | ||||
app: ApplicationState; | app: ApplicationState; | ||||
@@ -16,3 +18,12 @@ export interface RequestContext {} | |||||
export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>; | export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>; | ||||
export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator; | export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator; | ||||
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; | |||||
export interface AllowedMiddlewareSpecification<Schema extends BaseSchema = BaseSchema> { | |||||
method: Method; | |||||
middleware: Middleware; | |||||
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => BaseSchema; | |||||
allowed: (resource: Resource<Schema>) => boolean; | |||||
} |
@@ -0,0 +1,57 @@ | |||||
import {HttpMiddlewareError, Middleware, PlainResponse} from '../server'; | |||||
import {LinkMap} from '../utils'; | |||||
import {constants} from 'http2'; | |||||
import {AllowedMiddlewareSpecification} from '../../common'; | |||||
export const handleGetRoot: Middleware = (req) => { | |||||
const { backend, basePath } = req; | |||||
const data = { | |||||
name: backend.app.name | |||||
}; | |||||
const registeredResources = Array.from(backend.app.resources); | |||||
const availableResources = registeredResources.filter((r) => ( | |||||
r.state.canFetchCollection | |||||
|| r.state.canCreate | |||||
)); | |||||
const headers: Record<string, string> = {}; | |||||
if (availableResources.length > 0) { | |||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link | |||||
headers['Link'] = new LinkMap( | |||||
availableResources.map((r) => ({ | |||||
url: `${basePath}/${r.state.routeName}`, | |||||
params: { | |||||
rel: 'related', | |||||
name: r.state.routeName, | |||||
}, | |||||
})) | |||||
) | |||||
.toString(); | |||||
} | |||||
return new PlainResponse({ | |||||
headers, | |||||
statusMessage: 'ok', | |||||
statusCode: constants.HTTP_STATUS_OK, | |||||
body: data | |||||
}); | |||||
}; | |||||
export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => () => { | |||||
if (middlewares.length > 0) { | |||||
return new PlainResponse({ | |||||
headers: { | |||||
'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '), | |||||
}, | |||||
statusMessage: 'ok', | |||||
statusCode: constants.HTTP_STATUS_NO_CONTENT, | |||||
}); | |||||
} | |||||
// TODO add option for custom error handler | |||||
throw new HttpMiddlewareError('methodNotAllowed', { | |||||
statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED, | |||||
}); | |||||
}; |
@@ -1,59 +1,10 @@ | |||||
import { constants } from 'http2'; | import { constants } from 'http2'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; | |||||
import {LinkMap} from './utils'; | |||||
export const handleGetRoot: Middleware = (req) => { | |||||
const { backend, basePath } = req; | |||||
const data = { | |||||
name: backend.app.name | |||||
}; | |||||
const registeredResources = Array.from(backend.app.resources); | |||||
const availableResources = registeredResources.filter((r) => ( | |||||
r.state.canFetchCollection | |||||
|| r.state.canCreate | |||||
)); | |||||
const headers: Record<string, string> = {}; | |||||
if (availableResources.length > 0) { | |||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link | |||||
headers['Link'] = new LinkMap( | |||||
availableResources.map((r) => ({ | |||||
url: `${basePath}/${r.state.routeName}`, | |||||
params: { | |||||
rel: 'related', | |||||
name: r.state.routeName, | |||||
}, | |||||
})) | |||||
) | |||||
.toString(); | |||||
} | |||||
return new PlainResponse({ | |||||
headers, | |||||
statusMessage: 'ok', | |||||
statusCode: constants.HTTP_STATUS_OK, | |||||
body: data | |||||
}); | |||||
}; | |||||
import {HttpMiddlewareError, PlainResponse, Middleware} from '../server'; | |||||
export const handleGetCollection: Middleware = async (req) => { | export const handleGetCollection: Middleware = async (req) => { | ||||
const { query, resource, backend } = req; | const { query, resource, backend } = req; | ||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
let data: v.Output<typeof resource.schema>[]; | let data: v.Output<typeof resource.schema>[]; | ||||
let totalItemCount: number | undefined; | let totalItemCount: number | undefined; | ||||
try { | try { | ||||
@@ -88,32 +39,11 @@ export const handleGetCollection: Middleware = async (req) => { | |||||
export const handleGetItem: Middleware = async (req) => { | export const handleGetItem: Middleware = async (req) => { | ||||
const { resource, resourceId } = req; | const { resource, resourceId } = req; | ||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
if (typeof resourceId === 'undefined') { | |||||
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { | |||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
'resourceIdNotGiven', | 'resourceIdNotGiven', | ||||
{ | { | ||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
} | |||||
); | |||||
} | |||||
if ((resourceId.trim().length ?? 0) < 1) { | |||||
throw new HttpMiddlewareError( | |||||
'resourceIdNotGiven', | |||||
{ | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||||
} | } | ||||
); | ); | ||||
} | } | ||||
@@ -150,23 +80,11 @@ export const handleGetItem: Middleware = async (req) => { | |||||
export const handleDeleteItem: Middleware = async (req) => { | export const handleDeleteItem: Middleware = async (req) => { | ||||
const { resource, resourceId, backend } = req; | const { resource, resourceId, backend } = req; | ||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
if (typeof resourceId === 'undefined') { | |||||
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { | |||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
'resourceIdNotGiven', | 'resourceIdNotGiven', | ||||
{ | { | ||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||||
} | } | ||||
); | ); | ||||
} | } | ||||
@@ -207,23 +125,11 @@ export const handleDeleteItem: Middleware = async (req) => { | |||||
export const handlePatchItem: Middleware = async (req) => { | export const handlePatchItem: Middleware = async (req) => { | ||||
const { resource, resourceId, body } = req; | const { resource, resourceId, body } = req; | ||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
if (typeof resourceId === 'undefined') { | |||||
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { | |||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
'resourceIdNotGiven', | 'resourceIdNotGiven', | ||||
{ | { | ||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||||
} | } | ||||
); | ); | ||||
} | } | ||||
@@ -264,21 +170,9 @@ export const handlePatchItem: Middleware = async (req) => { | |||||
export const handleCreateItem: Middleware = async (req) => { | export const handleCreateItem: Middleware = async (req) => { | ||||
const { resource, body, backend, basePath } = req; | const { resource, body, backend, basePath } = req; | ||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
const idAttrRaw = resource.state.shared.get('idAttr'); | const idAttrRaw = resource.state.shared.get('idAttr'); | ||||
if (typeof idAttrRaw === 'undefined') { | if (typeof idAttrRaw === 'undefined') { | ||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | ||||
}); | }); | ||||
} | } | ||||
@@ -338,21 +232,9 @@ export const handleCreateItem: Middleware = async (req) => { | |||||
export const handleEmplaceItem: Middleware = async (req) => { | export const handleEmplaceItem: Middleware = async (req) => { | ||||
const { resource, resourceId, basePath, body, backend } = req; | const { resource, resourceId, basePath, body, backend } = req; | ||||
if (typeof resource === 'undefined') { | |||||
throw new HttpMiddlewareError('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
}); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | |||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||||
}); | |||||
} | |||||
const idAttrRaw = resource.state.shared.get('idAttr'); | const idAttrRaw = resource.state.shared.get('idAttr'); | ||||
if (typeof idAttrRaw === 'undefined') { | if (typeof idAttrRaw === 'undefined') { | ||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | |||||
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { | |||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | ||||
}); | }); | ||||
} | } |
@@ -1,18 +1,20 @@ | |||||
import http from 'http'; | import http from 'http'; | ||||
import {BackendState, RequestContext} from '../common'; | |||||
import {AllowedMiddlewareSpecification, BackendState, RequestContext} from '../common'; | |||||
import {Language, Resource, LanguageStatusMessageMap} from '../../common'; | import {Language, Resource, LanguageStatusMessageMap} from '../../common'; | ||||
import https from 'https'; | import https from 'https'; | ||||
import {constants} from 'http2'; | import {constants} from 'http2'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import { | |||||
handleGetRoot, handleOptions, | |||||
} from './handlers/default'; | |||||
import { | import { | ||||
handleCreateItem, | handleCreateItem, | ||||
handleDeleteItem, | handleDeleteItem, | ||||
handleEmplaceItem, | handleEmplaceItem, | ||||
handleGetCollection, | handleGetCollection, | ||||
handleGetItem, | handleGetItem, | ||||
handleGetRoot, | |||||
handlePatchItem, | handlePatchItem, | ||||
} from './handlers'; | |||||
} from './handlers/resource'; | |||||
import {getBody} from './utils'; | import {getBody} from './utils'; | ||||
import {decorateRequestWithBackend} from './decorators/backend'; | import {decorateRequestWithBackend} from './decorators/backend'; | ||||
import {decorateRequestWithMethod} from './decorators/method'; | import {decorateRequestWithMethod} from './decorators/method'; | ||||
@@ -87,19 +89,18 @@ export interface CreateServerParams { | |||||
streamResponses?: boolean; | streamResponses?: boolean; | ||||
} | } | ||||
type ResourceRequestContext = Omit<RequestContext, 'resource'> & Required<Pick<RequestContext, 'resource'>>; | |||||
type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource']; | |||||
export interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> { | |||||
(req: Req): undefined | Response | Promise<undefined | Response>; | |||||
interface ResourceWithDataSource extends Omit<RequiredResource, 'dataSource'> { | |||||
dataSource: Required<Pick<RequiredResource, 'dataSource'>>['dataSource']; | |||||
} | } | ||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; | |||||
interface ResourceRequestContext extends Omit<RequestContext, 'resource'> { | |||||
resource: ResourceWithDataSource; | |||||
} | |||||
interface AllowedMiddlewareSpecification<Schema extends v.BaseSchema = v.BaseSchema> { | |||||
method: Method; | |||||
middleware: Middleware; | |||||
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => v.BaseSchema; | |||||
allowed: (resource: Resource<Schema>) => boolean; | |||||
export interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> { | |||||
(req: Req): undefined | Response | Promise<undefined | Response>; | |||||
} | } | ||||
const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | ||||
@@ -304,6 +305,10 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
}); | }); | ||||
} | } | ||||
if (req.method === 'OPTIONS') { | |||||
return handleOptions(middlewares)(req); | |||||
} | |||||
if (typeof resource.dataSource === 'undefined') { | if (typeof resource.dataSource === 'undefined') { | ||||
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { | ||||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | ||||
@@ -374,6 +379,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
try { | try { | ||||
serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; | serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; | ||||
} catch (cause) { | } catch (cause) { | ||||
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace( | |||||
/\$RESOURCE/g, | |||||
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; | return; | ||||
@@ -382,6 +390,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
try { | try { | ||||
encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined; | encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined; | ||||
} catch (cause) { | } catch (cause) { | ||||
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, | |||||
resourceReq.resource!.state.itemName) ?? ''; | |||||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | ||||
res.end(); | res.end(); | ||||
} | } | ||||
@@ -1,765 +0,0 @@ | |||||
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 {request, Server} from 'http'; | |||||
import {constants} from 'http2'; | |||||
import {DataSource} from '../../src/backend/data-source'; | |||||
import { dataSources } from '../../src/backend'; | |||||
import { application, resource, validation as v, Resource } from '../../src'; | |||||
const PORT = 3000; | |||||
const HOST = '127.0.0.1'; | |||||
const ACCEPT = 'application/json'; | |||||
const ACCEPT_LANGUAGE = 'en'; | |||||
const CONTENT_TYPE_CHARSET = 'utf-8'; | |||||
const CONTENT_TYPE = ACCEPT; | |||||
const autoIncrement = async (dataSource: DataSource) => { | |||||
const data = await dataSource.getMultiple() as Record<string, string>[]; | |||||
const highestId = data.reduce<number>( | |||||
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), | |||||
-Infinity | |||||
); | |||||
if (Number.isFinite(highestId)) { | |||||
return (highestId + 1); | |||||
} | |||||
return 1; | |||||
}; | |||||
describe('yasumi', () => { | |||||
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 server: Server; | |||||
beforeEach(() => { | |||||
const app = application({ | |||||
name: 'piano-service', | |||||
}) | |||||
.resource(Piano); | |||||
const backend = app | |||||
.createBackend({ | |||||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||||
}) | |||||
.throwsErrorOnDeletingNotFound(); | |||||
server = backend.createHttpServer({ | |||||
basePath: '/api' | |||||
}); | |||||
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', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos', | |||||
method: 'GET', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
// TODO test status messsages | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
let resBuffer = Buffer.from(''); | |||||
res.on('data', (c) => { | |||||
resBuffer = Buffer.concat([resBuffer, c]); | |||||
}); | |||||
res.on('close', () => { | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | |||||
expect(resData).toEqual([]); | |||||
resolve(); | |||||
}); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('returns data on HEAD method', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos', | |||||
method: 'HEAD', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
resolve(); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
}); | |||||
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('returns data', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
// TODO all responses should have serialized ids | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos/1', | |||||
method: 'GET', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
let resBuffer = Buffer.from(''); | |||||
res.on('data', (c) => { | |||||
resBuffer = Buffer.concat([resBuffer, c]); | |||||
}); | |||||
res.on('close', () => { | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | |||||
expect(resData).toEqual(data); | |||||
resolve(); | |||||
}); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('returns data on HEAD method', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
// TODO all responses should have serialized ids | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos/1', | |||||
method: 'HEAD', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||||
resolve(); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('throws on item not found', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos/2', | |||||
method: 'GET', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
Piano.canFetchItem(false); | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||||
resolve(); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('throws on item not found on HEAD method', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos/2', | |||||
method: 'HEAD', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
Piano.canFetchItem(false); | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||||
resolve(); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
}); | |||||
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('returns data', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/pianos', | |||||
method: 'POST', | |||||
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_CREATED); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
let resBuffer = Buffer.from(''); | |||||
res.on('data', (c) => { | |||||
resBuffer = Buffer.concat([resBuffer, c]); | |||||
}); | |||||
res.on('close', () => { | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | |||||
expect(resData).toEqual({ | |||||
...newData, | |||||
id: 2 | |||||
}); | |||||
resolve(); | |||||
}); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.write(JSON.stringify(newData)); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
}); | |||||
describe('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('returns data', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: `/api/pianos/${data.id}`, | |||||
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_OK); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
let resBuffer = Buffer.from(''); | |||||
res.on('data', (c) => { | |||||
resBuffer = Buffer.concat([resBuffer, c]); | |||||
}); | |||||
res.on('close', () => { | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | |||||
expect(resData).toEqual({ | |||||
...data, | |||||
...newData, | |||||
}); | |||||
resolve(); | |||||
}); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.write(JSON.stringify(newData)); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('throws on item to patch not found', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/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('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); | |||||
}); | |||||
it('returns data for replacement', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: `/api/pianos/${newData.id}`, | |||||
method: 'PUT', | |||||
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_OK); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
let resBuffer = Buffer.from(''); | |||||
res.on('data', (c) => { | |||||
resBuffer = Buffer.concat([resBuffer, c]); | |||||
}); | |||||
res.on('close', () => { | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | |||||
expect(resData).toEqual(newData); | |||||
resolve(); | |||||
}); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.write(JSON.stringify(newData)); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('returns data for creation', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const id = 2; | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: `/api/pianos/${id}`, | |||||
method: 'PUT', | |||||
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_CREATED); | |||||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||||
let resBuffer = Buffer.from(''); | |||||
res.on('data', (c) => { | |||||
resBuffer = Buffer.concat([resBuffer, c]); | |||||
}); | |||||
res.on('close', () => { | |||||
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | |||||
expect(resData).toEqual({ | |||||
...newData, | |||||
id, | |||||
}); | |||||
resolve(); | |||||
}); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.write(JSON.stringify({ | |||||
...newData, | |||||
id, | |||||
})); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
}); | |||||
describe('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(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canDelete(false); | |||||
}); | |||||
it('returns data', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: `/api/pianos/${data.id}`, | |||||
method: 'DELETE', | |||||
headers: { | |||||
'Accept': ACCEPT, | |||||
'Accept-Language': ACCEPT_LANGUAGE, | |||||
}, | |||||
}, | |||||
(res) => { | |||||
res.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||||
resolve(); | |||||
}, | |||||
); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
}); | |||||
it('throws on item not found', () => { | |||||
return new Promise<void>((resolve, reject) => { | |||||
const req = request( | |||||
{ | |||||
host: HOST, | |||||
port: PORT, | |||||
path: '/api/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(); | |||||
}); | |||||
}); | |||||
}); | |||||
}); |