Organize code even further to improve extensibility.master
@@ -36,6 +36,10 @@ See [docs folder](./docs) for more details. | |||
https://www.rfc-editor.org/rfc/rfc9457.html | |||
- RFC 5988 - Web Linking | |||
- ~~RFC 5988 - Web Linking~~ | |||
https://datatracker.ietf.org/doc/html/rfc5988 | |||
~~https://datatracker.ietf.org/doc/html/rfc5988~~ | |||
- RFC 9288 - Web Linking | |||
https://httpwg.org/specs/rfc8288.html |
@@ -2,8 +2,8 @@ import { | |||
application, | |||
resource, | |||
validation as v, | |||
serializers, | |||
encodings, | |||
mediaTypes, | |||
charsets, | |||
} from '../../src'; | |||
import {TEXT_SERIALIZER_PAIR} from './serializers'; | |||
import {autoIncrement, dataSource} from './data-source'; | |||
@@ -49,28 +49,30 @@ const User = resource(v.object( | |||
const app = application({ | |||
name: 'piano-service', | |||
dataSource, | |||
}) | |||
.contentType(serializers.applicationJson) | |||
.contentType(serializers.textJson) | |||
.contentType(TEXT_SERIALIZER_PAIR) | |||
.encoding(encodings.utf8) | |||
.mediaType(mediaTypes.applicationJson) | |||
.mediaType(mediaTypes.textJson) | |||
.mediaType(TEXT_SERIALIZER_PAIR) | |||
.charset(charsets.utf8) | |||
.resource(Piano) | |||
.resource(User); | |||
const backend = app.createBackend(); | |||
app.create({ | |||
dataSource, | |||
}).then((backend) => { | |||
const server = backend.createServer({ | |||
basePath: '/api' | |||
}); | |||
server.listen(3000); | |||
const server = backend.createServer({ | |||
baseUrl: '/api' | |||
setTimeout(() => { | |||
// Allow user operations after 5 seconds from startup | |||
User | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canPatch(); | |||
}, 5000); | |||
}); | |||
server.listen(3000); | |||
setTimeout(() => { | |||
// Allow user operations after 5 seconds from startup | |||
User | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canPatch(); | |||
}, 5000); |
@@ -0,0 +1,61 @@ | |||
import * as v from 'valibot'; | |||
import * as en from './common/languages/en'; | |||
import * as utf8 from './common/charsets/utf-8'; | |||
import * as applicationJson from './common/media-types/application/json'; | |||
import {Resource, Language, MediaType, Charset, ApplicationParams, ApplicationState} from './common'; | |||
import {BackendBuilder, createBackend, CreateBackendParams} from './backend'; | |||
import {ClientBuilder, createClient, CreateClientParams} from './client'; | |||
export interface ApplicationBuilder { | |||
mediaType(mediaType: MediaType): this; | |||
language(language: Language): this; | |||
charset(charset: Charset): this; | |||
resource<T extends v.BaseSchema>(resRaw: Resource<T>): this; | |||
createBackend(params: Omit<CreateBackendParams, 'app'>): BackendBuilder; | |||
createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder; | |||
} | |||
export const application = (appParams: ApplicationParams): ApplicationBuilder => { | |||
const appState: ApplicationState = { | |||
name: appParams.name, | |||
resources: new Set<Resource<any>>(), | |||
languages: new Set<Language>(), | |||
mediaTypes: new Set<MediaType>(), | |||
charsets: new Set<Charset>(), | |||
}; | |||
appState.languages.add(en); | |||
appState.charsets.add(utf8); | |||
appState.mediaTypes.add(applicationJson); | |||
return { | |||
mediaType(serializerPair: MediaType) { | |||
appState.mediaTypes.add(serializerPair); | |||
return this; | |||
}, | |||
charset(encodingPair: Charset) { | |||
appState.charsets.add(encodingPair); | |||
return this; | |||
}, | |||
language(language: Language) { | |||
appState.languages.add(language); | |||
return this; | |||
}, | |||
resource<T extends v.BaseSchema>(resRaw: Resource<T>) { | |||
appState.resources.add(resRaw); | |||
return this; | |||
}, | |||
createBackend(params: Omit<CreateBackendParams, 'app'>) { | |||
return createBackend({ | |||
...params, | |||
app: appState | |||
}); | |||
}, | |||
createClient(params: Omit<CreateClientParams, 'app'>) { | |||
return createClient({ | |||
...params, | |||
app: appState | |||
}); | |||
}, | |||
}; | |||
}; |
@@ -0,0 +1,21 @@ | |||
import {ApplicationState, Charset, Language, MediaType, Resource} from '../common'; | |||
import {BaseDataSource} from '../common/data-source'; | |||
export interface BackendState { | |||
app: ApplicationState; | |||
dataSource: (resource: Resource) => BaseDataSource; | |||
cn: { | |||
language: Language; | |||
charset: Charset; | |||
mediaType: MediaType; | |||
} | |||
errorHeaders: { | |||
language?: string; | |||
charset?: string; | |||
serializer?: string; | |||
} | |||
showTotalItemCountOnGetCollection: boolean; | |||
throws404OnDeletingNotFound: boolean; | |||
checksSerializersOnDelete: boolean; | |||
showTotalItemCountOnCreateItem: boolean; | |||
} |
@@ -0,0 +1,162 @@ | |||
import * as v from 'valibot'; | |||
import {ApplicationState, Language, LanguageStatusMessageMap, Resource} from '../common'; | |||
import http from 'http'; | |||
import {createServer, CreateServerParams} from './server'; | |||
import https from 'https'; | |||
import {BackendState} from './common'; | |||
import {BaseDataSource} from '../common/data-source'; | |||
import * as en from '../common/languages/en'; | |||
import * as utf8 from '../common/charsets/utf-8'; | |||
import * as applicationJson from '../common/media-types/application/json'; | |||
import {DataSource} from './data-source'; | |||
export interface BackendResource< | |||
DataSourceType extends BaseDataSource = DataSource, | |||
ResourceSchema extends v.BaseSchema = v.BaseSchema, | |||
ResourceName extends string = string, | |||
ResourceRouteName extends string = string, | |||
IdAttr extends string = string, | |||
IdSchema extends v.BaseSchema = v.BaseSchema | |||
> extends Resource<ResourceSchema, ResourceName, ResourceRouteName, IdAttr, IdSchema> { | |||
newId(dataSource: DataSourceType): string | number | unknown; | |||
dataSource: DataSourceType; | |||
} | |||
export interface RequestContext extends http.IncomingMessage {} | |||
export interface BackendBuilder<T extends BaseDataSource = BaseDataSource> { | |||
showTotalItemCountOnGetCollection(b?: boolean): this; | |||
showTotalItemCountOnCreateItem(b?: boolean): this; | |||
checksSerializersOnDelete(b?: boolean): this; | |||
throws404OnDeletingNotFound(b?: boolean): this; | |||
createServer(serverParams?: CreateServerParams): http.Server | https.Server; | |||
dataSource?: (resource: Resource) => T; | |||
} | |||
export class MiddlewareError extends Error {} | |||
interface ResponseParams { | |||
statusCode: Response['statusCode']; | |||
statusMessage?: Response['statusMessage']; | |||
headers?: Response['headers']; | |||
} | |||
interface PlainResponseParams<T = unknown> extends ResponseParams { | |||
body?: T; | |||
} | |||
interface StreamResponseParams extends ResponseParams { | |||
stream: NodeJS.ReadableStream; | |||
} | |||
interface HttpMiddlewareErrorParams<T = unknown> extends Omit<PlainResponseParams<T>, 'statusMessage'> { | |||
cause?: unknown | |||
} | |||
export interface Response { | |||
statusCode: number; | |||
statusMessage?: keyof LanguageStatusMessageMap; | |||
headers?: Record<string, string>; | |||
} | |||
export class PlainResponse<T = unknown> implements Response { | |||
readonly statusCode: Response['statusCode']; | |||
readonly statusMessage?: keyof LanguageStatusMessageMap; | |||
readonly headers: Response['headers']; | |||
readonly body?: T; | |||
constructor(args: PlainResponseParams<T>) { | |||
this.statusCode = args.statusCode; | |||
this.statusMessage = args.statusMessage; | |||
this.headers = args.headers; | |||
this.body = args.body; | |||
} | |||
} | |||
export class StreamResponse implements Response { | |||
readonly statusCode: Response['statusCode']; | |||
readonly statusMessage?: keyof LanguageStatusMessageMap; | |||
readonly headers: Response['headers']; | |||
readonly stream: NodeJS.ReadableStream; | |||
constructor(args: StreamResponseParams) { | |||
this.statusCode = args.statusCode; | |||
this.statusMessage = args.statusMessage; | |||
this.headers = args.headers; | |||
this.stream = args.stream; | |||
} | |||
} | |||
export class HttpMiddlewareError extends MiddlewareError { | |||
readonly response: PlainResponse; | |||
constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams) { | |||
super(statusMessage, { cause: params.cause }); | |||
this.response = new PlainResponse({ | |||
...params, | |||
statusMessage, | |||
}); | |||
} | |||
} | |||
export interface ResponseContext<T extends http.IncomingMessage> extends http.ServerResponse<T> {} | |||
export interface CreateBackendParams { | |||
app: ApplicationState; | |||
dataSource: (resource: Resource) => BaseDataSource; | |||
} | |||
export const createBackend = (params: CreateBackendParams) => { | |||
const backendState: BackendState = { | |||
app: params.app, | |||
dataSource: params.dataSource, | |||
cn: { | |||
language: en, | |||
charset: utf8, | |||
mediaType: applicationJson | |||
}, | |||
errorHeaders: { | |||
// undefined follows user accept headers strictly | |||
// | |||
language: undefined, | |||
charset: undefined, | |||
serializer: undefined, | |||
}, | |||
showTotalItemCountOnGetCollection: false, | |||
showTotalItemCountOnCreateItem: false, | |||
throws404OnDeletingNotFound: false, | |||
checksSerializersOnDelete: false, | |||
}; | |||
return { | |||
showTotalItemCountOnGetCollection(b = true) { | |||
backendState.showTotalItemCountOnGetCollection = b; | |||
return this; | |||
}, | |||
showTotalItemCountOnCreateItem(b = true) { | |||
backendState.showTotalItemCountOnCreateItem = b; | |||
return this; | |||
}, | |||
throws404OnDeletingNotFound(b = true) { | |||
backendState.throws404OnDeletingNotFound = b; | |||
return this; | |||
}, | |||
checksSerializersOnDelete(b = true) { | |||
backendState.checksSerializersOnDelete = b; | |||
return this; | |||
}, | |||
createServer(serverParams = {} as CreateServerParams) { | |||
return createServer(backendState, serverParams); | |||
} | |||
} satisfies BackendBuilder; | |||
}; |
@@ -0,0 +1,13 @@ | |||
import {BaseDataSource} from '../common/data-source'; | |||
export interface DataSource<T = object, Q = object> extends BaseDataSource { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Q): Promise<number>; | |||
getMultiple(query?: Q): Promise<T[]>; | |||
getById(id: string): Promise<T | null>; | |||
getSingle?(query?: Q): Promise<T | null>; | |||
create(data: T): Promise<T>; | |||
delete(id: string): Promise<unknown>; | |||
emplace(id: string, data: T): Promise<[T, boolean]>; | |||
patch(id: string, data: Partial<T>): Promise<T | null>; | |||
} |
@@ -1,6 +1,7 @@ | |||
import {readFile, writeFile} from 'fs/promises'; | |||
import {join} from 'path'; | |||
import {DataSource as DataSourceInterface, Resource} from '../core'; | |||
import {DataSource as DataSourceInterface} from '../data-source'; | |||
import {Resource} from '../..'; | |||
export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> { | |||
private readonly path: string; | |||
@@ -8,7 +9,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
data: T[] = []; | |||
constructor(private readonly resource: Resource, baseDir = '') { | |||
this.path = join(baseDir, `${this.resource.state.collectionName}.jsonl`); | |||
this.path = join(baseDir, `${this.resource.state.routeName}.jsonl`); | |||
} | |||
async initialize() { |
@@ -0,0 +1,20 @@ | |||
import {constants} from 'http2'; | |||
import http from 'http'; | |||
import {HttpMiddlewareError} from '../index'; | |||
interface RequestContext extends http.IncomingMessage { | |||
method: string; | |||
} | |||
export const adjustMethod = (req: RequestContext) => { | |||
if (!req.method) { | |||
throw new HttpMiddlewareError('methodNotAllowed', { | |||
statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED, | |||
headers: { | |||
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', | |||
}, | |||
}); | |||
} | |||
req.method = req.method.trim().toUpperCase(); | |||
}; |
@@ -0,0 +1,27 @@ | |||
import {constants} from 'http2'; | |||
import http from 'http'; | |||
import {HttpMiddlewareError} from '..'; | |||
interface RequestContext extends http.IncomingMessage { | |||
basePath?: string; | |||
query?: URLSearchParams; | |||
rawUrl: string; | |||
} | |||
export const adjustUrl = (req: RequestContext) => { | |||
if (!req.url) { | |||
throw new HttpMiddlewareError('badRequest', { | |||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||
}); | |||
} | |||
const theBasePathUrl = req.basePath ?? ''; | |||
const basePath = new URL(theBasePathUrl, 'http://localhost'); | |||
const parsedUrl = new URL(`${theBasePathUrl}/${req.url}`, 'http://localhost'); | |||
req.rawUrl = req.url; | |||
req.url = req.url.slice(basePath.pathname.length); | |||
req.query = parsedUrl.searchParams; | |||
return; | |||
}; |
@@ -0,0 +1,290 @@ | |||
import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {HttpMiddlewareError, PlainResponse} from './core'; | |||
import {Middleware} from './server'; | |||
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'] = availableResources | |||
.map((r) => [ | |||
`<${basePath}/${r.state.routeName}>`, | |||
'rel="related"', | |||
`name="${encodeURIComponent(r.state.routeName)}"` | |||
].join('; ')) | |||
.join(', '); | |||
} | |||
return new PlainResponse({ | |||
headers, | |||
statusMessage: 'ok', | |||
statusCode: constants.HTTP_STATUS_OK, | |||
body: data | |||
}); | |||
}; | |||
export const handleGetCollection: Middleware = async (req) => { | |||
const { query, resource, backend } = req; | |||
let data: v.Output<typeof resource.schema>[]; | |||
let totalItemCount: number | undefined; | |||
try { | |||
// TODO querying mechanism | |||
data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource | |||
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(query); | |||
} | |||
} catch (cause) { | |||
throw new HttpMiddlewareError( | |||
'unableToFetchResourceCollection', | |||
{ | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
} | |||
); | |||
} | |||
const headers: Record<string, string> = {}; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
return new PlainResponse({ | |||
headers, | |||
statusCode: constants.HTTP_STATUS_OK, | |||
statusMessage: 'resourceCollectionFetched', | |||
body: data, | |||
}); | |||
}; | |||
export const handleGetItem: Middleware = async (req) => { | |||
const { resource, resourceId } = req; | |||
if (typeof resourceId === 'undefined') { | |||
throw new HttpMiddlewareError( | |||
'resourceIdNotGiven', | |||
{ | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
} | |||
); | |||
} | |||
let data: v.Output<typeof resource.schema> | null = null; | |||
try { | |||
data = await resource.dataSource.getById(resourceId); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError( | |||
'unableToFetchResource', | |||
{ | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
} | |||
); | |||
} | |||
if (typeof data !== 'undefined' && data !== null) { | |||
return new PlainResponse({ | |||
statusCode: constants.HTTP_STATUS_OK, | |||
statusMessage: 'resourceFetched', | |||
body: data | |||
}); | |||
} | |||
throw new HttpMiddlewareError( | |||
'resourceNotFound', | |||
{ | |||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||
} | |||
); | |||
}; | |||
export const handleDeleteItem: Middleware = async (req) => { | |||
const { resource, resourceId, backend } = req; | |||
if (typeof resourceId === 'undefined') { | |||
throw new HttpMiddlewareError( | |||
'resourceIdNotGiven', | |||
{ | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
} | |||
); | |||
} | |||
let existing: unknown | null; | |||
try { | |||
existing = await resource.dataSource.getById(resourceId); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToFetchResource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR | |||
}); | |||
} | |||
if (!existing && backend.throws404OnDeletingNotFound) { | |||
throw new HttpMiddlewareError('deleteNonExistingResource', { | |||
statusCode: constants.HTTP_STATUS_NOT_FOUND | |||
}); | |||
} | |||
try { | |||
if (existing) { | |||
await resource.dataSource.delete(resourceId); | |||
} | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToDeleteResource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR | |||
}) | |||
} | |||
return new PlainResponse({ | |||
statusCode: constants.HTTP_STATUS_NO_CONTENT, | |||
statusMessage: 'resourceDeleted', | |||
}); | |||
}; | |||
export const handlePatchItem: Middleware = async (req) => { | |||
const { resource, resourceId, body } = req; | |||
let existing: unknown | null; | |||
try { | |||
existing = await resource.dataSource.getById(resourceId!); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToFetchResource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
}); | |||
} | |||
if (!existing) { | |||
throw new HttpMiddlewareError('patchNonExistingResource', { | |||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||
}); | |||
} | |||
let newObject: v.Output<typeof resource.schema> | null; | |||
try { | |||
newObject = await resource.dataSource.patch(resourceId!, body as object); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToPatchResource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR | |||
}); | |||
} | |||
return new PlainResponse({ | |||
statusCode: constants.HTTP_STATUS_OK, | |||
statusMessage: 'resourcePatched', | |||
body: newObject, | |||
}); | |||
// TODO finish the rest of the handlers!!! | |||
}; | |||
export const handleCreateItem: Middleware = async (req) => { | |||
const { resource, body, backend, basePath } = req; | |||
let newId; | |||
let params: v.Output<typeof resource.schema>; | |||
try { | |||
newId = await resource.newId(resource.dataSource); | |||
params = { ...body as Record<string, unknown> }; | |||
params[resource.state.idAttr] = newId; | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
}); | |||
} | |||
let newObject; | |||
let totalItemCount: number | undefined; | |||
try { | |||
newObject = await resource.dataSource.create(params); | |||
if (backend.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToCreateResource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
}); | |||
} | |||
const location = `${basePath}/${resource.state.routeName}/${newId}`; | |||
if (typeof totalItemCount !== 'undefined') { | |||
return new PlainResponse({ | |||
statusCode: constants.HTTP_STATUS_CREATED, | |||
headers: { | |||
'Location': location, | |||
'X-Resource-Total-Item-Count': totalItemCount.toString() | |||
}, | |||
body: newObject, | |||
statusMessage: 'resourceCreated' | |||
}); | |||
} | |||
return new PlainResponse({ | |||
statusCode: constants.HTTP_STATUS_CREATED, | |||
body: newObject, | |||
headers: { | |||
'Location': location, | |||
}, | |||
statusMessage: 'resourceCreated' | |||
}); | |||
} | |||
export const handleEmplaceItem: Middleware = async (req) => { | |||
const { resource, resourceId, basePath, body, backend } = req; | |||
let newObject: v.Output<typeof resource.schema>; | |||
let isCreated: boolean; | |||
try { | |||
const params = { ...body as Record<string, unknown> }; | |||
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); | |||
[newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToEmplaceResource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
}); | |||
} | |||
const headers: Record<string, string> = {}; | |||
let totalItemCount: number | undefined; | |||
if (backend.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
if (isCreated) { | |||
headers['Location'] = `${basePath}/${resource.state.routeName}/${resourceId}`; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
} | |||
return new PlainResponse({ | |||
statusCode: isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, | |||
headers, | |||
statusMessage: ( | |||
isCreated | |||
? 'resourceCreated' | |||
: 'resourceReplaced' | |||
), | |||
body: newObject | |||
}); | |||
} |
@@ -0,0 +1,2 @@ | |||
export * from './core'; | |||
export * as dataSources from './data-sources'; |
@@ -0,0 +1,533 @@ | |||
import http from 'http'; | |||
import {BackendState} from './common'; | |||
import {Language, Resource, Charset, MediaType} from '../common'; | |||
import * as applicationJson from '../common/media-types/application/json'; | |||
import * as utf8 from '../common/charsets/utf-8'; | |||
import * as en from '../common/languages/en'; | |||
import https from 'https'; | |||
import Negotiator from 'negotiator'; | |||
import {constants} from 'http2'; | |||
import {adjustMethod} from './extenders/method'; | |||
import {adjustUrl} from './extenders/url'; | |||
import { | |||
handleCreateItem, | |||
handleDeleteItem, | |||
handleEmplaceItem, | |||
handleGetCollection, | |||
handleGetItem, | |||
handleGetRoot, | |||
handlePatchItem, | |||
} from './handlers'; | |||
import { | |||
HttpMiddlewareError, | |||
PlainResponse, | |||
ResponseContext, | |||
StreamResponse, | |||
Response, | |||
BackendResource, | |||
} from './core'; | |||
import * as v from 'valibot'; | |||
import {getBody} from './utils'; | |||
import {DataSource} from './data-source'; | |||
export interface CreateServerParams { | |||
basePath?: string; | |||
host?: string; | |||
cert?: string; | |||
key?: string; | |||
requestTimeout?: number; | |||
// CQRS | |||
streamResponses?: boolean; | |||
} | |||
export interface RequestContext extends http.IncomingMessage { | |||
backend: BackendState; | |||
host: string; | |||
scheme: string; | |||
basePath: string; | |||
method: string; | |||
url: string; | |||
rawUrl: string; | |||
cn: { | |||
language: Language; | |||
mediaType: MediaType; | |||
charset: Charset; | |||
}; | |||
query: URLSearchParams; | |||
resource: BackendResource; | |||
resourceId?: string; | |||
body?: unknown; | |||
} | |||
export interface Middleware<Req extends RequestContext = RequestContext> { | |||
(req: Req): undefined | Response | Promise<undefined | Response>; | |||
} | |||
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId: string) => { | |||
const middlewares = [] as [string, Middleware, v.BaseSchema?][]; | |||
if (mainResourceId === '') { | |||
if (resource.state.canFetchCollection) { | |||
middlewares.push(['GET', handleGetCollection]); | |||
} | |||
if (resource.state.canCreate) { | |||
middlewares.push(['POST', handleCreateItem, resource.schema]); | |||
} | |||
return middlewares; | |||
} | |||
if (resource.state.canFetchItem) { | |||
middlewares.push(['GET', handleGetItem]); | |||
} | |||
if (resource.state.canEmplace) { | |||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | |||
const putSchema = ( | |||
schema.type === 'object' | |||
? v.merge([ | |||
schema as v.ObjectSchema<any>, | |||
v.object({ | |||
[resource.state.idAttr]: v.transform( | |||
v.any(), | |||
input => resource.state.idConfig.serialize(input), | |||
v.literal(mainResourceId) | |||
) | |||
}) | |||
]) | |||
: schema | |||
); | |||
middlewares.push(['PUT', handleEmplaceItem, putSchema]); | |||
} | |||
if (resource.state.canPatch) { | |||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | |||
const patchSchema = ( | |||
schema.type === 'object' | |||
? v.partial( | |||
schema as v.ObjectSchema<any>, | |||
(schema as v.ObjectSchema<any>).rest, | |||
(schema as v.ObjectSchema<any>).pipe | |||
) | |||
: schema | |||
); | |||
middlewares.push(['PATCH', handlePatchItem, patchSchema]); | |||
} | |||
if (resource.state.canDelete) { | |||
middlewares.push(['DELETE', handleDeleteItem]); | |||
} | |||
return middlewares; | |||
}; | |||
export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { | |||
const isHttps = 'key' in serverParams && 'cert' in serverParams; | |||
class ServerYasumiRequest extends http.IncomingMessage implements RequestContext { | |||
readonly host = serverParams.host ?? 'localhost'; | |||
readonly scheme = isHttps ? 'https' : 'http'; | |||
readonly basePath = serverParams.basePath ?? ''; | |||
readonly backend = backendState; | |||
resource = undefined as unknown as BackendResource; | |||
resourceId?: string; | |||
query = new URLSearchParams(); | |||
body?: unknown; | |||
method = ''; | |||
url = ''; | |||
rawUrl = ''; | |||
readonly cn: { | |||
language: Language; | |||
mediaType: MediaType; | |||
charset: Charset; | |||
} = { | |||
language: en, | |||
mediaType: applicationJson, | |||
charset: utf8, | |||
}; | |||
} | |||
class ServerYasumiResponse<T extends http.IncomingMessage> extends http.ServerResponse<T> { | |||
} | |||
const server = isHttps | |||
? https.createServer({ | |||
key: serverParams.key, | |||
cert: serverParams.cert, | |||
requestTimeout: serverParams.requestTimeout, | |||
IncomingMessage: ServerYasumiRequest, | |||
ServerResponse: ServerYasumiResponse, | |||
}) | |||
: http.createServer({ | |||
requestTimeout: serverParams.requestTimeout, | |||
IncomingMessage: ServerYasumiRequest, | |||
ServerResponse: ServerYasumiResponse, | |||
}); | |||
const adjustRequestForContentNegotiation = (req: RequestContext, res: ResponseContext<RequestContext>) => { | |||
const negotiator = new Negotiator(req); | |||
const availableLanguages = Array.from(req.backend.app.languages); | |||
const availableCharsets = Array.from(req.backend.app.charsets); | |||
const availableMediaTypes = Array.from(req.backend.app.mediaTypes); | |||
const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? backendState.cn.language.name; | |||
const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? backendState.cn.charset.name; | |||
const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? backendState.cn.mediaType.name; | |||
// TODO refactor | |||
const currentLanguage = availableLanguages.find((l) => l.name === languageCandidate); | |||
if (typeof currentLanguage === 'undefined') { | |||
const data = req.backend?.cn.language.bodies.languageNotAcceptable(); | |||
const responseRaw = req.backend?.cn.mediaType.serialize(data); | |||
const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; | |||
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { | |||
'Content-Language': req.backend?.cn.language.name, | |||
'Content-Type': [ | |||
req.backend?.cn.mediaType.name, | |||
`charset="${req.backend?.cn.charset.name}"` | |||
].join('; '), | |||
}); | |||
res.statusMessage = req.backend?.cn.language.statusMessages.languageNotAcceptable() ?? ''; | |||
res.end(response); | |||
return; | |||
} | |||
const currentMediaType = availableMediaTypes.find((l) => l.name === mediaTypeCandidate); | |||
if (typeof currentMediaType === 'undefined') { | |||
const data = req.backend?.cn.language.bodies.mediaTypeNotAcceptable(); | |||
const responseRaw = req.backend?.cn.mediaType.serialize(data); | |||
const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; | |||
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { | |||
'Content-Language': req.backend?.cn.language.name, | |||
'Content-Type': [ | |||
req.backend?.cn.mediaType.name, | |||
`charset="${req.backend?.cn.charset.name}"` | |||
].join('; '), | |||
}); | |||
res.statusMessage = req.backend?.cn.language.statusMessages.mediaTypeNotAcceptable() ?? ''; | |||
res.end(response); | |||
return; | |||
} | |||
const responseBodyCharset = availableCharsets.find((l) => l.name === charsetCandidate); | |||
if (typeof responseBodyCharset === 'undefined') { | |||
const data = req.backend?.cn.language.bodies.encodingNotAcceptable(); | |||
const responseRaw = req.backend?.cn.mediaType.serialize(data); | |||
const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; | |||
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { | |||
'Content-Language': req.backend?.cn.language.name, | |||
'Content-Type': [ | |||
req.backend?.cn.mediaType.name, | |||
`charset="${req.backend?.cn.charset.name}"` | |||
].join('; '), | |||
}); | |||
res.statusMessage = req.backend?.cn.language.statusMessages.encodingNotAcceptable() ?? ''; | |||
res.end(response); | |||
return; | |||
} | |||
req.cn.language = currentLanguage; | |||
req.cn.mediaType = currentMediaType; | |||
req.cn.charset = responseBodyCharset; | |||
}; | |||
server.on('request', async (req: RequestContext, res) => { | |||
adjustRequestForContentNegotiation(req, res); | |||
try { | |||
adjustMethod(req); | |||
} catch (errRaw) { | |||
if (typeof errRaw !== 'undefined') { | |||
const err= errRaw as HttpMiddlewareError; | |||
const errBody = err.response.body; | |||
if (typeof errBody !== 'undefined') { | |||
res.writeHead(err.response.statusCode, { | |||
...(err.response.headers ?? {}), | |||
'Content-Language': req.backend.cn.language.name, | |||
'Content-Type': [ | |||
req.backend.cn.mediaType.name, | |||
`charset="${req.backend.cn.charset.name}"` | |||
].join('; '), | |||
}); | |||
res.statusMessage = err.response.statusMessage ?? ''; | |||
const errBodySerialized = req.backend.cn.mediaType.serialize(errBody); | |||
const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined; | |||
res.end(errBodyEncoded); | |||
return; | |||
} | |||
res.writeHead(err.response.statusCode, { | |||
...(err.response.headers ?? {}), | |||
'Content-Language': req.backend.cn.language.name, | |||
}); | |||
res.statusMessage = err.response.statusMessage ?? ''; | |||
res.end(); | |||
return; | |||
} | |||
} | |||
try { | |||
adjustUrl(req); | |||
} catch (errRaw) { | |||
if (typeof errRaw !== 'undefined') { | |||
const err= errRaw as HttpMiddlewareError; | |||
const errBody = err.response.body; | |||
if (typeof errBody !== 'undefined') { | |||
res.writeHead(err.response.statusCode, { | |||
...(err.response.headers ?? {}), | |||
'Content-Language': req.backend.cn.language.name, | |||
'Content-Type': [ | |||
req.backend.cn.mediaType.name, | |||
`charset="${req.backend.cn.charset.name}"` | |||
].join('; '), | |||
}); | |||
res.statusMessage = err.response.statusMessage ?? ''; | |||
const errBodySerialized = req.backend.cn.mediaType.serialize(errBody); | |||
const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined; | |||
res.end(errBodyEncoded); | |||
return; | |||
} | |||
res.writeHead(err.response.statusCode, { | |||
...(err.response.headers ?? {}), | |||
'Content-Language': req.backend.cn.language.name, | |||
}); | |||
res.statusMessage = err.response.statusMessage ?? ''; | |||
res.end(); | |||
return; | |||
} | |||
} | |||
if (req.url === '/') { | |||
const middlewareState = await handleGetRoot(req); | |||
if (typeof middlewareState !== 'undefined') { | |||
return; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: 'HEAD, GET' | |||
}); | |||
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); | |||
res.end(); | |||
return; | |||
} | |||
const [, resourceRouteName, resourceId = ''] = req.url.split('/') ?? []; | |||
const resource = Array.from(req.backend.app.resources).find((r) => r.state!.routeName === resourceRouteName); | |||
if (typeof resource === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); | |||
res.end(); | |||
return; | |||
} | |||
req.resource = resource as BackendResource; | |||
req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; | |||
req.resourceId = resourceId; | |||
try { | |||
await req.resource.dataSource.initialize(); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError( | |||
'unableToInitializeResourceDataSource', | |||
{ | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
} | |||
); | |||
} | |||
const middlewares = getAllowedMiddlewares(resource, resourceId); | |||
const middlewareState = await middlewares.reduce<unknown>( | |||
async (currentHandlerStatePromise, currentValue) => { | |||
const [middlewareMethod, middleware, schema] = currentValue; | |||
try { | |||
const currentHandlerState = await currentHandlerStatePromise; | |||
if (req.method !== middlewareMethod) { | |||
return currentHandlerState; | |||
} | |||
if (typeof currentHandlerState !== 'undefined') { | |||
return currentHandlerState; | |||
} | |||
if (schema) { | |||
const availableSerializers = Array.from(req.backend.app.mediaTypes); | |||
const availableCharsets = Array.from(req.backend.app.charsets); | |||
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | |||
const fragments = contentTypeHeader.split(';'); | |||
const mediaType = fragments[0]; | |||
const charsetParam = fragments.map((s) => s.trim()) | |||
.find((f) => f.startsWith('charset=')) ?? (mediaType.startsWith('text/') ? 'charset=utf-8' : 'charset=binary'); | |||
const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim()); | |||
const charset = ( | |||
( | |||
(charsetRaw.startsWith('"') && charsetRaw.endsWith('"')) | |||
|| (charsetRaw.startsWith("'") && charsetRaw.endsWith("'")) | |||
) | |||
? charsetRaw.slice(1, -1).trim() | |||
: charsetRaw.trim() | |||
) | |||
const deserializerPair = availableSerializers.find((l) => l.name === mediaType); | |||
const encodingPair = availableCharsets.find((l) => l.name === charset); | |||
(req as unknown as Record<string, unknown>).body = await getBody(req, schema, encodingPair, deserializerPair); | |||
} | |||
const result = await middleware(req); | |||
return Promise.resolve(result); | |||
} catch (errRaw) { | |||
// todo use error message key for each method | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
if (errRaw instanceof v.ValiError && Array.isArray(errRaw.issues)) { | |||
return new HttpMiddlewareError('invalidResource', { | |||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||
body: errRaw.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
}); | |||
} | |||
return errRaw; | |||
} | |||
}, | |||
Promise.resolve<ReturnType<Middleware>>(undefined) | |||
) as Awaited<ReturnType<Middleware>>; | |||
if (typeof middlewareState !== 'undefined') { | |||
try { | |||
if (middlewareState instanceof Error) { | |||
throw middlewareState; | |||
} | |||
const headers: Record<string, string> = { | |||
...( | |||
middlewareState.headers ?? {} | |||
), | |||
'Content-Language': req.cn.language.name | |||
}; | |||
if (middlewareState instanceof StreamResponse) { | |||
res.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); | |||
middlewareState.stream.pipe(res); | |||
middlewareState.stream.on('end', () => { | |||
res.end(); | |||
}); | |||
return; | |||
} | |||
if (middlewareState instanceof PlainResponse) { | |||
let encoded: Buffer | undefined; | |||
if (typeof middlewareState.body !== 'undefined') { | |||
let serialized; | |||
try { | |||
serialized = req.cn.mediaType.serialize(middlewareState.body); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToSerializeResponse', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
headers: { | |||
'Content-Language': req.backend.cn.language.name, | |||
}, | |||
}) | |||
} | |||
try { | |||
encoded = req.cn.charset.encode(serialized); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError('unableToEncodeResponse', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
headers: { | |||
'Content-Language': req.backend.cn.language.name, | |||
}, | |||
}) | |||
} | |||
headers['Content-Type'] = [ | |||
req.cn.mediaType.name, | |||
`charset=${req.cn.charset.name}` | |||
].join('; '); | |||
} | |||
res.writeHead(middlewareState.statusCode, headers); | |||
res.statusMessage = middlewareState.statusMessage ?? ''; | |||
if (typeof encoded !== 'undefined') { | |||
res.end(encoded); | |||
return; | |||
} | |||
res.end(); | |||
} | |||
return; | |||
} catch (finalErrRaw) { | |||
const finalErr = finalErrRaw as HttpMiddlewareError; | |||
const headers = finalErr.response.headers ?? {}; | |||
let encoded: Buffer | undefined; | |||
let serialized; | |||
try { | |||
serialized = typeof finalErr.response.body !== 'undefined' ? req.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; | |||
} catch (cause) { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
res.end(); | |||
return; | |||
} | |||
try { | |||
encoded = typeof serialized !== 'undefined' ? req.backend.cn.charset.encode(serialized) : undefined; | |||
} catch (cause) { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
res.end(); | |||
} | |||
headers['Content-Type'] = [ | |||
req.backend.cn.mediaType.name, | |||
`charset=${req.backend.cn.charset.name}` | |||
].join('; '); | |||
res.writeHead(finalErr.response.statusCode, headers); | |||
res.statusMessage = finalErr.response.statusMessage ?? ''; | |||
if (typeof encoded !== 'undefined') { | |||
res.end(encoded); | |||
return; | |||
} | |||
res.end(); | |||
return; | |||
} | |||
} | |||
if (middlewares.length > 0) { | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: middlewares.map((m) => m[0]).join(', ') | |||
}); | |||
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); | |||
res.end(); | |||
return; | |||
} | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); | |||
res.end(); | |||
return; | |||
}); | |||
return server; | |||
} |
@@ -0,0 +1,28 @@ | |||
import {IncomingMessage} from 'http'; | |||
import {MediaType, Charset} from '../common'; | |||
import {BaseSchema, parseAsync} from 'valibot'; | |||
export const getBody = ( | |||
req: IncomingMessage, | |||
schema: BaseSchema, | |||
encodingPair?: Charset, | |||
deserializer?: MediaType, | |||
) => new Promise((resolve, reject) => { | |||
let body = Buffer.from(''); | |||
req.on('data', (chunk) => { | |||
body = Buffer.concat([body, chunk]); | |||
}); | |||
req.on('end', async () => { | |||
const bodyStr = encodingPair?.decode(body) ?? body.toString(); | |||
try { | |||
const bodyDeserialized = await parseAsync( | |||
schema, | |||
deserializer?.deserialize(bodyStr) ?? body, | |||
{abortEarly: false}, | |||
); | |||
resolve(bodyDeserialized); | |||
} catch (err) { | |||
reject(err); | |||
} | |||
}); | |||
}); |
@@ -0,0 +1,48 @@ | |||
import * as applicationJson from '../common/media-types/application/json'; | |||
import * as utf8 from '../common/charsets/utf-8'; | |||
import * as en from '../common/languages/en'; | |||
import {ApplicationState, Charset, Language, MediaType} from '../common'; | |||
export interface ClientState { | |||
app: ApplicationState; | |||
mediaType: MediaType; | |||
charset: Charset; | |||
language: Language; | |||
} | |||
export interface ClientBuilder { | |||
setLanguage(languageCode: ClientState['language']['name']): this; | |||
setCharset(charset: ClientState['charset']['name']): this; | |||
setMediaType(mediaType: ClientState['mediaType']['name']): this; | |||
} | |||
export interface CreateClientParams { | |||
app: ApplicationState; | |||
} | |||
export const createClient = (params: CreateClientParams) => { | |||
const clientState: ClientState = { | |||
app: params.app, | |||
mediaType: applicationJson, | |||
charset: utf8, | |||
language: en | |||
}; | |||
return { | |||
setMediaType(mediaTypeName) { | |||
const mediaType = Array.from(clientState.app.mediaTypes).find((l) => l.name === mediaTypeName); | |||
clientState.mediaType = mediaType ?? applicationJson; | |||
return this; | |||
}, | |||
setCharset(charsetName) { | |||
const charset = Array.from(clientState.app.charsets).find((l) => l.name === charsetName); | |||
clientState.charset = charset ?? utf8; | |||
return this; | |||
}, | |||
setLanguage(languageCode) { | |||
const language = Array.from(clientState.app.languages).find((l) => l.name === languageCode); | |||
clientState.language = language ?? en; | |||
return this; | |||
} | |||
} satisfies ClientBuilder; | |||
}; |
@@ -0,0 +1,16 @@ | |||
import {Resource} from './resource'; | |||
import {Language} from './language'; | |||
import {MediaType} from './media-type'; | |||
import {Charset} from './charset'; | |||
export interface ApplicationState { | |||
name: string; | |||
resources: Set<Resource<any>>; | |||
languages: Set<Language>; | |||
mediaTypes: Set<MediaType>; | |||
charsets: Set<Charset>; | |||
} | |||
export interface ApplicationParams { | |||
name: string; | |||
} |
@@ -1,6 +1,4 @@ | |||
export * as utf8 from './utf-8'; | |||
export interface EncodingPair { | |||
export interface Charset { | |||
name: string; | |||
encode: (str: string) => Buffer; | |||
decode: (buf: Buffer) => string; |
@@ -0,0 +1 @@ | |||
export * as utf8 from './utf-8'; |
@@ -0,0 +1,5 @@ | |||
export interface BaseDataSource {} | |||
export interface GenerationStrategy<D extends BaseDataSource = BaseDataSource> { | |||
(dataSource: D, ...args: unknown[]): Promise<string | number | unknown>; | |||
} |
@@ -0,0 +1,5 @@ | |||
export * from './app'; | |||
export * from './charset'; | |||
export * from './media-type'; | |||
export * from './resource'; | |||
export * from './language'; |
@@ -1,4 +1,4 @@ | |||
import {Resource} from './core'; | |||
import {Resource} from './resource'; | |||
export type MessageBody = string | string[] | (string | string[])[]; | |||
@@ -6,6 +6,7 @@ export interface LanguageStatusMessageMap { | |||
unableToInitializeResourceDataSource(resource: Resource): string; | |||
unableToFetchResourceCollection(resource: Resource): string; | |||
unableToFetchResource(resource: Resource): string; | |||
resourceIdNotGiven(resource: Resource): string; | |||
languageNotAcceptable(): string; | |||
encodingNotAcceptable(): string; | |||
mediaTypeNotAcceptable(): string; | |||
@@ -17,6 +18,7 @@ export interface LanguageStatusMessageMap { | |||
resourceFetched(resource: Resource): string; | |||
resourceNotFound(resource: Resource): string; | |||
deleteNonExistingResource(resource: Resource): string; | |||
unableToCreateResource(resource: Resource): string; | |||
unableToGenerateIdFromResourceDataSource(resource: Resource): string; | |||
unableToEmplaceResource(resource: Resource): string; | |||
unableToSerializeResponse(): string; | |||
@@ -33,12 +35,14 @@ export interface LanguageStatusMessageMap { | |||
resourceReplaced(resource: Resource): string; | |||
} | |||
export interface LanguageBodyMap { | |||
languageNotAcceptable(): MessageBody; | |||
encodingNotAcceptable(): MessageBody; | |||
mediaTypeNotAcceptable(): MessageBody; | |||
} | |||
export interface Language { | |||
name: string, | |||
statusMessages: LanguageStatusMessageMap, | |||
bodies: { | |||
languageNotAcceptable(): MessageBody; | |||
encodingNotAcceptable(): MessageBody; | |||
mediaTypeNotAcceptable(): MessageBody; | |||
} | |||
bodies: LanguageBodyMap | |||
} |
@@ -1,5 +1,5 @@ | |||
import {Resource} from '../../core'; | |||
import {Language} from '../../common'; | |||
import {Resource} from '../../resource'; | |||
import {Language} from '../../language'; | |||
export const statusMessages = { | |||
unableToSerializeResponse(): string { | |||
@@ -85,6 +85,12 @@ export const statusMessages = { | |||
}, | |||
unableToEmplaceResource(resource: Resource): string { | |||
return `Unable To Emplace ${resource.state.itemName}`; | |||
}, | |||
resourceIdNotGiven(resource: Resource): string { | |||
return `${resource.state.itemName} ID Not Given`; | |||
}, | |||
unableToCreateResource(resource: Resource): string { | |||
return `Unable To Create ${resource.state.itemName}`; | |||
} | |||
} satisfies Language['statusMessages']; | |||
@@ -0,0 +1 @@ | |||
export * as en from './en'; |
@@ -0,0 +1,5 @@ | |||
export interface MediaType { | |||
name: string; | |||
serialize: <T>(object: T) => string; | |||
deserialize: <T>(s: string) => T; | |||
} |
@@ -0,0 +1,2 @@ | |||
export * as applicationJson from './application/json'; | |||
export * as textJson from './application/json'; |
@@ -0,0 +1,149 @@ | |||
import * as v from 'valibot'; | |||
import {BaseDataSource, GenerationStrategy} from './data-source'; | |||
export interface ResourceIdConfig<IdSchema extends v.BaseSchema, DataSource extends BaseDataSource = BaseDataSource> { | |||
generationStrategy: GenerationStrategy<DataSource>; | |||
serialize: (id: unknown) => string; | |||
deserialize: (id: string) => v.Output<IdSchema>; | |||
schema: IdSchema; | |||
} | |||
export interface ResourceState< | |||
ItemName extends string = string, | |||
RouteName extends string = string, | |||
IdAttr extends string = string, | |||
IdSchema extends v.BaseSchema = v.BaseSchema | |||
> { | |||
idAttr: IdAttr; | |||
itemName: ItemName; | |||
routeName: RouteName; | |||
idConfig: ResourceIdConfig<IdSchema>; | |||
fullTextAttrs: Set<string>; | |||
canCreate: boolean; | |||
canFetchCollection: boolean; | |||
canFetchItem: boolean; | |||
canPatch: boolean; | |||
canEmplace: boolean; | |||
canDelete: boolean; | |||
} | |||
export interface Resource< | |||
Schema extends v.BaseSchema = v.BaseSchema, | |||
CurrentName extends string = string, | |||
CurrentRouteName extends string = string, | |||
CurrentIdAttr extends string = string, | |||
IdSchema extends v.BaseSchema = v.BaseSchema | |||
> { | |||
dataSource?: unknown; | |||
schema: Schema; | |||
state: ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends v.BaseSchema>( | |||
newIdAttr: NewIdAttr, | |||
params: ResourceIdConfig<TheIdSchema> | |||
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>; | |||
fullText(fullTextAttr: string): this; | |||
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>; | |||
canFetchCollection(b?: boolean): this; | |||
canFetchItem(b?: boolean): this; | |||
canCreate(b?: boolean): this; | |||
canPatch(b?: boolean): this; | |||
canEmplace(b?: boolean): this; | |||
canDelete(b?: boolean): this; | |||
} | |||
export const resource = < | |||
Schema extends v.BaseSchema, | |||
CurrentName extends string = string, | |||
CurrentRouteName extends string = string, | |||
CurrentIdAttr extends string = string, | |||
IdSchema extends v.BaseSchema = v.BaseSchema | |||
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> => { | |||
const resourceState = { | |||
fullTextAttrs: new Set<string>(), | |||
canCreate: false, | |||
canFetchCollection: false, | |||
canFetchItem: false, | |||
canPatch: false, | |||
canEmplace: false, | |||
canDelete: false, | |||
} as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
return { | |||
get state(): ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> { | |||
return Object.freeze({ | |||
...resourceState | |||
}) as unknown as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
}, | |||
canFetchCollection(b = true) { | |||
resourceState.canFetchCollection = b; | |||
return this; | |||
}, | |||
canFetchItem(b = true) { | |||
resourceState.canFetchItem = b; | |||
return this; | |||
}, | |||
canCreate(b = true) { | |||
resourceState.canCreate = b; | |||
return this; | |||
}, | |||
canPatch(b = true) { | |||
resourceState.canPatch = b; | |||
return this; | |||
}, | |||
canEmplace(b = true) { | |||
resourceState.canEmplace = b; | |||
return this; | |||
}, | |||
canDelete(b = true) { | |||
resourceState.canDelete = b; | |||
return this; | |||
}, | |||
id<NewIdAttr extends CurrentIdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) { | |||
resourceState.idAttr = newIdAttr; | |||
resourceState.idConfig = params; | |||
return this as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||
}, | |||
newId(dataSource: BaseDataSource) { | |||
return resourceState?.idConfig?.generationStrategy?.(dataSource); | |||
}, | |||
fullText(fullTextAttr: string) { | |||
if ( | |||
schema.type === 'object' | |||
&& ( | |||
schema as unknown as v.ObjectSchema< | |||
Record<string, v.BaseSchema>, | |||
undefined, | |||
Record<string, string> | |||
> | |||
) | |||
.entries[fullTextAttr]?.type === 'string' | |||
) { | |||
resourceState.fullTextAttrs?.add(fullTextAttr); | |||
return this; | |||
} | |||
throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); | |||
}, | |||
name<NewName extends CurrentName>(n: NewName) { | |||
resourceState.itemName = n; | |||
return this as Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
}, | |||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName) { | |||
resourceState.routeName = n; | |||
return this as Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>; | |||
}, | |||
get idAttr() { | |||
return resourceState.idAttr; | |||
}, | |||
get itemName() { | |||
return resourceState.itemName; | |||
}, | |||
get routeName() { | |||
return resourceState.routeName; | |||
}, | |||
get schema() { | |||
return schema; | |||
}, | |||
} as Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
}; |
@@ -1,6 +1,6 @@ | |||
import * as v from 'valibot'; | |||
export * from 'valibot'; | |||
import { Resource } from './core'; | |||
import { Resource } from './resource'; | |||
export const datelike = () => v.transform( | |||
v.union([ |
@@ -1,585 +0,0 @@ | |||
import * as http from 'http'; | |||
import * as https from 'https'; | |||
import { constants } from 'http2'; | |||
import { pluralize } from 'inflection'; | |||
import * as v from 'valibot'; | |||
import { SerializerPair } from './serializers'; | |||
import { | |||
handleCreateItem, handleDeleteItem, handleEmplaceItem, | |||
handleGetCollection, | |||
handleGetItem, | |||
handleGetRoot, | |||
handleHasMethodAndUrl, handlePatchItem, | |||
} from './handlers'; | |||
import Negotiator from 'negotiator'; | |||
import {getMethod, getUrl} from './utils'; | |||
import {EncodingPair} from './encodings'; | |||
import * as en from './languages/en'; | |||
import * as utf8 from './encodings/utf-8'; | |||
import * as applicationJson from './serializers/application/json'; | |||
import {Language} from './common'; | |||
// TODO separate frontend and backend factory methods | |||
export interface DataSource<T = object, Q = object> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Q): Promise<number>; | |||
getMultiple(query?: Q): Promise<T[]>; | |||
getById(id: string): Promise<T | null>; | |||
getSingle?(query?: Q): Promise<T | null>; | |||
create(data: T): Promise<T>; | |||
delete(id: string): Promise<unknown>; | |||
emplace(id: string, data: T): Promise<[T, boolean]>; | |||
patch(id: string, data: Partial<T>): Promise<T | null>; | |||
} | |||
export interface ApplicationParams { | |||
name: string; | |||
dataSource?: (resource: Resource) => DataSource; | |||
} | |||
interface ResourceState<IdAttr extends string = string, IdSchema extends v.BaseSchema = v.BaseSchema> { | |||
idAttr: IdAttr; | |||
itemName: string; | |||
collectionName: string; | |||
routeName: string; | |||
idConfig: ResourceIdConfig<IdSchema>; | |||
fullTextAttrs: Set<string>; | |||
canCreate: boolean; | |||
canFetchCollection: boolean; | |||
canFetchItem: boolean; | |||
canPatch: boolean; | |||
canEmplace: boolean; | |||
canDelete: boolean; | |||
} | |||
export interface Resource< | |||
ResourceSchema extends v.BaseSchema = v.BaseSchema, | |||
IdAttr extends string = string, | |||
IdSchema extends v.BaseSchema = v.BaseSchema | |||
> { | |||
newId(dataSource: DataSource): string | number | unknown; | |||
schema: ResourceSchema; | |||
state: ResourceState<IdAttr, IdSchema>; | |||
id<NewIdAttr extends IdAttr, TheIdSchema extends v.BaseSchema>( | |||
newIdAttr: NewIdAttr, | |||
params: ResourceIdConfig<TheIdSchema> | |||
): Resource<ResourceSchema, NewIdAttr, TheIdSchema>; | |||
fullText(fullTextAttr: string): this; | |||
name(n: string): this; | |||
collection(n: string): this; | |||
route(n: string): this; | |||
canFetchCollection(b?: boolean): this; | |||
canFetchItem(b?: boolean): this; | |||
canCreate(b?: boolean): this; | |||
canPatch(b?: boolean): this; | |||
canEmplace(b?: boolean): this; | |||
canDelete(b?: boolean): this; | |||
} | |||
export interface ResourceWithDataSource<T extends v.BaseSchema> extends Resource<T> { | |||
dataSource: DataSource; | |||
} | |||
interface GenerationStrategy { | |||
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | |||
} | |||
interface ResourceIdConfig<IdSchema extends v.BaseSchema> { | |||
generationStrategy: GenerationStrategy; | |||
serialize: (id: unknown) => string; | |||
deserialize: (id: string) => v.Output<IdSchema>; | |||
schema: IdSchema; | |||
} | |||
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId: string) => { | |||
const middlewares = [] as [string, Middleware][]; | |||
if (mainResourceId === '') { | |||
if (resource.state.canFetchCollection) { | |||
middlewares.push(['GET', handleGetCollection]); | |||
} | |||
if (resource.state.canCreate) { | |||
middlewares.push(['POST', handleCreateItem]); | |||
} | |||
return middlewares; | |||
} | |||
if (resource.state.canFetchItem) { | |||
middlewares.push(['GET', handleGetItem]); | |||
} | |||
if (resource.state.canEmplace) { | |||
middlewares.push(['PUT', handleEmplaceItem]); | |||
} | |||
if (resource.state.canPatch) { | |||
middlewares.push(['PATCH', handlePatchItem]); | |||
} | |||
if (resource.state.canDelete) { | |||
middlewares.push(['DELETE', handleDeleteItem]); | |||
} | |||
return middlewares; | |||
}; | |||
export const resource = < | |||
ResourceSchema extends v.BaseSchema, | |||
IdAttr extends string = string, | |||
IdSchema extends v.BaseSchema = v.BaseSchema | |||
>(schema: ResourceSchema): Resource<ResourceSchema, IdAttr, IdSchema> => { | |||
const resourceState = { | |||
fullTextAttrs: new Set<string>(), | |||
canCreate: false, | |||
canFetchCollection: false, | |||
canFetchItem: false, | |||
canPatch: false, | |||
canEmplace: false, | |||
canDelete: false, | |||
} as ResourceState<IdAttr, IdSchema>; | |||
return { | |||
get state(): ResourceState<IdAttr, IdSchema> { | |||
return Object.freeze({ | |||
...resourceState | |||
}) as unknown as ResourceState<IdAttr, IdSchema>; | |||
}, | |||
canFetchCollection(b = true) { | |||
resourceState.canFetchCollection = b; | |||
return this; | |||
}, | |||
canFetchItem(b = true) { | |||
resourceState.canFetchItem = b; | |||
return this; | |||
}, | |||
canCreate(b = true) { | |||
resourceState.canCreate = b; | |||
return this; | |||
}, | |||
canPatch(b = true) { | |||
resourceState.canPatch = b; | |||
return this; | |||
}, | |||
canEmplace(b = true) { | |||
resourceState.canEmplace = b; | |||
return this; | |||
}, | |||
canDelete(b = true) { | |||
resourceState.canDelete = b; | |||
return this; | |||
}, | |||
id<NewIdAttr extends IdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) { | |||
resourceState.idAttr = newIdAttr; | |||
resourceState.idConfig = params; | |||
return this as Resource<ResourceSchema, NewIdAttr, NewIdSchema>; | |||
}, | |||
newId(dataSource: DataSource) { | |||
return resourceState?.idConfig?.generationStrategy?.(dataSource); | |||
}, | |||
fullText(fullTextAttr: string) { | |||
if ( | |||
schema.type === 'object' | |||
&& ( | |||
schema as unknown as v.ObjectSchema< | |||
Record<string, v.BaseSchema>, | |||
undefined, | |||
Record<string, string> | |||
> | |||
) | |||
.entries[fullTextAttr]?.type === 'string' | |||
) { | |||
resourceState.fullTextAttrs?.add(fullTextAttr); | |||
return this; | |||
} | |||
throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); | |||
}, | |||
name(n: string) { | |||
resourceState.itemName = n; | |||
resourceState.collectionName = resourceState.collectionName ?? pluralize(n).toLowerCase(); | |||
resourceState.routeName = resourceState.routeName ?? resourceState.collectionName; | |||
return this; | |||
}, | |||
collection(n: string) { | |||
resourceState.collectionName = n; | |||
resourceState.routeName = resourceState.routeName ?? n; | |||
return this; | |||
}, | |||
route(n: string) { | |||
resourceState.routeName = n; | |||
return this; | |||
}, | |||
get idAttr() { | |||
return resourceState.idAttr; | |||
}, | |||
get collectionName() { | |||
return resourceState.collectionName; | |||
}, | |||
get itemName() { | |||
return resourceState.itemName; | |||
}, | |||
get routeName() { | |||
return resourceState.routeName; | |||
}, | |||
get schema() { | |||
return schema; | |||
}, | |||
} as Resource<ResourceSchema, IdAttr, IdSchema>; | |||
}; | |||
type RequestListenerWithReturn< | |||
P extends unknown = unknown, | |||
Q extends typeof http.IncomingMessage = typeof http.IncomingMessage, | |||
R extends typeof http.ServerResponse = typeof http.ServerResponse | |||
> = ( | |||
...args: Parameters<http.RequestListener<Q, R>> | |||
) => P; | |||
interface HandlerState { | |||
handled: boolean; | |||
} | |||
export interface ApplicationState { | |||
resources: Set<ResourceWithDataSource<any>>; | |||
languages: Set<Language>; | |||
serializers: Set<SerializerPair>; | |||
encodings: Set<EncodingPair>; | |||
} | |||
export interface BackendState { | |||
fallback: { | |||
language: string; | |||
encoding: string; | |||
serializer: string; | |||
} | |||
errorHeaders: { | |||
language?: string; | |||
encoding?: string; | |||
serializer?: string; | |||
} | |||
showTotalItemCountOnGetCollection: boolean; | |||
throws404OnDeletingNotFound: boolean; | |||
checksSerializersOnDelete: boolean; | |||
showTotalItemCountOnCreateItem: boolean; | |||
} | |||
interface MiddlewareArgs<T extends v.BaseSchema> { | |||
handlerState: HandlerState; | |||
backendState: BackendState; | |||
appState: ApplicationState; | |||
appParams: ApplicationParams; | |||
serverParams: CreateServerParams; | |||
responseBodyLanguage: Language; | |||
responseBodyEncoding: EncodingPair; | |||
responseBodyMediaType: SerializerPair; | |||
errorResponseBodyLanguage: Language; | |||
errorResponseBodyEncoding: EncodingPair; | |||
errorResponseBodyMediaType: SerializerPair; | |||
resource: ResourceWithDataSource<T>; | |||
resourceId: string; | |||
query: URLSearchParams; | |||
} | |||
export interface Middleware { | |||
<T extends v.BaseSchema = v.BaseSchema>(args: MiddlewareArgs<T>): RequestListenerWithReturn<HandlerState | Promise<HandlerState>> | |||
} | |||
interface CreateServerParams { | |||
baseUrl?: string; | |||
host?: string; | |||
cert?: string; | |||
key?: string; | |||
requestTimeout?: number; | |||
} | |||
export interface Backend { | |||
showTotalItemCountOnGetCollection(b?: boolean): this; | |||
showTotalItemCountOnCreateItem(b?: boolean): this; | |||
checksSerializersOnDelete(b?: boolean): this; | |||
throws404OnDeletingNotFound(b?: boolean): this; | |||
createServer(serverParams?: CreateServerParams): http.Server | https.Server; | |||
} | |||
export interface Client { | |||
setLanguage(languageCode: string): this; | |||
setEncoding(encoding: string): this; | |||
setContentType(contentType: string): this; | |||
} | |||
export interface Application { | |||
contentType(serializerPair: SerializerPair): this; | |||
language(language: Language): this; | |||
encoding(encodingPair: EncodingPair): this; | |||
resource<T extends v.BaseSchema>(resRaw: Partial<Resource<T>>): this; | |||
createBackend(): Backend; | |||
createClient(): Client; | |||
} | |||
export const application = (appParams: ApplicationParams): Application => { | |||
const appState: ApplicationState = { | |||
resources: new Set<ResourceWithDataSource<any>>(), | |||
languages: new Set<Language>(), | |||
serializers: new Set<SerializerPair>(), | |||
encodings: new Set<EncodingPair>(), | |||
}; | |||
appState.languages.add(en); | |||
appState.encodings.add(utf8); | |||
appState.serializers.add(applicationJson); | |||
return { | |||
contentType(serializerPair: SerializerPair) { | |||
appState.serializers.add(serializerPair); | |||
return this; | |||
}, | |||
encoding(encodingPair: EncodingPair) { | |||
appState.encodings.add(encodingPair); | |||
return this; | |||
}, | |||
language(language: Language) { | |||
appState.languages.add(language); | |||
return this; | |||
}, | |||
resource<T extends v.BaseSchema>(resRaw: Partial<Resource<T>>) { | |||
const res = resRaw as Partial<ResourceWithDataSource<T>>; | |||
res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource); | |||
if (typeof res.dataSource === 'undefined') { | |||
throw new Error(`Resource ${res.state!.itemName} must have a data source.`); | |||
} | |||
appState.resources.add(res as ResourceWithDataSource<T>); | |||
return this; | |||
}, | |||
createClient(): Client { | |||
const clientState = { | |||
contentType: applicationJson.name, | |||
encoding: utf8.name, | |||
language: en.name as string | |||
}; | |||
return { | |||
setContentType(contentType: string) { | |||
clientState.contentType = contentType; | |||
return this; | |||
}, | |||
setEncoding(encoding: string) { | |||
clientState.encoding = encoding; | |||
return this; | |||
}, | |||
setLanguage(languageCode: string) { | |||
clientState.language = languageCode; | |||
return this; | |||
} | |||
} satisfies Client; | |||
}, | |||
createBackend(): Backend { | |||
const backendState: BackendState = { | |||
fallback: { | |||
language: en.name, | |||
encoding: utf8.name, | |||
serializer: applicationJson.name | |||
}, | |||
errorHeaders: { | |||
// undefined follows user accept headers strictly | |||
// | |||
language: undefined, | |||
encoding: undefined, | |||
serializer: undefined, | |||
}, | |||
showTotalItemCountOnGetCollection: false, | |||
showTotalItemCountOnCreateItem: false, | |||
throws404OnDeletingNotFound: false, | |||
checksSerializersOnDelete: false, | |||
}; | |||
return { | |||
showTotalItemCountOnGetCollection(b = true) { | |||
backendState.showTotalItemCountOnGetCollection = b; | |||
return this; | |||
}, | |||
showTotalItemCountOnCreateItem(b = true) { | |||
backendState.showTotalItemCountOnCreateItem = b; | |||
return this; | |||
}, | |||
throws404OnDeletingNotFound(b = true) { | |||
backendState.throws404OnDeletingNotFound = b; | |||
return this; | |||
}, | |||
checksSerializersOnDelete(b = true) { | |||
backendState.checksSerializersOnDelete = b; | |||
return this; | |||
}, | |||
createServer(serverParams = {} as CreateServerParams) { | |||
const server = 'key' in serverParams && 'cert' in serverParams | |||
? https.createServer({ | |||
key: serverParams.key, | |||
cert: serverParams.cert, | |||
requestTimeout: serverParams.requestTimeout | |||
}) | |||
: http.createServer({ | |||
requestTimeout: serverParams.requestTimeout | |||
}); | |||
server.on('request', async (req, res) => { | |||
const method = getMethod(req); | |||
const baseUrl = serverParams.baseUrl ?? ''; | |||
const { url, query } = getUrl(req, baseUrl); | |||
const negotiator = new Negotiator(req); | |||
const availableLanguages = Array.from(appState.languages); | |||
const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? backendState.fallback.language; | |||
const availableEncodings = Array.from(appState.encodings); | |||
const encodingCandidate = negotiator.encoding(availableEncodings.map((l) => l.name)) ?? backendState.fallback.encoding; | |||
const availableContentTypes = Array.from(appState.serializers); | |||
const contentTypeCandidate = negotiator.mediaType(availableContentTypes.map((l) => l.name)) ?? backendState.fallback.serializer; | |||
const fallbackMessageCollection = en as Language; | |||
const fallbackSerializerPair = applicationJson as SerializerPair; | |||
const fallbackEncoding = utf8 as EncodingPair; | |||
const errorLanguageCode = backendState.errorHeaders.language ?? backendState.fallback.language; | |||
const errorMessageCollection = availableLanguages.find((l) => l.name === errorLanguageCode) ?? fallbackMessageCollection; | |||
const errorContentType = backendState.errorHeaders.serializer ?? backendState.fallback.serializer; | |||
const errorSerializerPair = availableContentTypes.find((l) => l.name === errorContentType) ?? fallbackSerializerPair; | |||
const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding; | |||
const errorEncoding = availableEncodings.find((l) => l.name === errorEncodingKey) ?? fallbackEncoding; | |||
// TODO refactor | |||
const currentLanguageMessages = availableLanguages.find((l) => l.name === languageCandidate); | |||
if (typeof currentLanguageMessages === 'undefined') { | |||
const data = errorMessageCollection.bodies.languageNotAcceptable(); | |||
const responseRaw = errorSerializerPair.serialize(data); | |||
const response = errorEncoding.encode(responseRaw); | |||
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { | |||
'Content-Language': errorLanguageCode, | |||
'Content-Type': errorContentType, | |||
'Content-Encoding': errorEncodingKey, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.languageNotAcceptable(); | |||
res.end(response); | |||
return; | |||
} | |||
const currentMediaType = availableContentTypes.find((l) => l.name === contentTypeCandidate); | |||
if (typeof currentMediaType === 'undefined') { | |||
const data = errorMessageCollection.bodies.languageNotAcceptable(); | |||
const responseRaw = errorSerializerPair.serialize(data); | |||
const response = errorEncoding.encode(responseRaw); | |||
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { | |||
'Content-Language': errorLanguageCode, | |||
'Content-Type': errorContentType, | |||
'Content-Encoding': errorEncodingKey, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.mediaTypeNotAcceptable(); | |||
res.end(response); | |||
return; | |||
} | |||
const responseBodyEncodingEntry = availableEncodings.find((l) => l.name === encodingCandidate); | |||
if (typeof responseBodyEncodingEntry === 'undefined') { | |||
const data = errorMessageCollection.bodies.languageNotAcceptable(); | |||
const responseRaw = errorSerializerPair.serialize(data); | |||
const response = errorEncoding.encode(responseRaw); | |||
res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { | |||
'Content-Language': errorLanguageCode, | |||
'Content-Type': errorContentType, | |||
'Content-Encoding': errorEncodingKey, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.encodingNotAcceptable(); | |||
res.end(response); | |||
return; | |||
} | |||
const middlewareArgs: Omit<MiddlewareArgs<never>, 'resource' | 'resourceId'> = { | |||
handlerState: { | |||
handled: false | |||
}, | |||
appState, | |||
appParams, | |||
backendState, | |||
serverParams, | |||
query, | |||
responseBodyEncoding: responseBodyEncodingEntry, | |||
responseBodyMediaType: currentMediaType, | |||
responseBodyLanguage: currentLanguageMessages, | |||
errorResponseBodyMediaType: errorSerializerPair, | |||
errorResponseBodyEncoding: errorEncoding, | |||
errorResponseBodyLanguage: errorMessageCollection, | |||
}; | |||
const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs<never>)(req, res); | |||
if (methodAndUrl.handled) { | |||
return; | |||
} | |||
if (url === '/') { | |||
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs<never>)(req, res); | |||
if (middlewareState.handled) { | |||
return; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: 'HEAD, GET' | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed(); | |||
res.end(); | |||
return; | |||
} | |||
const [, resourceRouteName, resourceId = ''] = url.split('/'); | |||
const resource = Array.from(appState.resources).find((r) => r.state!.routeName === resourceRouteName); | |||
if (typeof resource === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = errorMessageCollection.statusMessages.urlNotFound(); | |||
res.end(); | |||
return; | |||
} | |||
const middlewares = getAllowedMiddlewares(resource, resourceId); | |||
const middlewareState = await middlewares | |||
.reduce( | |||
async (currentHandlerStatePromise, [middlewareMethod, middleware]) => { | |||
const currentHandlerState = await currentHandlerStatePromise; | |||
if (method !== middlewareMethod) { | |||
return currentHandlerState; | |||
} | |||
if (currentHandlerState.handled) { | |||
return currentHandlerState; | |||
} | |||
return middleware({ | |||
...middlewareArgs, | |||
handlerState: currentHandlerState, | |||
resource, | |||
resourceId: resourceId, | |||
})(req, res); | |||
}, | |||
Promise.resolve<HandlerState>({ | |||
handled: false | |||
}) | |||
); | |||
if (middlewareState.handled) { | |||
return; | |||
} | |||
if (middlewares.length > 0) { | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: middlewares.map((m) => m[0]).join(', ') | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed(); | |||
res.end(); | |||
return; | |||
} | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = errorMessageCollection.statusMessages.urlNotFound(); | |||
res.end(); | |||
return; | |||
}); | |||
return server; | |||
} | |||
} satisfies Backend; | |||
}, | |||
}; | |||
}; |
@@ -1,835 +0,0 @@ | |||
import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {Middleware} from './core'; | |||
import {getBody, getDeserializerObjects} from './utils'; | |||
import {IncomingMessage, ServerResponse} from 'http'; | |||
export const handleHasMethodAndUrl: Middleware = ({ | |||
errorResponseBodyLanguage, | |||
}) => (req: IncomingMessage, res: ServerResponse) => { | |||
if (!req.method) { | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.methodNotAllowed(); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
if (!req.url) { | |||
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.badRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
return { | |||
handled: false | |||
}; | |||
}; | |||
export const handleGetRoot: Middleware = ({ | |||
appState, | |||
appParams, | |||
serverParams, | |||
responseBodyMediaType, | |||
responseBodyLanguage, | |||
responseBodyEncoding, | |||
errorResponseBodyLanguage, | |||
}) => (_req: IncomingMessage, res: ServerResponse) => { | |||
const data = { | |||
name: appParams.name | |||
}; | |||
let serialized; | |||
try { | |||
serialized = responseBodyMediaType.serialize(data); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let encoded; | |||
try { | |||
encoded = responseBodyEncoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const theHeaders: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType.name, | |||
'Content-Language': responseBodyLanguage.name, | |||
'Content-Encoding': responseBodyEncoding.name, | |||
}; | |||
const registeredResources = Array.from(appState.resources); | |||
const availableResources = registeredResources.filter((r) => ( | |||
r.canFetchCollection | |||
|| r.canCreate | |||
)); | |||
if (availableResources.length > 0) { | |||
// we are using custom headers for links because the standard Link header | |||
// is referring to the document metadata (e.g. author, next page, etc) | |||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link | |||
theHeaders['Link'] = availableResources | |||
.map((r) => | |||
`<${serverParams.baseUrl}/${r.state.routeName}>; rel="related"; name="${r.state.collectionName}"`, | |||
) | |||
.join(', '); | |||
} | |||
res.writeHead(constants.HTTP_STATUS_OK, theHeaders); | |||
res.statusMessage = responseBodyLanguage.statusMessages.ok(); | |||
res.end(encoded); | |||
return { | |||
handled: true | |||
}; | |||
}; | |||
export const handleGetCollection: Middleware = ({ | |||
resource, | |||
responseBodyMediaType, | |||
responseBodyLanguage, | |||
responseBodyEncoding, | |||
errorResponseBodyLanguage, | |||
backendState, | |||
query, | |||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let data: v.Output<typeof resource.schema>[]; | |||
let totalItemCount: number | undefined; | |||
try { | |||
// TODO querying mechanism | |||
data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource | |||
if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(query); | |||
} | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResourceCollection(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let serialized; | |||
try { | |||
serialized = responseBodyMediaType.serialize(data); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let encoded; | |||
try { | |||
encoded = responseBodyEncoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const headers: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType.name, | |||
'Content-Language': responseBodyLanguage.name, | |||
'Content-Encoding': responseBodyEncoding.name, | |||
}; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
res.writeHead(constants.HTTP_STATUS_OK, headers); | |||
res.statusMessage = responseBodyLanguage.statusMessages.resourceCollectionFetched(resource); | |||
res.end(encoded); | |||
return { | |||
handled: true | |||
}; | |||
}; | |||
export const handleGetItem: Middleware = ({ | |||
resourceId, | |||
resource, | |||
responseBodyMediaType, | |||
responseBodyLanguage, | |||
responseBodyEncoding, | |||
errorResponseBodyLanguage, | |||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let data: v.Output<typeof resource.schema> | null = null; | |||
try { | |||
data = await resource.dataSource.getById(resourceId); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let serialized: string | null; | |||
try { | |||
serialized = data === null ? null : responseBodyMediaType.serialize(data); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let encoded; | |||
try { | |||
encoded = serialized === null ? null : responseBodyEncoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
if (encoded) { | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseBodyMediaType.name, | |||
'Content-Language': responseBodyLanguage.name, | |||
'Content-Encoding': responseBodyEncoding.name, | |||
}); | |||
res.statusMessage = responseBodyLanguage.statusMessages.resourceFetched(resource) | |||
res.end(encoded); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.resourceNotFound(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
}; | |||
export const handleDeleteItem: Middleware = ({ | |||
resource, | |||
resourceId, | |||
responseBodyLanguage, | |||
errorResponseBodyLanguage, | |||
backendState, | |||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let existing: unknown | null; | |||
try { | |||
existing = await resource.dataSource.getById(resourceId); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
if (!existing && backendState.throws404OnDeletingNotFound) { | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.deleteNonExistingResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
try { | |||
if (existing) { | |||
await resource.dataSource.delete(resourceId); | |||
} | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeleteResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_NO_CONTENT, { | |||
'Content-Language': responseBodyLanguage.name, | |||
}); | |||
res.statusMessage = responseBodyLanguage.statusMessages.resourceDeleted(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
}; | |||
export const handlePatchItem: Middleware = ({ | |||
appState, | |||
resource, | |||
resourceId, | |||
responseBodyMediaType, | |||
responseBodyLanguage, | |||
responseBodyEncoding, | |||
errorResponseBodyLanguage, | |||
errorResponseBodyMediaType, | |||
errorResponseBodyEncoding, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | |||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | |||
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let existing: unknown | null; | |||
try { | |||
existing = await resource.dataSource.getById(resourceId); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
if (!existing) { | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.patchNonExistingResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let bodyDeserialized: unknown; | |||
try { | |||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema | |||
bodyDeserialized = await getBody( | |||
req, | |||
requestBodyDeserializerPair, | |||
requestBodyEncodingPair, | |||
schema.type === 'object' | |||
? v.partial( | |||
schema as v.ObjectSchema<any>, | |||
(schema as v.ObjectSchema<any>).rest, | |||
(schema as v.ObjectSchema<any>).pipe | |||
) | |||
: schema | |||
); | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
const headers: Record<string, string> = { | |||
'Content-Language': responseBodyLanguage.name, | |||
}; | |||
if (!Array.isArray(err.issues)) { | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResourcePatch(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
const serialized = errorResponseBodyMediaType.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
); | |||
const encoded = errorResponseBodyEncoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { | |||
...headers, | |||
'Content-Type': errorResponseBodyMediaType.name, | |||
'Content-Encoding': errorResponseBodyEncoding.name, | |||
}) | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResourcePatch(resource); | |||
res.end(encoded); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const params = bodyDeserialized as Record<string, unknown>; | |||
let newObject: v.Output<typeof resource.schema> | null; | |||
try { | |||
newObject = await resource.dataSource.patch(resourceId, params); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToPatchResource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let serialized; | |||
try { | |||
serialized = responseBodyMediaType.serialize(newObject); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let encoded; | |||
try { | |||
encoded = responseBodyEncoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseBodyMediaType.name, | |||
'Content-Language': responseBodyLanguage.name, | |||
'Content-Encoding': responseBodyEncoding.name, | |||
}); | |||
res.statusMessage = responseBodyLanguage.statusMessages.resourcePatched(resource); | |||
res.end(encoded); | |||
return { | |||
handled: true | |||
}; | |||
// TODO finish the rest of the handlers!!! | |||
}; | |||
export const handleCreateItem: Middleware = ({ | |||
appState, | |||
serverParams, | |||
backendState, | |||
responseBodyMediaType, | |||
responseBodyLanguage, | |||
responseBodyEncoding, | |||
errorResponseBodyLanguage, | |||
errorResponseBodyMediaType, | |||
errorResponseBodyEncoding, | |||
resource, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | |||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | |||
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let bodyDeserialized: unknown; | |||
try { | |||
bodyDeserialized = await getBody( | |||
req, | |||
requestBodyDeserializerPair, | |||
requestBodyEncodingPair, | |||
resource.schema | |||
); | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
const headers: Record<string, string> = { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}; | |||
if (!Array.isArray(err.issues)) { | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
const serialized = errorResponseBodyMediaType.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
); | |||
const encoded = errorResponseBodyEncoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { | |||
...headers, | |||
'Content-Type': errorResponseBodyMediaType.name, | |||
'Content-Encoding': errorResponseBodyEncoding.name, | |||
}) | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); | |||
res.end(encoded); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
//v.Output<typeof resource.schema> | |||
let newId; | |||
let params: v.Output<typeof resource.schema>; | |||
try { | |||
newId = await resource.newId(resource.dataSource); | |||
params = bodyDeserialized as Record<string, unknown>; | |||
params[resource.state.idAttr] = newId; | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToGenerateIdFromResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
// noop | |||
// TODO | |||
} | |||
let newObject; | |||
let totalItemCount: number | undefined; | |||
try { | |||
newObject = await resource.dataSource.create(params); | |||
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
} catch { | |||
// noop | |||
// TODO | |||
} | |||
let serialized; | |||
try { | |||
serialized = responseBodyMediaType.serialize(newObject); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let encoded; | |||
try { | |||
encoded = responseBodyEncoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const headers: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType.name, | |||
'Content-Language': responseBodyLanguage.name, | |||
'Content-Encoding': responseBodyEncoding.name, | |||
'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}` | |||
}; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
res.writeHead(constants.HTTP_STATUS_CREATED, headers); | |||
res.statusMessage = responseBodyLanguage.statusMessages.resourceCreated(resource); | |||
res.end(encoded); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
export const handleEmplaceItem: Middleware = ({ | |||
appState, | |||
serverParams, | |||
responseBodyMediaType, | |||
responseBodyLanguage, | |||
responseBodyEncoding, | |||
errorResponseBodyLanguage, | |||
errorResponseBodyMediaType, | |||
errorResponseBodyEncoding, | |||
resource, | |||
resourceId, | |||
backendState, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | |||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | |||
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let bodyDeserialized: unknown; | |||
try { | |||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema | |||
bodyDeserialized = await getBody( | |||
req, | |||
requestBodyDeserializerPair, | |||
requestBodyEncodingPair, | |||
schema.type === 'object' | |||
? v.merge([ | |||
schema as v.ObjectSchema<any>, | |||
v.object({ | |||
[resource.state.idAttr]: v.transform( | |||
v.any(), | |||
input => resource.state.idConfig.serialize(input), | |||
v.literal(resourceId) | |||
) | |||
}) | |||
]) | |||
: schema | |||
); | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
const headers: Record<string, string> = { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}; | |||
if (!Array.isArray(err.issues)) { | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
const serialized = errorResponseBodyMediaType.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
); | |||
const encoded = errorResponseBodyEncoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { | |||
...headers, | |||
'Content-Type': errorResponseBodyMediaType.name, | |||
'Content-Encoding': errorResponseBodyEncoding.name, | |||
}) | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); | |||
res.end(encoded); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let newObject: v.Output<typeof resource.schema>; | |||
let isCreated: boolean; | |||
try { | |||
const params = bodyDeserialized as Record<string, unknown>; | |||
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); | |||
[newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEmplaceResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let serialized; | |||
try { | |||
serialized = responseBodyMediaType.serialize(newObject); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let encoded; | |||
try { | |||
encoded = responseBodyEncoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorResponseBodyLanguage.name, | |||
}); | |||
res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const headers: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType.name, | |||
'Content-Language': responseBodyLanguage.name, | |||
'Content-Encoding': responseBodyEncoding.name, | |||
}; | |||
let totalItemCount: number | undefined; | |||
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
if (isCreated) { | |||
headers['Location'] = `${serverParams.baseUrl}/${resource.state.routeName}/${resourceId}`; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
} | |||
res.writeHead(isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, headers); | |||
res.statusMessage = ( | |||
isCreated | |||
? responseBodyLanguage.statusMessages.resourceCreated(resource) | |||
: responseBodyLanguage.statusMessages.resourceReplaced(resource) | |||
); | |||
res.end(encoded); | |||
return { | |||
handled: true | |||
}; | |||
} |
@@ -1,5 +1,10 @@ | |||
export * from './core'; | |||
export * as validation from './validation'; | |||
export * as dataSources from './data-sources'; | |||
export * as serializers from './serializers'; | |||
export * as encodings from './encodings'; | |||
export * from './common'; | |||
export * as validation from './common/validation'; | |||
export * as dataSources from './backend/data-sources'; | |||
export * as mediaTypes from './common/media-types'; | |||
export * as charsets from './common/charsets'; | |||
export * as languages from './common/languages'; | |||
export * from './app'; |
@@ -1,8 +0,0 @@ | |||
export * as applicationJson from './application/json'; | |||
export * as textJson from './application/json'; | |||
export interface SerializerPair { | |||
name: string; | |||
serialize: <T>(object: T) => string; | |||
deserialize: <T>(s: string) => T; | |||
} |
@@ -1,54 +0,0 @@ | |||
import {IncomingMessage} from 'http'; | |||
import {SerializerPair} from './serializers'; | |||
import {BaseSchema, parseAsync} from 'valibot'; | |||
import { URL } from 'url'; | |||
import {EncodingPair} from './encodings'; | |||
import {ApplicationState} from './core'; | |||
export const getDeserializerObjects = (appState: ApplicationState, req: IncomingMessage) => { | |||
const availableSerializers = Array.from(appState.serializers); | |||
const availableEncodings = Array.from(appState.encodings); | |||
const deserializerPair = availableSerializers.find((l) => l.name === (req.headers['content-type'] ?? 'application/octet-stream')); | |||
const encodingPair = availableEncodings.find((l) => l.name === (req.headers['content-encoding'] ?? 'utf-8')); | |||
return { | |||
deserializerPair, | |||
encodingPair, | |||
}; | |||
}; | |||
export const getMethod = (req: IncomingMessage) => req.method!.trim().toUpperCase(); | |||
export const getUrl = (req: IncomingMessage, baseUrl?: string) => { | |||
const urlObject = new URL(req.url!, 'http://localhost'); | |||
const urlWithoutBaseRaw = urlObject.pathname.slice(baseUrl?.length ?? 0); | |||
return { | |||
url: urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw, | |||
query: urlObject.searchParams, | |||
}; | |||
} | |||
export const getBody = ( | |||
req: IncomingMessage, | |||
deserializer: SerializerPair, | |||
encodingPair: EncodingPair, | |||
schema: BaseSchema | |||
) => new Promise((resolve, reject) => { | |||
let body = Buffer.from(''); | |||
req.on('data', (chunk) => { | |||
body = Buffer.concat([body, chunk]); | |||
}); | |||
req.on('end', async () => { | |||
const bodyStr = encodingPair.decode(body); | |||
try { | |||
const bodyDeserialized = await parseAsync( | |||
schema, | |||
deserializer.deserialize(bodyStr), | |||
{abortEarly: false}, | |||
); | |||
resolve(bodyDeserialized); | |||
} catch (err) { | |||
reject(err); | |||
} | |||
}); | |||
}); |
@@ -19,23 +19,16 @@ import { | |||
import { | |||
join | |||
} from 'path'; | |||
import { | |||
application, | |||
DataSource, | |||
dataSources, | |||
encodings, | |||
Resource, | |||
resource, | |||
serializers, | |||
validation as v, | |||
} from '../../src'; | |||
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, charsets, mediaTypes } from '../../src'; | |||
const PORT = 3000; | |||
const HOST = 'localhost'; | |||
const ACCEPT_ENCODING = encodings.utf8.name; | |||
const ACCEPT = serializers.applicationJson.name; | |||
const ACCEPT_CHARSET = charsets.utf8.name; | |||
const ACCEPT = mediaTypes.applicationJson.name; | |||
const autoIncrement = async (dataSource: DataSource) => { | |||
const data = await dataSource.getMultiple() as Record<string, string>[]; | |||
@@ -79,31 +72,33 @@ describe('yasumi', () => { | |||
}, | |||
v.never() | |||
)) | |||
.name('Piano') | |||
.name('Piano' as const) | |||
.route('pianos' as const) | |||
.id('id' as const, { | |||
generationStrategy: autoIncrement, | |||
generationStrategy: autoIncrement as any, | |||
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', | |||
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | |||
}) | |||
.contentType(serializers.applicationJson) | |||
.encoding(encodings.utf8) | |||
.mediaType(mediaTypes.applicationJson) | |||
.charset(charsets.utf8) | |||
.resource(Piano); | |||
const backend = app | |||
.createBackend() | |||
.createBackend({ | |||
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | |||
}) | |||
.throws404OnDeletingNotFound(); | |||
server = backend.createServer({ | |||
baseUrl: '/api' | |||
basePath: '/api' | |||
}); | |||
return new Promise((resolve, reject) => { | |||
@@ -149,8 +144,8 @@ describe('yasumi', () => { | |||
path: '/api/pianos', | |||
method: 'GET', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
'Accept-Language': 'en', | |||
}, | |||
}, | |||
(res) => { | |||
@@ -159,7 +154,7 @@ describe('yasumi', () => { | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', ACCEPT); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
let resBuffer = Buffer.from(''); | |||
res.on('data', (c) => { | |||
@@ -167,7 +162,7 @@ describe('yasumi', () => { | |||
}); | |||
res.on('close', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual([]); | |||
resolve(); | |||
@@ -213,8 +208,7 @@ describe('yasumi', () => { | |||
path: '/api/pianos/1', | |||
method: 'GET', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
}, | |||
}, | |||
(res) => { | |||
@@ -223,7 +217,7 @@ describe('yasumi', () => { | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', ACCEPT); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
let resBuffer = Buffer.from(''); | |||
res.on('data', (c) => { | |||
@@ -231,7 +225,7 @@ describe('yasumi', () => { | |||
}); | |||
res.on('close', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual(data); | |||
resolve(); | |||
@@ -256,8 +250,7 @@ describe('yasumi', () => { | |||
path: '/api/pianos/2', | |||
method: 'GET', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
}, | |||
}, | |||
(res) => { | |||
@@ -312,8 +305,7 @@ describe('yasumi', () => { | |||
path: '/api/pianos', | |||
method: 'POST', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
'Content-Type': ACCEPT, | |||
}, | |||
}, | |||
@@ -323,7 +315,7 @@ describe('yasumi', () => { | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | |||
expect(res.headers).toHaveProperty('content-type', ACCEPT); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
let resBuffer = Buffer.from(''); | |||
res.on('data', (c) => { | |||
@@ -331,7 +323,7 @@ describe('yasumi', () => { | |||
}); | |||
res.on('close', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual({ | |||
...newData, | |||
@@ -385,8 +377,7 @@ describe('yasumi', () => { | |||
path: `/api/pianos/${data.id}`, | |||
method: 'PATCH', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
'Content-Type': ACCEPT, | |||
}, | |||
}, | |||
@@ -396,7 +387,7 @@ describe('yasumi', () => { | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', ACCEPT); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
let resBuffer = Buffer.from(''); | |||
res.on('data', (c) => { | |||
@@ -404,7 +395,7 @@ describe('yasumi', () => { | |||
}); | |||
res.on('close', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual({ | |||
...data, | |||
@@ -433,8 +424,7 @@ describe('yasumi', () => { | |||
path: '/api/pianos/2', | |||
method: 'PATCH', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
'Content-Type': ACCEPT, | |||
}, | |||
}, | |||
@@ -491,8 +481,7 @@ describe('yasumi', () => { | |||
path: `/api/pianos/${newData.id}`, | |||
method: 'PUT', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
'Content-Type': ACCEPT, | |||
}, | |||
}, | |||
@@ -502,7 +491,7 @@ describe('yasumi', () => { | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', ACCEPT); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
let resBuffer = Buffer.from(''); | |||
res.on('data', (c) => { | |||
@@ -510,7 +499,7 @@ describe('yasumi', () => { | |||
}); | |||
res.on('close', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual(newData); | |||
resolve(); | |||
@@ -538,8 +527,7 @@ describe('yasumi', () => { | |||
path: `/api/pianos/${id}`, | |||
method: 'PUT', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
'Content-Type': ACCEPT, | |||
}, | |||
}, | |||
@@ -549,7 +537,7 @@ describe('yasumi', () => { | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | |||
expect(res.headers).toHaveProperty('content-type', ACCEPT); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
let resBuffer = Buffer.from(''); | |||
res.on('data', (c) => { | |||
@@ -557,7 +545,7 @@ describe('yasumi', () => { | |||
}); | |||
res.on('close', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual({ | |||
...newData, | |||
@@ -609,8 +597,7 @@ describe('yasumi', () => { | |||
path: `/api/pianos/${data.id}`, | |||
method: 'DELETE', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
}, | |||
}, | |||
(res) => { | |||
@@ -640,8 +627,7 @@ describe('yasumi', () => { | |||
path: '/api/pianos/2', | |||
method: 'DELETE', | |||
headers: { | |||
'Accept': ACCEPT, | |||
'Accept-Encoding': ACCEPT_ENCODING, | |||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||
}, | |||
}, | |||
(res) => { | |||
@@ -3,7 +3,10 @@ | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["ESNext"], | |||
"lib": [ | |||
"ESNext", | |||
"dom" | |||
], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||