@@ -58,7 +58,7 @@ const backend = app.createBackend({ | |||
dataSource, | |||
}); | |||
const server = backend.createServer({ | |||
const server = backend.createHttpServer({ | |||
basePath: '/api' | |||
}); | |||
@@ -1,67 +0,0 @@ | |||
import * as v from 'valibot'; | |||
import { | |||
Resource, | |||
Language, | |||
MediaType, | |||
Charset, | |||
ApplicationParams, | |||
ApplicationState, | |||
FALLBACK_LANGUAGE, | |||
FALLBACK_CHARSET, FALLBACK_MEDIA_TYPE, | |||
} 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(FALLBACK_LANGUAGE); | |||
appState.charsets.add(FALLBACK_CHARSET); | |||
appState.mediaTypes.add(FALLBACK_MEDIA_TYPE); | |||
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 | |||
}); | |||
}, | |||
}; | |||
}; |
@@ -1,16 +1,21 @@ | |||
import {ApplicationState, Charset, Language, MediaType, Resource} from '../common'; | |||
import {ApplicationState, ContentNegotiation, Resource} from '../common'; | |||
import {BaseDataSource} from '../common/data-source'; | |||
import http from 'http'; | |||
export interface BackendState { | |||
app: ApplicationState; | |||
dataSource: (resource: Resource) => BaseDataSource; | |||
cn: { | |||
language: Language; | |||
charset: Charset; | |||
mediaType: MediaType; | |||
} | |||
cn: ContentNegotiation; | |||
showTotalItemCountOnGetCollection: boolean; | |||
throws404OnDeletingNotFound: boolean; | |||
checksSerializersOnDelete: boolean; | |||
showTotalItemCountOnCreateItem: boolean; | |||
} | |||
export interface RequestContext extends http.IncomingMessage { | |||
body?: unknown; | |||
} | |||
export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>; | |||
export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator; |
@@ -23,8 +23,8 @@ 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; | |||
throwsErrorOnDeletingNotFound(b?: boolean): this; | |||
createHttpServer(serverParams?: CreateServerParams): http.Server | https.Server; | |||
dataSource?: (resource: Resource) => T; | |||
} | |||
@@ -57,7 +57,7 @@ export const createBackend = (params: CreateBackendParams) => { | |||
backendState.showTotalItemCountOnCreateItem = b; | |||
return this; | |||
}, | |||
throws404OnDeletingNotFound(b = true) { | |||
throwsErrorOnDeletingNotFound(b = true) { | |||
backendState.throws404OnDeletingNotFound = b; | |||
return this; | |||
}, | |||
@@ -65,8 +65,8 @@ export const createBackend = (params: CreateBackendParams) => { | |||
backendState.checksSerializersOnDelete = b; | |||
return this; | |||
}, | |||
createServer(serverParams = {} as CreateServerParams) { | |||
createHttpServer(serverParams = {} as CreateServerParams) { | |||
return createServer(backendState, serverParams); | |||
} | |||
}, | |||
} satisfies BackendBuilder; | |||
}; |
@@ -0,0 +1,29 @@ | |||
import {ContentNegotiation} from '../../../common'; | |||
import {RequestDecorator} from '../../common'; | |||
import Negotiator from 'negotiator'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
cn: ContentNegotiation; | |||
} | |||
} | |||
export const decorateRequestWithContentNegotiation: RequestDecorator = (req) => { | |||
const negotiator = new Negotiator(req); | |||
const availableLanguages = Array.from(req.backend.app.languages.values() ?? []); | |||
const availableCharsets = Array.from(req.backend.app.charsets.values() ?? []); | |||
const availableMediaTypes = Array.from(req.backend.app.mediaTypes.values() ?? []); | |||
const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? req.backend.cn.language.name; | |||
const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? req.backend.cn.charset.name; | |||
const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? req.backend.cn.mediaType.name; | |||
req.cn = { | |||
language: req.backend.app.languages.get(languageCandidate) ?? req.backend.cn.language, | |||
mediaType: req.backend.app.mediaTypes.get(mediaTypeCandidate) ?? req.backend.cn.mediaType, | |||
charset: req.backend.app.charsets.get(charsetCandidate) ?? req.backend.cn.charset, | |||
}; | |||
return req; | |||
}; |
@@ -0,0 +1,18 @@ | |||
import {BackendState, ParamRequestDecorator} from '../../common'; | |||
import {decorateRequestWithContentNegotiation} from './content-negotiation'; | |||
import {decorateRequestWithResource} from './resource'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
backend: BackendState; | |||
} | |||
} | |||
export const decorateRequestWithBackend: ParamRequestDecorator<[BackendState]> = (backend) => (req) => { | |||
req.backend = backend; | |||
decorateRequestWithContentNegotiation(req); | |||
decorateRequestWithResource(req); | |||
return req; | |||
}; |
@@ -0,0 +1,25 @@ | |||
import {RequestDecorator} from '../../common'; | |||
import {DataSource} from '../../data-source'; | |||
import {Resource} from '../../../common'; | |||
import {BackendResource} from '../../core'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
resource?: Resource; | |||
resourceId?: string; | |||
} | |||
} | |||
export const decorateRequestWithResource: RequestDecorator = (req) => { | |||
const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? []; | |||
const resource = Array.from(req.backend.app.resources) | |||
.find((r) => r.state.routeName === resourceRouteName) as BackendResource | undefined; | |||
if (typeof resource !== 'undefined') { | |||
req.resource = resource; | |||
req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; | |||
req.resourceId = resourceId; | |||
} | |||
return req; | |||
}; |
@@ -0,0 +1,7 @@ | |||
import {RequestDecorator} from '../../common'; | |||
export const decorateRequestWithMethod: RequestDecorator = (req) => { | |||
req.method = req.method?.trim().toUpperCase() ?? ''; | |||
return req; | |||
}; |
@@ -0,0 +1,13 @@ | |||
import {ParamRequestDecorator} from '../../common'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
basePath: string; | |||
} | |||
} | |||
export const decorateRequestWithBasePath: ParamRequestDecorator<[string]> = (basePath) => (req) => { | |||
req.basePath = basePath; | |||
return req; | |||
} |
@@ -0,0 +1,13 @@ | |||
import {ParamRequestDecorator} from '../../common'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
host: string; | |||
} | |||
} | |||
export const decorateRequestWithHost: ParamRequestDecorator<[string]> = (host) => (req) => { | |||
req.host = host; | |||
return req; | |||
}; |
@@ -0,0 +1,26 @@ | |||
import {ParamRequestDecorator} from '../../common'; | |||
import {CreateServerParams} from '../../server'; | |||
import {decorateRequestWithScheme} from './scheme'; | |||
import {decorateRequestWithHost} from './host'; | |||
import {decorateRequestWithBasePath} from './base-path'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
rawUrl?: string; | |||
query: URLSearchParams; | |||
} | |||
} | |||
export const decorateRequestWithUrl: ParamRequestDecorator<[CreateServerParams]> = (serverParams) => (req) => { | |||
const isHttps = 'key' in serverParams && 'cert' in serverParams; | |||
decorateRequestWithScheme(isHttps ? 'https' : 'http')(req); | |||
decorateRequestWithHost(serverParams.host ?? '127.0.0.1')(req); | |||
decorateRequestWithBasePath(serverParams.basePath ?? '')(req); | |||
const basePath = new URL(req.basePath, `${req.scheme}://${req.host}`); | |||
const parsedUrl = new URL(`${basePath.pathname}/${req.url ?? ''}`, basePath.origin); | |||
req.rawUrl = req.url; | |||
req.url = req.url?.slice(basePath.pathname.length) ?? ''; | |||
req.query = parsedUrl.searchParams; | |||
return req; | |||
}; |
@@ -0,0 +1,13 @@ | |||
import {ParamRequestDecorator} from '../../common'; | |||
declare module '../../common' { | |||
interface RequestContext { | |||
scheme: string; | |||
} | |||
} | |||
export const decorateRequestWithScheme: ParamRequestDecorator<[string]> = (scheme) => (req) => { | |||
req.scheme = scheme; | |||
return req; | |||
}; |
@@ -1,15 +1,17 @@ | |||
import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; | |||
import {LinkMap} from './utils'; | |||
import {BackendResource} from './core'; | |||
export const handleGetRoot: Middleware = (req) => { | |||
const { backend, basePath } = req; | |||
const data = { | |||
name: backend!.app.name | |||
name: backend.app.name | |||
}; | |||
const registeredResources = Array.from(backend!.app.resources); | |||
const registeredResources = Array.from(backend.app.resources); | |||
const availableResources = registeredResources.filter((r) => ( | |||
r.state.canFetchCollection | |||
|| r.state.canCreate | |||
@@ -18,13 +20,16 @@ export const handleGetRoot: Middleware = (req) => { | |||
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(', '); | |||
headers['Link'] = new LinkMap( | |||
availableResources.map((r) => ({ | |||
url: `${basePath}/${r.state.routeName}`, | |||
params: { | |||
rel: 'related', | |||
name: r.state.routeName, | |||
}, | |||
})) | |||
) | |||
.toString(); | |||
} | |||
return new PlainResponse({ | |||
@@ -36,7 +41,12 @@ export const handleGetRoot: Middleware = (req) => { | |||
}; | |||
export const handleGetCollection: Middleware = async (req) => { | |||
const { query, resource, backend } = req; | |||
const { query, resource: resourceRaw, backend } = req; | |||
if (typeof resourceRaw === 'undefined') { | |||
throw new Error('No resource'); | |||
} | |||
const resource = resourceRaw as BackendResource; | |||
let data: v.Output<typeof resource.schema>[]; | |||
let totalItemCount: number | undefined; | |||
@@ -57,7 +67,6 @@ export const handleGetCollection: Middleware = async (req) => { | |||
} | |||
const headers: Record<string, string> = {}; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
@@ -71,7 +80,12 @@ export const handleGetCollection: Middleware = async (req) => { | |||
}; | |||
export const handleGetItem: Middleware = async (req) => { | |||
const { resource, resourceId } = req; | |||
const { resource: resourceRaw, resourceId } = req; | |||
if (typeof resourceRaw === 'undefined') { | |||
throw new Error('No resource'); | |||
} | |||
const resource = resourceRaw as BackendResource; | |||
if (typeof resourceId === 'undefined') { | |||
throw new HttpMiddlewareError( | |||
@@ -82,6 +96,15 @@ export const handleGetItem: Middleware = async (req) => { | |||
); | |||
} | |||
if ((resourceId.trim().length ?? 0) < 1) { | |||
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); | |||
@@ -112,7 +135,12 @@ export const handleGetItem: Middleware = async (req) => { | |||
}; | |||
export const handleDeleteItem: Middleware = async (req) => { | |||
const { resource, resourceId, backend } = req; | |||
const { resource: resourceRaw, resourceId, backend } = req; | |||
if (typeof resourceRaw === 'undefined') { | |||
throw new Error('No resource'); | |||
} | |||
const resource = resourceRaw as BackendResource; | |||
if (typeof resourceId === 'undefined') { | |||
throw new HttpMiddlewareError( | |||
@@ -157,7 +185,21 @@ export const handleDeleteItem: Middleware = async (req) => { | |||
}; | |||
export const handlePatchItem: Middleware = async (req) => { | |||
const { resource, resourceId, body } = req; | |||
const { resource: resourceRaw, resourceId, body } = req; | |||
if (typeof resourceRaw === 'undefined') { | |||
throw new Error('No resource'); | |||
} | |||
const resource = resourceRaw as BackendResource; | |||
if (typeof resourceId === 'undefined') { | |||
throw new HttpMiddlewareError( | |||
'resourceIdNotGiven', | |||
{ | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
} | |||
); | |||
} | |||
let existing: unknown | null; | |||
try { | |||
@@ -195,7 +237,12 @@ export const handlePatchItem: Middleware = async (req) => { | |||
}; | |||
export const handleCreateItem: Middleware = async (req) => { | |||
const { resource, body, backend, basePath } = req; | |||
const { resource: resourceRaw, body, backend, basePath } = req; | |||
if (typeof resourceRaw === 'undefined') { | |||
throw new Error('No resource'); | |||
} | |||
const resource = resourceRaw as BackendResource; | |||
let newId; | |||
let params: v.Output<typeof resource.schema>; | |||
@@ -249,7 +296,12 @@ export const handleCreateItem: Middleware = async (req) => { | |||
} | |||
export const handleEmplaceItem: Middleware = async (req) => { | |||
const { resource, resourceId, basePath, body, backend } = req; | |||
const { resource: resourceRaw, resourceId, basePath, body, backend } = req; | |||
if (typeof resourceRaw === 'undefined') { | |||
throw new Error('No resource'); | |||
} | |||
const resource = resourceRaw as BackendResource; | |||
let newObject: v.Output<typeof resource.schema>; | |||
let isCreated: boolean; | |||
@@ -1,8 +1,7 @@ | |||
import http from 'http'; | |||
import {BackendState} from './common'; | |||
import {Language, Resource, Charset, MediaType, LanguageStatusMessageMap} from '../common'; | |||
import {BackendState, RequestContext} from './common'; | |||
import {Language, Resource, LanguageStatusMessageMap} from '../common'; | |||
import https from 'https'; | |||
import Negotiator from 'negotiator'; | |||
import {constants} from 'http2'; | |||
import { | |||
handleCreateItem, | |||
@@ -18,7 +17,9 @@ import { | |||
} from './core'; | |||
import * as v from 'valibot'; | |||
import {getBody} from './utils'; | |||
import {DataSource} from './data-source'; | |||
import {decorateRequestWithBackend} from './decorators/backend'; | |||
import {decorateRequestWithMethod} from './decorators/method'; | |||
import {decorateRequestWithUrl} from './decorators/url'; | |||
export interface Response { | |||
statusCode: number; | |||
@@ -83,36 +84,6 @@ export interface CreateServerParams { | |||
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>; | |||
} | |||
@@ -175,73 +146,6 @@ const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, m | |||
return middlewares; | |||
}; | |||
const adjustRequestForContentNegotiation = (req: RequestContext, res: http.ServerResponse<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)) ?? req.backend!.cn.language.name; | |||
const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? req.backend!.cn.charset.name; | |||
const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? req.backend!.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; | |||
}; | |||
export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { | |||
const isHttps = 'key' in serverParams && 'cert' in serverParams; | |||
@@ -255,31 +159,32 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
requestTimeout: serverParams.requestTimeout, | |||
}); | |||
server.on('request', async (req: RequestContext, res: http.ServerResponse<RequestContext>) => { | |||
req.backend = backendState; | |||
req.basePath = serverParams.basePath ?? ''; | |||
req.host = serverParams.host ?? 'localhost'; | |||
req.scheme = isHttps ? 'https' : 'http'; | |||
req.cn = req.backend.cn; | |||
req.method = req.method?.trim().toUpperCase() ?? ''; | |||
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; | |||
adjustRequestForContentNegotiation(req, res); | |||
const requestDecorators = [ | |||
decorateRequestWithMethod, | |||
decorateRequestWithUrl(serverParams), | |||
decorateRequestWithBackend(backendState), | |||
]; | |||
const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => { | |||
let req: RequestContext; | |||
// TODO custom decorators | |||
const effectiveRequestDecorators = requestDecorators; | |||
req = await effectiveRequestDecorators.reduce( | |||
async (resultRequestPromise, decorator) => { | |||
const resultRequest = await resultRequestPromise; | |||
return await decorator(resultRequest); | |||
}, | |||
Promise.resolve(reqRaw) | |||
); | |||
let middlewareState; | |||
if (req.url === '/' || req.url === '') { | |||
middlewareState = await handleGetRoot(req); | |||
} | |||
let resource = req.resource as BackendResource | undefined; | |||
if (typeof middlewareState === 'undefined') { | |||
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; | |||
@@ -287,12 +192,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
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(); | |||
await resource.dataSource.initialize(); | |||
} catch (cause) { | |||
throw new HttpMiddlewareError( | |||
'unableToInitializeResourceDataSource', | |||
@@ -321,8 +222,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
} | |||
if (schema) { | |||
const availableSerializers = Array.from(req.backend!.app.mediaTypes); | |||
const availableCharsets = Array.from(req.backend!.app.charsets); | |||
const availableSerializers = Array.from(req.backend!.app.mediaTypes.values()); | |||
const availableCharsets = Array.from(req.backend!.app.charsets.values()); | |||
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | |||
const fragments = contentTypeHeader.split(';'); | |||
const mediaType = fragments[0]; | |||
@@ -424,7 +325,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
} | |||
const statusMessageKey = middlewareState.statusMessage ? req.cn.language.statusMessages[middlewareState.statusMessage] : undefined; | |||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; | |||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; | |||
res.writeHead(middlewareState.statusCode, headers); | |||
if (typeof encoded !== 'undefined') { | |||
res.end(encoded); | |||
@@ -459,7 +360,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
].join('; '); | |||
const statusMessageKey = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; | |||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; | |||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; | |||
res.writeHead(finalErr.response.statusCode, headers); | |||
if (typeof encoded !== 'undefined') { | |||
res.end(encoded); | |||
@@ -471,7 +372,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
} | |||
if (middlewares.length > 0) { | |||
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; | |||
res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: middlewares.map((m) => m[0]).join(', '), | |||
'Content-Language': req.backend.cn.language.name, | |||
@@ -481,13 +382,15 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
} | |||
// TODO error handler in line with authentication | |||
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; | |||
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': req.backend.cn.language.name, | |||
}); | |||
res.end(); | |||
return; | |||
}); | |||
}; | |||
server.on('request', handleRequest); | |||
return server; | |||
} |
@@ -3,26 +3,46 @@ import {MediaType, Charset} from '../common'; | |||
import {BaseSchema, parseAsync} from 'valibot'; | |||
export const getBody = ( | |||
req: IncomingMessage, | |||
schema: BaseSchema, | |||
encodingPair?: Charset, | |||
deserializer?: MediaType, | |||
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); | |||
} | |||
}); | |||
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); | |||
} | |||
}); | |||
}); | |||
interface LinkMapEntry { | |||
url: string; | |||
params: Record<string, string>; | |||
} | |||
export class LinkMap extends Set<LinkMapEntry> { | |||
toString() { | |||
const entries = Array.from(this.values()); | |||
return entries.map((e) => { | |||
const params = Object.entries(e.params); | |||
return [ | |||
`<${encodeURIComponent(e.url)}>`, | |||
...params.map(([key, value]) => `${encodeURIComponent(key)}="${encodeURIComponent(value)}"`) | |||
].join(';') | |||
}).join(','); | |||
} | |||
} |
@@ -16,9 +16,9 @@ export interface ClientState { | |||
} | |||
export interface ClientBuilder { | |||
setLanguage(languageCode: ClientState['language']['name']): this; | |||
setCharset(charset: ClientState['charset']['name']): this; | |||
setMediaType(mediaType: ClientState['mediaType']['name']): this; | |||
language(languageCode: ClientState['language']['name']): this; | |||
charset(charset: ClientState['charset']['name']): this; | |||
mediaTyoe(mediaType: ClientState['mediaType']['name']): this; | |||
} | |||
export interface CreateClientParams { | |||
@@ -34,18 +34,18 @@ export const createClient = (params: CreateClientParams) => { | |||
}; | |||
return { | |||
setMediaType(mediaTypeName) { | |||
const mediaType = Array.from(clientState.app.mediaTypes).find((l) => l.name === mediaTypeName); | |||
mediaTyoe(mediaTypeName) { | |||
const mediaType = clientState.app.mediaTypes.get(mediaTypeName); | |||
clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE; | |||
return this; | |||
}, | |||
setCharset(charsetName) { | |||
const charset = Array.from(clientState.app.charsets).find((l) => l.name === charsetName); | |||
charset(charsetName) { | |||
const charset = clientState.app.charsets.get(charsetName); | |||
clientState.charset = charset ?? FALLBACK_CHARSET; | |||
return this; | |||
}, | |||
setLanguage(languageCode) { | |||
const language = Array.from(clientState.app.languages).find((l) => l.name === languageCode); | |||
language(languageCode) { | |||
const language = clientState.app.languages.get(languageCode); | |||
clientState.language = language ?? FALLBACK_LANGUAGE; | |||
return this; | |||
} | |||
@@ -2,15 +2,77 @@ import {Resource} from './resource'; | |||
import {Language} from './language'; | |||
import {MediaType} from './media-type'; | |||
import {Charset} from './charset'; | |||
import * as v from 'valibot'; | |||
import {BackendBuilder, createBackend, CreateBackendParams} from '../backend'; | |||
import {ClientBuilder, createClient, CreateClientParams} from '../client'; | |||
import {FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE} from './index'; | |||
type ApplicationMap<T extends { name: string }> = Map<T['name'], T>; | |||
export interface ApplicationState { | |||
name: string; | |||
resources: Set<Resource<any>>; | |||
languages: Set<Language>; | |||
mediaTypes: Set<MediaType>; | |||
charsets: Set<Charset>; | |||
languages: ApplicationMap<Language>; | |||
mediaTypes: ApplicationMap<MediaType>; | |||
charsets: ApplicationMap<Charset>; | |||
} | |||
export interface ApplicationParams { | |||
name: string; | |||
} | |||
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 Map<Language["name"], Language>([ | |||
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | |||
]), | |||
mediaTypes: new Map<MediaType["name"], MediaType>([ | |||
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], | |||
]), | |||
charsets: new Map<Charset["name"], Charset>([ | |||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | |||
]), | |||
}; | |||
return { | |||
mediaType(mediaType: MediaType) { | |||
appState.mediaTypes.set(mediaType.name, mediaType); | |||
return this; | |||
}, | |||
charset(charset: Charset) { | |||
appState.charsets.set(charset.name, charset); | |||
return this; | |||
}, | |||
language(language: Language) { | |||
appState.languages.set(language.name, 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 | |||
}); | |||
}, | |||
}; | |||
}; |
@@ -3,3 +3,9 @@ export interface Charset { | |||
encode: (str: string) => Buffer; | |||
decode: (buf: Buffer) => string; | |||
} | |||
export const FALLBACK_CHARSET = { | |||
encode: (str: string) => Buffer.from(str, 'utf-8'), | |||
decode: (buf: Buffer) => buf.toString('utf-8'), | |||
name: 'utf-8' as const, | |||
} satisfies Charset; |
@@ -8,55 +8,8 @@ export * from './media-type'; | |||
export * from './resource'; | |||
export * from './language'; | |||
export const FALLBACK_LANGUAGE = { | |||
name: 'en' as const, | |||
statusMessages: { | |||
unableToSerializeResponse: 'Unable To Serialize Response', | |||
unableToEncodeResponse: 'Unable To Encode Response', | |||
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | |||
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | |||
unableToFetchResource: 'Unable To Fetch $RESOURCE', | |||
unableToDeleteResource: 'Unable To Delete $RESOURCE', | |||
languageNotAcceptable: 'Language Not Acceptable', | |||
encodingNotAcceptable: 'Encoding Not Acceptable', | |||
mediaTypeNotAcceptable: 'Media Type Not Acceptable', | |||
methodNotAllowed: 'Method Not Allowed', | |||
urlNotFound: 'URL Not Found', | |||
badRequest: 'Bad Request', | |||
ok: 'OK', | |||
resourceCollectionFetched: '$RESOURCE Collection Fetched', | |||
resourceFetched: '$RESOURCE Fetched', | |||
resourceNotFound: '$RESOURCE Not Found', | |||
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', | |||
resourceDeleted: '$RESOURCE Deleted', | |||
unableToDeserializeRequest: 'Unable To Deserialize Request', | |||
patchNonExistingResource: 'Patch Non-Existing $RESOURCE', | |||
unableToPatchResource: 'Unable To Patch $RESOURCE', | |||
invalidResourcePatch: 'Invalid $RESOURCE Patch', | |||
invalidResource: 'Invalid $RESOURCE', | |||
resourcePatched: '$RESOURCE Patched', | |||
resourceCreated: '$RESOURCE Created', | |||
resourceReplaced: '$RESOURCE Replaced', | |||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | |||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | |||
resourceIdNotGiven: '$RESOURCE ID Not Given', | |||
unableToCreateResource: 'Unable To Create $RESOURCE', | |||
}, | |||
bodies: { | |||
languageNotAcceptable: [], | |||
encodingNotAcceptable: [], | |||
mediaTypeNotAcceptable: [] | |||
}, | |||
} satisfies Language; | |||
export const FALLBACK_CHARSET = { | |||
encode: (str: string) => Buffer.from(str, 'utf-8'), | |||
decode: (buf: Buffer) => buf.toString('utf-8'), | |||
name: 'utf-8' as const, | |||
} satisfies Charset; | |||
export const FALLBACK_MEDIA_TYPE = { | |||
serialize: (obj: unknown) => JSON.stringify(obj), | |||
deserialize: (str: string) => JSON.parse(str), | |||
name: 'application/json' as const, | |||
} satisfies MediaType; | |||
export interface ContentNegotiation { | |||
language: Language; | |||
mediaType: MediaType; | |||
charset: Charset; | |||
} |
@@ -52,3 +52,44 @@ export interface Language { | |||
statusMessages: LanguageStatusMessageMap, | |||
bodies: LanguageBodyMap | |||
} | |||
export const FALLBACK_LANGUAGE = { | |||
name: 'en' as const, | |||
statusMessages: { | |||
unableToSerializeResponse: 'Unable To Serialize Response', | |||
unableToEncodeResponse: 'Unable To Encode Response', | |||
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | |||
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | |||
unableToFetchResource: 'Unable To Fetch $RESOURCE', | |||
unableToDeleteResource: 'Unable To Delete $RESOURCE', | |||
languageNotAcceptable: 'Language Not Acceptable', | |||
encodingNotAcceptable: 'Encoding Not Acceptable', | |||
mediaTypeNotAcceptable: 'Media Type Not Acceptable', | |||
methodNotAllowed: 'Method Not Allowed', | |||
urlNotFound: 'URL Not Found', | |||
badRequest: 'Bad Request', | |||
ok: 'OK', | |||
resourceCollectionFetched: '$RESOURCE Collection Fetched', | |||
resourceFetched: '$RESOURCE Fetched', | |||
resourceNotFound: '$RESOURCE Not Found', | |||
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', | |||
resourceDeleted: '$RESOURCE Deleted', | |||
unableToDeserializeRequest: 'Unable To Deserialize Request', | |||
patchNonExistingResource: 'Patch Non-Existing $RESOURCE', | |||
unableToPatchResource: 'Unable To Patch $RESOURCE', | |||
invalidResourcePatch: 'Invalid $RESOURCE Patch', | |||
invalidResource: 'Invalid $RESOURCE', | |||
resourcePatched: '$RESOURCE Patched', | |||
resourceCreated: '$RESOURCE Created', | |||
resourceReplaced: '$RESOURCE Replaced', | |||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | |||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | |||
resourceIdNotGiven: '$RESOURCE ID Not Given', | |||
unableToCreateResource: 'Unable To Create $RESOURCE', | |||
}, | |||
bodies: { | |||
languageNotAcceptable: [], | |||
encodingNotAcceptable: [], | |||
mediaTypeNotAcceptable: [] | |||
}, | |||
} satisfies Language; |
@@ -3,3 +3,9 @@ export interface MediaType { | |||
serialize: <T>(object: T) => string; | |||
deserialize: <T>(s: string) => T; | |||
} | |||
export const FALLBACK_MEDIA_TYPE = { | |||
serialize: (obj: unknown) => JSON.stringify(obj), | |||
deserialize: (str: string) => JSON.parse(str), | |||
name: 'application/json' as const, | |||
} satisfies MediaType; |
@@ -147,3 +147,7 @@ export const resource = < | |||
}, | |||
} as Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
}; | |||
export type ResourceType<R extends Resource> = v.Output<R['schema']>; | |||
export type ResourceTypeWithId<R extends Resource> = ResourceType<R> & Record<R['state']['idAttr'], v.Output<R['state']['idConfig']['schema']>>; |
@@ -1,6 +1,5 @@ | |||
import * as v from 'valibot'; | |||
export * from 'valibot'; | |||
import { Resource } from './resource'; | |||
export const datelike = () => v.transform( | |||
v.union([ | |||
@@ -12,7 +11,3 @@ export const datelike = () => v.transform( | |||
(value) => new Date(value).toISOString(), | |||
v.string([v.isoTimestamp()]) | |||
); | |||
export type ResourceType<R extends Resource> = v.Output<R['schema']>; | |||
export type ResourceTypeWithId<R extends Resource> = ResourceType<R> & Record<R['state']['idAttr'], v.Output<R['state']['idConfig']['schema']>>; |
@@ -2,5 +2,3 @@ export * from './common'; | |||
export * as validation from './common/validation'; | |||
export * as dataSources from './backend/data-sources'; | |||
export * from './app'; |
@@ -6,7 +6,6 @@ import { | |||
describe, | |||
expect, | |||
it, | |||
test, | |||
} from 'vitest'; | |||
import { | |||
tmpdir | |||
@@ -93,9 +92,9 @@ describe('yasumi', () => { | |||
.createBackend({ | |||
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | |||
}) | |||
.throws404OnDeletingNotFound(); | |||
.throwsErrorOnDeletingNotFound(); | |||
server = backend.createServer({ | |||
server = backend.createHttpServer({ | |||
basePath: '/api' | |||
}); | |||
@@ -750,13 +749,4 @@ describe('yasumi', () => { | |||
}); | |||
}); | |||
}); | |||
// https://github.com/mayajs/maya/blob/main/test/index.test.ts | |||
// | |||
// peak unit test | |||
describe("Contribute to see a unit test", () => { | |||
test("should have a unit test", () => { | |||
expect("Is this a unit test?").not.toEqual("Yes this is a unit test."); | |||
}); | |||
}); | |||
}); |