@@ -58,7 +58,7 @@ const backend = app.createBackend({ | |||||
dataSource, | dataSource, | ||||
}); | }); | ||||
const server = backend.createServer({ | |||||
const server = backend.createHttpServer({ | |||||
basePath: '/api' | 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 {BaseDataSource} from '../common/data-source'; | ||||
import http from 'http'; | |||||
export interface BackendState { | export interface BackendState { | ||||
app: ApplicationState; | app: ApplicationState; | ||||
dataSource: (resource: Resource) => BaseDataSource; | dataSource: (resource: Resource) => BaseDataSource; | ||||
cn: { | |||||
language: Language; | |||||
charset: Charset; | |||||
mediaType: MediaType; | |||||
} | |||||
cn: ContentNegotiation; | |||||
showTotalItemCountOnGetCollection: boolean; | showTotalItemCountOnGetCollection: boolean; | ||||
throws404OnDeletingNotFound: boolean; | throws404OnDeletingNotFound: boolean; | ||||
checksSerializersOnDelete: boolean; | checksSerializersOnDelete: boolean; | ||||
showTotalItemCountOnCreateItem: 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; | showTotalItemCountOnGetCollection(b?: boolean): this; | ||||
showTotalItemCountOnCreateItem(b?: boolean): this; | showTotalItemCountOnCreateItem(b?: boolean): this; | ||||
checksSerializersOnDelete(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; | dataSource?: (resource: Resource) => T; | ||||
} | } | ||||
@@ -57,7 +57,7 @@ export const createBackend = (params: CreateBackendParams) => { | |||||
backendState.showTotalItemCountOnCreateItem = b; | backendState.showTotalItemCountOnCreateItem = b; | ||||
return this; | return this; | ||||
}, | }, | ||||
throws404OnDeletingNotFound(b = true) { | |||||
throwsErrorOnDeletingNotFound(b = true) { | |||||
backendState.throws404OnDeletingNotFound = b; | backendState.throws404OnDeletingNotFound = b; | ||||
return this; | return this; | ||||
}, | }, | ||||
@@ -65,8 +65,8 @@ export const createBackend = (params: CreateBackendParams) => { | |||||
backendState.checksSerializersOnDelete = b; | backendState.checksSerializersOnDelete = b; | ||||
return this; | return this; | ||||
}, | }, | ||||
createServer(serverParams = {} as CreateServerParams) { | |||||
createHttpServer(serverParams = {} as CreateServerParams) { | |||||
return createServer(backendState, serverParams); | return createServer(backendState, serverParams); | ||||
} | |||||
}, | |||||
} satisfies BackendBuilder; | } 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 { constants } from 'http2'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; | import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; | ||||
import {LinkMap} from './utils'; | |||||
import {BackendResource} from './core'; | |||||
export const handleGetRoot: Middleware = (req) => { | export const handleGetRoot: Middleware = (req) => { | ||||
const { backend, basePath } = req; | const { backend, basePath } = req; | ||||
const data = { | 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) => ( | const availableResources = registeredResources.filter((r) => ( | ||||
r.state.canFetchCollection | r.state.canFetchCollection | ||||
|| r.state.canCreate | || r.state.canCreate | ||||
@@ -18,13 +20,16 @@ export const handleGetRoot: Middleware = (req) => { | |||||
const headers: Record<string, string> = {}; | const headers: Record<string, string> = {}; | ||||
if (availableResources.length > 0) { | if (availableResources.length > 0) { | ||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link | // 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({ | return new PlainResponse({ | ||||
@@ -36,7 +41,12 @@ export const handleGetRoot: Middleware = (req) => { | |||||
}; | }; | ||||
export const handleGetCollection: Middleware = async (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 data: v.Output<typeof resource.schema>[]; | ||||
let totalItemCount: number | undefined; | let totalItemCount: number | undefined; | ||||
@@ -57,7 +67,6 @@ export const handleGetCollection: Middleware = async (req) => { | |||||
} | } | ||||
const headers: Record<string, string> = {}; | const headers: Record<string, string> = {}; | ||||
if (typeof totalItemCount !== 'undefined') { | if (typeof totalItemCount !== 'undefined') { | ||||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | ||||
} | } | ||||
@@ -71,7 +80,12 @@ export const handleGetCollection: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handleGetItem: 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') { | if (typeof resourceId === 'undefined') { | ||||
throw new HttpMiddlewareError( | 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; | let data: v.Output<typeof resource.schema> | null = null; | ||||
try { | try { | ||||
data = await resource.dataSource.getById(resourceId); | data = await resource.dataSource.getById(resourceId); | ||||
@@ -112,7 +135,12 @@ export const handleGetItem: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handleDeleteItem: Middleware = async (req) => { | export const handleDeleteItem: Middleware = async (req) => { | ||||
const { resource, resourceId, backend } = req; | |||||
const { resource: resourceRaw, resourceId, backend } = req; | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
} | |||||
const resource = resourceRaw as BackendResource; | |||||
if (typeof resourceId === 'undefined') { | if (typeof resourceId === 'undefined') { | ||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
@@ -157,7 +185,21 @@ export const handleDeleteItem: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handlePatchItem: Middleware = async (req) => { | export const handlePatchItem: Middleware = async (req) => { | ||||
const { resource, resourceId, body } = req; | |||||
const { resource: 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; | let existing: unknown | null; | ||||
try { | try { | ||||
@@ -195,7 +237,12 @@ export const handlePatchItem: Middleware = async (req) => { | |||||
}; | }; | ||||
export const handleCreateItem: Middleware = async (req) => { | export const handleCreateItem: Middleware = async (req) => { | ||||
const { resource, body, backend, basePath } = req; | |||||
const { resource: resourceRaw, body, backend, basePath } = req; | |||||
if (typeof resourceRaw === 'undefined') { | |||||
throw new Error('No resource'); | |||||
} | |||||
const resource = resourceRaw as BackendResource; | |||||
let newId; | let newId; | ||||
let params: v.Output<typeof resource.schema>; | let params: v.Output<typeof resource.schema>; | ||||
@@ -249,7 +296,12 @@ export const handleCreateItem: Middleware = async (req) => { | |||||
} | } | ||||
export const handleEmplaceItem: Middleware = async (req) => { | export const handleEmplaceItem: Middleware = async (req) => { | ||||
const { resource, resourceId, basePath, body, backend } = req; | |||||
const { resource: 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 newObject: v.Output<typeof resource.schema>; | ||||
let isCreated: boolean; | let isCreated: boolean; | ||||
@@ -1,8 +1,7 @@ | |||||
import http from 'http'; | 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 https from 'https'; | ||||
import Negotiator from 'negotiator'; | |||||
import {constants} from 'http2'; | import {constants} from 'http2'; | ||||
import { | import { | ||||
handleCreateItem, | handleCreateItem, | ||||
@@ -18,7 +17,9 @@ import { | |||||
} from './core'; | } from './core'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {getBody} from './utils'; | 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 { | export interface Response { | ||||
statusCode: number; | statusCode: number; | ||||
@@ -83,36 +84,6 @@ export interface CreateServerParams { | |||||
streamResponses?: boolean; | 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> { | export interface Middleware<Req extends RequestContext = RequestContext> { | ||||
(req: Req): undefined | Response | Promise<undefined | Response>; | (req: Req): undefined | Response | Promise<undefined | Response>; | ||||
} | } | ||||
@@ -175,73 +146,6 @@ const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, m | |||||
return middlewares; | 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) => { | export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { | ||||
const isHttps = 'key' in serverParams && 'cert' in serverParams; | const isHttps = 'key' in serverParams && 'cert' in serverParams; | ||||
@@ -255,31 +159,32 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
requestTimeout: serverParams.requestTimeout, | 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; | let middlewareState; | ||||
if (req.url === '/' || req.url === '') { | if (req.url === '/' || req.url === '') { | ||||
middlewareState = await handleGetRoot(req); | middlewareState = await handleGetRoot(req); | ||||
} | } | ||||
let resource = req.resource as BackendResource | undefined; | |||||
if (typeof middlewareState === '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') { | if (typeof resource === 'undefined') { | ||||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | ||||
res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound; | res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound; | ||||
@@ -287,12 +192,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
return; | return; | ||||
} | } | ||||
req.resource = resource as BackendResource; | |||||
req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; | |||||
req.resourceId = resourceId; | |||||
try { | try { | ||||
await req.resource.dataSource.initialize(); | |||||
await resource.dataSource.initialize(); | |||||
} catch (cause) { | } catch (cause) { | ||||
throw new HttpMiddlewareError( | throw new HttpMiddlewareError( | ||||
'unableToInitializeResourceDataSource', | 'unableToInitializeResourceDataSource', | ||||
@@ -321,8 +222,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
} | } | ||||
if (schema) { | 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 contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; | ||||
const fragments = contentTypeHeader.split(';'); | const fragments = contentTypeHeader.split(';'); | ||||
const mediaType = fragments[0]; | 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; | 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); | res.writeHead(middlewareState.statusCode, headers); | ||||
if (typeof encoded !== 'undefined') { | if (typeof encoded !== 'undefined') { | ||||
res.end(encoded); | res.end(encoded); | ||||
@@ -459,7 +360,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
].join('; '); | ].join('; '); | ||||
const statusMessageKey = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; | 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); | res.writeHead(finalErr.response.statusCode, headers); | ||||
if (typeof encoded !== 'undefined') { | if (typeof encoded !== 'undefined') { | ||||
res.end(encoded); | res.end(encoded); | ||||
@@ -471,7 +372,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||||
} | } | ||||
if (middlewares.length > 0) { | 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, { | res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | ||||
Allow: middlewares.map((m) => m[0]).join(', '), | Allow: middlewares.map((m) => m[0]).join(', '), | ||||
'Content-Language': req.backend.cn.language.name, | '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 | // 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, { | res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | ||||
'Content-Language': req.backend.cn.language.name, | 'Content-Language': req.backend.cn.language.name, | ||||
}); | }); | ||||
res.end(); | res.end(); | ||||
return; | return; | ||||
}); | |||||
}; | |||||
server.on('request', handleRequest); | |||||
return server; | return server; | ||||
} | } |
@@ -3,26 +3,46 @@ import {MediaType, Charset} from '../common'; | |||||
import {BaseSchema, parseAsync} from 'valibot'; | import {BaseSchema, parseAsync} from 'valibot'; | ||||
export const getBody = ( | export const getBody = ( | ||||
req: IncomingMessage, | |||||
schema: BaseSchema, | |||||
encodingPair?: Charset, | |||||
deserializer?: MediaType, | |||||
req: IncomingMessage, | |||||
schema: BaseSchema, | |||||
encodingPair?: Charset, | |||||
deserializer?: MediaType, | |||||
) => new Promise((resolve, reject) => { | ) => 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 { | 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 { | export interface CreateClientParams { | ||||
@@ -34,18 +34,18 @@ export const createClient = (params: CreateClientParams) => { | |||||
}; | }; | ||||
return { | 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; | clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE; | ||||
return this; | 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; | clientState.charset = charset ?? FALLBACK_CHARSET; | ||||
return this; | 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; | clientState.language = language ?? FALLBACK_LANGUAGE; | ||||
return this; | return this; | ||||
} | } | ||||
@@ -2,15 +2,77 @@ import {Resource} from './resource'; | |||||
import {Language} from './language'; | import {Language} from './language'; | ||||
import {MediaType} from './media-type'; | import {MediaType} from './media-type'; | ||||
import {Charset} from './charset'; | 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 { | export interface ApplicationState { | ||||
name: string; | name: string; | ||||
resources: Set<Resource<any>>; | 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 { | export interface ApplicationParams { | ||||
name: string; | 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; | encode: (str: string) => Buffer; | ||||
decode: (buf: Buffer) => string; | 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 './resource'; | ||||
export * from './language'; | 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, | statusMessages: LanguageStatusMessageMap, | ||||
bodies: LanguageBodyMap | 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; | serialize: <T>(object: T) => string; | ||||
deserialize: <T>(s: string) => T; | 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>; | } 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'; | import * as v from 'valibot'; | ||||
export * from 'valibot'; | export * from 'valibot'; | ||||
import { Resource } from './resource'; | |||||
export const datelike = () => v.transform( | export const datelike = () => v.transform( | ||||
v.union([ | v.union([ | ||||
@@ -12,7 +11,3 @@ export const datelike = () => v.transform( | |||||
(value) => new Date(value).toISOString(), | (value) => new Date(value).toISOString(), | ||||
v.string([v.isoTimestamp()]) | 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 validation from './common/validation'; | ||||
export * as dataSources from './backend/data-sources'; | export * as dataSources from './backend/data-sources'; | ||||
export * from './app'; |
@@ -6,7 +6,6 @@ import { | |||||
describe, | describe, | ||||
expect, | expect, | ||||
it, | it, | ||||
test, | |||||
} from 'vitest'; | } from 'vitest'; | ||||
import { | import { | ||||
tmpdir | tmpdir | ||||
@@ -93,9 +92,9 @@ describe('yasumi', () => { | |||||
.createBackend({ | .createBackend({ | ||||
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), | ||||
}) | }) | ||||
.throws404OnDeletingNotFound(); | |||||
.throwsErrorOnDeletingNotFound(); | |||||
server = backend.createServer({ | |||||
server = backend.createHttpServer({ | |||||
basePath: '/api' | 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."); | |||||
}); | |||||
}); | |||||
}); | }); |