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 | 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, | application, | ||||
resource, | resource, | ||||
validation as v, | validation as v, | ||||
serializers, | |||||
encodings, | |||||
mediaTypes, | |||||
charsets, | |||||
} from '../../src'; | } from '../../src'; | ||||
import {TEXT_SERIALIZER_PAIR} from './serializers'; | import {TEXT_SERIALIZER_PAIR} from './serializers'; | ||||
import {autoIncrement, dataSource} from './data-source'; | import {autoIncrement, dataSource} from './data-source'; | ||||
@@ -49,28 +49,30 @@ const User = resource(v.object( | |||||
const app = application({ | const app = application({ | ||||
name: 'piano-service', | 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(Piano) | ||||
.resource(User); | .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 {readFile, writeFile} from 'fs/promises'; | ||||
import {join} from 'path'; | 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> { | export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> { | ||||
private readonly path: string; | private readonly path: string; | ||||
@@ -8,7 +9,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
data: T[] = []; | data: T[] = []; | ||||
constructor(private readonly resource: Resource, baseDir = '') { | 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() { | 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; | name: string; | ||||
encode: (str: string) => Buffer; | encode: (str: string) => Buffer; | ||||
decode: (buf: Buffer) => string; | 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[])[]; | export type MessageBody = string | string[] | (string | string[])[]; | ||||
@@ -6,6 +6,7 @@ export interface LanguageStatusMessageMap { | |||||
unableToInitializeResourceDataSource(resource: Resource): string; | unableToInitializeResourceDataSource(resource: Resource): string; | ||||
unableToFetchResourceCollection(resource: Resource): string; | unableToFetchResourceCollection(resource: Resource): string; | ||||
unableToFetchResource(resource: Resource): string; | unableToFetchResource(resource: Resource): string; | ||||
resourceIdNotGiven(resource: Resource): string; | |||||
languageNotAcceptable(): string; | languageNotAcceptable(): string; | ||||
encodingNotAcceptable(): string; | encodingNotAcceptable(): string; | ||||
mediaTypeNotAcceptable(): string; | mediaTypeNotAcceptable(): string; | ||||
@@ -17,6 +18,7 @@ export interface LanguageStatusMessageMap { | |||||
resourceFetched(resource: Resource): string; | resourceFetched(resource: Resource): string; | ||||
resourceNotFound(resource: Resource): string; | resourceNotFound(resource: Resource): string; | ||||
deleteNonExistingResource(resource: Resource): string; | deleteNonExistingResource(resource: Resource): string; | ||||
unableToCreateResource(resource: Resource): string; | |||||
unableToGenerateIdFromResourceDataSource(resource: Resource): string; | unableToGenerateIdFromResourceDataSource(resource: Resource): string; | ||||
unableToEmplaceResource(resource: Resource): string; | unableToEmplaceResource(resource: Resource): string; | ||||
unableToSerializeResponse(): string; | unableToSerializeResponse(): string; | ||||
@@ -33,12 +35,14 @@ export interface LanguageStatusMessageMap { | |||||
resourceReplaced(resource: Resource): string; | resourceReplaced(resource: Resource): string; | ||||
} | } | ||||
export interface LanguageBodyMap { | |||||
languageNotAcceptable(): MessageBody; | |||||
encodingNotAcceptable(): MessageBody; | |||||
mediaTypeNotAcceptable(): MessageBody; | |||||
} | |||||
export interface Language { | export interface Language { | ||||
name: string, | name: string, | ||||
statusMessages: LanguageStatusMessageMap, | 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 = { | export const statusMessages = { | ||||
unableToSerializeResponse(): string { | unableToSerializeResponse(): string { | ||||
@@ -85,6 +85,12 @@ export const statusMessages = { | |||||
}, | }, | ||||
unableToEmplaceResource(resource: Resource): string { | unableToEmplaceResource(resource: Resource): string { | ||||
return `Unable To Emplace ${resource.state.itemName}`; | 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']; | } 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'; | import * as v from 'valibot'; | ||||
export * from 'valibot'; | export * from 'valibot'; | ||||
import { Resource } from './core'; | |||||
import { Resource } from './resource'; | |||||
export const datelike = () => v.transform( | export const datelike = () => v.transform( | ||||
v.union([ | 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 { | import { | ||||
join | join | ||||
} from 'path'; | } from 'path'; | ||||
import { | |||||
application, | |||||
DataSource, | |||||
dataSources, | |||||
encodings, | |||||
Resource, | |||||
resource, | |||||
serializers, | |||||
validation as v, | |||||
} from '../../src'; | |||||
import {request, Server} from 'http'; | import {request, Server} from 'http'; | ||||
import {constants} from 'http2'; | 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 PORT = 3000; | ||||
const HOST = 'localhost'; | 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 autoIncrement = async (dataSource: DataSource) => { | ||||
const data = await dataSource.getMultiple() as Record<string, string>[]; | const data = await dataSource.getMultiple() as Record<string, string>[]; | ||||
@@ -79,31 +72,33 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
v.never() | v.never() | ||||
)) | )) | ||||
.name('Piano') | |||||
.name('Piano' as const) | |||||
.route('pianos' as const) | |||||
.id('id' as const, { | .id('id' as const, { | ||||
generationStrategy: autoIncrement, | |||||
generationStrategy: autoIncrement as any, | |||||
serialize: (id) => id?.toString() ?? '0', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | schema: v.number(), | ||||
}) | |||||
}); | |||||
}); | }); | ||||
let server: Server; | let server: Server; | ||||
beforeEach(() => { | beforeEach(() => { | ||||
const app = application({ | const app = application({ | ||||
name: 'piano-service', | 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); | .resource(Piano); | ||||
const backend = app | const backend = app | ||||
.createBackend() | |||||
.createBackend({ | |||||
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | |||||
}) | |||||
.throws404OnDeletingNotFound(); | .throws404OnDeletingNotFound(); | ||||
server = backend.createServer({ | server = backend.createServer({ | ||||
baseUrl: '/api' | |||||
basePath: '/api' | |||||
}); | }); | ||||
return new Promise((resolve, reject) => { | return new Promise((resolve, reject) => { | ||||
@@ -149,8 +144,8 @@ describe('yasumi', () => { | |||||
path: '/api/pianos', | path: '/api/pianos', | ||||
method: 'GET', | method: 'GET', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Accept-Language': 'en', | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -159,7 +154,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | 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(''); | let resBuffer = Buffer.from(''); | ||||
res.on('data', (c) => { | res.on('data', (c) => { | ||||
@@ -167,7 +162,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual([]); | expect(resData).toEqual([]); | ||||
resolve(); | resolve(); | ||||
@@ -213,8 +208,7 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/1', | path: '/api/pianos/1', | ||||
method: 'GET', | method: 'GET', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -223,7 +217,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | 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(''); | let resBuffer = Buffer.from(''); | ||||
res.on('data', (c) => { | res.on('data', (c) => { | ||||
@@ -231,7 +225,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual(data); | expect(resData).toEqual(data); | ||||
resolve(); | resolve(); | ||||
@@ -256,8 +250,7 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'GET', | method: 'GET', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -312,8 +305,7 @@ describe('yasumi', () => { | |||||
path: '/api/pianos', | path: '/api/pianos', | ||||
method: 'POST', | method: 'POST', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | 'Content-Type': ACCEPT, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -323,7 +315,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | 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(''); | let resBuffer = Buffer.from(''); | ||||
res.on('data', (c) => { | res.on('data', (c) => { | ||||
@@ -331,7 +323,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...newData, | ...newData, | ||||
@@ -385,8 +377,7 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${data.id}`, | path: `/api/pianos/${data.id}`, | ||||
method: 'PATCH', | method: 'PATCH', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | 'Content-Type': ACCEPT, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -396,7 +387,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | 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(''); | let resBuffer = Buffer.from(''); | ||||
res.on('data', (c) => { | res.on('data', (c) => { | ||||
@@ -404,7 +395,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...data, | ...data, | ||||
@@ -433,8 +424,7 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'PATCH', | method: 'PATCH', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | 'Content-Type': ACCEPT, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -491,8 +481,7 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${newData.id}`, | path: `/api/pianos/${newData.id}`, | ||||
method: 'PUT', | method: 'PUT', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | 'Content-Type': ACCEPT, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -502,7 +491,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | 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(''); | let resBuffer = Buffer.from(''); | ||||
res.on('data', (c) => { | res.on('data', (c) => { | ||||
@@ -510,7 +499,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual(newData); | expect(resData).toEqual(newData); | ||||
resolve(); | resolve(); | ||||
@@ -538,8 +527,7 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${id}`, | path: `/api/pianos/${id}`, | ||||
method: 'PUT', | method: 'PUT', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
'Content-Type': ACCEPT, | 'Content-Type': ACCEPT, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -549,7 +537,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | 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(''); | let resBuffer = Buffer.from(''); | ||||
res.on('data', (c) => { | res.on('data', (c) => { | ||||
@@ -557,7 +545,7 @@ describe('yasumi', () => { | |||||
}); | }); | ||||
res.on('close', () => { | res.on('close', () => { | ||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||||
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...newData, | ...newData, | ||||
@@ -609,8 +597,7 @@ describe('yasumi', () => { | |||||
path: `/api/pianos/${data.id}`, | path: `/api/pianos/${data.id}`, | ||||
method: 'DELETE', | method: 'DELETE', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -640,8 +627,7 @@ describe('yasumi', () => { | |||||
path: '/api/pianos/2', | path: '/api/pianos/2', | ||||
method: 'DELETE', | method: 'DELETE', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | |||||
'Accept-Encoding': ACCEPT_ENCODING, | |||||
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, | |||||
}, | }, | ||||
}, | }, | ||||
(res) => { | (res) => { | ||||
@@ -3,7 +3,10 @@ | |||||
"include": ["src", "types"], | "include": ["src", "types"], | ||||
"compilerOptions": { | "compilerOptions": { | ||||
"module": "ESNext", | "module": "ESNext", | ||||
"lib": ["ESNext"], | |||||
"lib": [ | |||||
"ESNext", | |||||
"dom" | |||||
], | |||||
"importHelpers": true, | "importHelpers": true, | ||||
"declaration": true, | "declaration": true, | ||||
"sourceMap": true, | "sourceMap": true, | ||||