@@ -9,10 +9,10 @@ export const autoIncrement = async (dataSource: DataSource) => { | |||
); | |||
if (Number.isFinite(highestId)) { | |||
return (highestId + 1).toString(); | |||
return (highestId + 1); | |||
} | |||
return "1"; | |||
return 1; | |||
}; | |||
export const dataSource = (resource: Resource) => new dataSources.jsonlFile.DataSource(resource, 'examples/basic'); |
@@ -20,12 +20,12 @@ const Piano = resource(v.object( | |||
serialize: (id) => id?.toString() ?? '0', | |||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | |||
}) | |||
.allowFetchItem() | |||
.allowFetchCollection() | |||
.allowCreate() | |||
.allowEmplace() | |||
.allowPatch() | |||
.allowDelete(); | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete(); | |||
const User = resource(v.object( | |||
{ | |||
@@ -56,7 +56,9 @@ const app = application({ | |||
.resource(Piano) | |||
.resource(User); | |||
const server = app.createServer({ | |||
const backend = app.createBackend(); | |||
const server = backend.createServer({ | |||
baseUrl: '/api' | |||
}); | |||
@@ -65,8 +67,8 @@ server.listen(3000); | |||
setTimeout(() => { | |||
// Allow user operations after 5 seconds from startup | |||
User | |||
.allowFetchItem() | |||
.allowFetchCollection() | |||
.allowCreate() | |||
.allowPatch(); | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canPatch(); | |||
}, 5000); |
@@ -14,9 +14,17 @@ import { | |||
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'; | |||
// TODO define ResourceState | |||
// TODO separate frontend and backend factory methods | |||
// TODO complete content negotiation and default (fallback) messages collection | |||
export interface DataSource<T = object> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(): Promise<number>; | |||
getMultiple(): Promise<T[]>; | |||
getSingle(id: string): Promise<T | null>; | |||
create(data: Partial<T>): Promise<T>; | |||
@@ -30,52 +38,36 @@ export interface ApplicationParams { | |||
dataSource?: (resource: Resource) => DataSource; | |||
} | |||
interface ResourceFactory { | |||
shouldCheckSerializersOnDelete(b: boolean): this; | |||
shouldThrow404OnDeletingNotFound(b: boolean): this; | |||
export interface Resource<T extends BaseSchema = any> { | |||
newId(dataSource: DataSource): string | number | unknown; | |||
schema: T; | |||
state: { | |||
idAttr: string; | |||
itemName?: string; | |||
collectionName?: string; | |||
routeName?: string; | |||
idSerializer: NonNullable<IdParams['serialize']>; | |||
idDeserializer: NonNullable<IdParams['deserialize']>; | |||
canCreate: boolean; | |||
canFetchCollection: boolean; | |||
canFetchItem: boolean; | |||
canPatch: boolean; | |||
canEmplace: boolean; | |||
canDelete: boolean; | |||
}; | |||
id(newIdAttr: string, params: IdParams): this; | |||
fullText(fullTextAttr: string): this; | |||
name(n: string): this; | |||
collection(n: string): this; | |||
route(n: string): this; | |||
allowFetchCollection(): this; | |||
allowFetchItem(): this; | |||
allowCreate(): this; | |||
allowPatch(): this; | |||
allowEmplace(): this; | |||
allowDelete(): this; | |||
revokeFetchCollection(): this; | |||
revokeFetchItem(): this; | |||
revokeCreate(): this; | |||
revokePatch(): this; | |||
revokeEmplace(): this; | |||
revokeDelete(): 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 ResourceData<T extends BaseSchema> { | |||
idAttr: string; | |||
itemName?: string; | |||
collectionName?: string; | |||
routeName?: string; | |||
newId(dataSource: DataSource): string | number | unknown; | |||
schema: T; | |||
throws404OnDeletingNotFound: boolean; | |||
checksSerializersOnDelete: boolean; | |||
idSerializer: NonNullable<IdParams['serialize']>; | |||
idDeserializer: NonNullable<IdParams['deserialize']>; | |||
} | |||
export interface ResourcePermissions { | |||
canCreate: boolean; | |||
canFetchCollection: boolean; | |||
canFetchItem: boolean; | |||
canPatch: boolean; | |||
canEmplace: boolean; | |||
canDelete: boolean; | |||
} | |||
export type Resource<T extends BaseSchema = any> = ResourceData<T> & ResourceFactory & ResourcePermissions; | |||
export interface ResourceWithDataSource<T extends BaseSchema = any> extends Resource<T> { | |||
dataSource: DataSource; | |||
} | |||
@@ -93,145 +85,98 @@ interface IdParams { | |||
const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { | |||
const middlewares = [] as [string, Middleware][]; | |||
if (mainResourceId === '') { | |||
if (resource.canFetchCollection) { | |||
if (resource.state.canFetchCollection) { | |||
middlewares.push(['GET', handleGetCollection]); | |||
} | |||
if (resource.canCreate) { | |||
if (resource.state.canCreate) { | |||
middlewares.push(['POST', handleCreateItem]); | |||
} | |||
return middlewares; | |||
} | |||
if (resource.canFetchItem) { | |||
if (resource.state.canFetchItem) { | |||
middlewares.push(['GET', handleGetItem]); | |||
} | |||
if (resource.canEmplace) { | |||
if (resource.state.canEmplace) { | |||
middlewares.push(['PUT', handleEmplaceItem]); | |||
} | |||
if (resource.canPatch) { | |||
if (resource.state.canPatch) { | |||
middlewares.push(['PATCH', handlePatchItem]); | |||
} | |||
if (resource.canDelete) { | |||
if (resource.state.canDelete) { | |||
middlewares.push(['DELETE', handleDeleteItem]); | |||
} | |||
return middlewares; | |||
}; | |||
interface ResourceState { | |||
idAttr: string | |||
itemName: string | |||
collectionName: string | |||
routeName: string | |||
idGenerationStrategy: GenerationStrategy | |||
idSerializer: IdParams['serialize'] | |||
idDeserializer: IdParams['deserialize'] | |||
fullTextAttrs: Set<string>; | |||
canCreate: boolean; | |||
canFetchCollection: boolean; | |||
canFetchItem: boolean; | |||
canPatch: boolean; | |||
canEmplace: boolean; | |||
canDelete: boolean; | |||
} | |||
export const resource = <T extends BaseSchema>(schema: T): Resource<T> => { | |||
let theIdAttr: string; | |||
let theItemName: string; | |||
let theCollectionName: string; | |||
let theRouteName: string; | |||
let theIdGenerationStrategy: GenerationStrategy; | |||
let theIdSerializer: IdParams['serialize']; | |||
let theIdDeserializer: IdParams['deserialize']; | |||
let throw404OnDeletingNotFound = true; | |||
let checkSerializersOnDelete = false; | |||
const fullTextAttrs = new Set<string>(); | |||
let canCreate = false; | |||
let canFetchCollection = false; | |||
let canFetchItem = false; | |||
let canPatch = false; | |||
let canEmplace = false; | |||
let canDelete = false; | |||
const resourceState = { | |||
fullTextAttrs: new Set<string>(), | |||
canCreate: false, | |||
canFetchCollection: false, | |||
canFetchItem: false, | |||
canPatch: false, | |||
canEmplace: false, | |||
canDelete: false, | |||
} as Partial<ResourceState>; | |||
return { | |||
allowFetchCollection() { | |||
canFetchCollection = true; | |||
return this; | |||
}, | |||
allowFetchItem() { | |||
canFetchItem = true; | |||
return this; | |||
}, | |||
allowCreate() { | |||
canCreate = true; | |||
return this; | |||
}, | |||
allowPatch() { | |||
canPatch = true; | |||
return this; | |||
}, | |||
allowEmplace() { | |||
canEmplace = true; | |||
return this; | |||
}, | |||
allowDelete() { | |||
canDelete = true; | |||
return this; | |||
}, | |||
revokeFetchCollection() { | |||
canFetchCollection = false; | |||
return this; | |||
get state(): ResourceState { | |||
return Object.freeze({ | |||
...resourceState | |||
}) as unknown as ResourceState; | |||
}, | |||
revokeFetchItem() { | |||
canFetchItem = false; | |||
canFetchCollection(b = true) { | |||
resourceState.canFetchCollection = b; | |||
return this; | |||
}, | |||
revokeCreate() { | |||
canCreate = false; | |||
canFetchItem(b = true) { | |||
resourceState.canFetchItem = b; | |||
return this; | |||
}, | |||
revokePatch() { | |||
canPatch = false; | |||
canCreate(b = true) { | |||
resourceState.canCreate = b; | |||
return this; | |||
}, | |||
revokeEmplace() { | |||
canEmplace = false; | |||
canPatch(b = true) { | |||
resourceState.canPatch = b; | |||
return this; | |||
}, | |||
revokeDelete() { | |||
canDelete = false; | |||
return this; | |||
}, | |||
get canCreate() { | |||
return canCreate; | |||
}, | |||
get canFetchCollection() { | |||
return canFetchCollection; | |||
}, | |||
get canFetchItem() { | |||
return canFetchItem; | |||
}, | |||
get canPatch() { | |||
return canPatch; | |||
}, | |||
get canEmplace() { | |||
return canEmplace; | |||
}, | |||
get canDelete() { | |||
return canDelete; | |||
}, | |||
shouldCheckSerializersOnDelete(b = true) { | |||
checkSerializersOnDelete = b; | |||
canEmplace(b = true) { | |||
resourceState.canEmplace = b; | |||
return this; | |||
}, | |||
get checksSerializersOnDelete() { | |||
return checkSerializersOnDelete; | |||
}, | |||
shouldThrow404OnDeletingNotFound(b = true) { | |||
throw404OnDeletingNotFound = b; | |||
canDelete(b = true) { | |||
resourceState.canDelete = b; | |||
return this; | |||
}, | |||
get throws404OnDeletingNotFound() { | |||
return throw404OnDeletingNotFound; | |||
}, | |||
get idSerializer() { | |||
return theIdSerializer; | |||
}, | |||
get idDeserializer() { | |||
return theIdDeserializer; | |||
}, | |||
id(newIdAttr: string, params: IdParams) { | |||
theIdAttr = newIdAttr; | |||
theIdGenerationStrategy = params.generationStrategy; | |||
theIdSerializer = params.serialize; | |||
theIdDeserializer = params.deserialize; | |||
resourceState.idAttr = newIdAttr; | |||
resourceState.idGenerationStrategy = params.generationStrategy; | |||
resourceState.idSerializer = params.serialize; | |||
resourceState.idDeserializer = params.deserialize; | |||
return this; | |||
}, | |||
newId(dataSource: DataSource) { | |||
return theIdGenerationStrategy(dataSource); | |||
return resourceState?.idGenerationStrategy?.(dataSource); | |||
}, | |||
fullText(fullTextAttr: string) { | |||
if ( | |||
@@ -245,38 +190,38 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => { | |||
) | |||
.entries[fullTextAttr]?.type === 'string' | |||
) { | |||
fullTextAttrs.add(fullTextAttr); | |||
resourceState.fullTextAttrs?.add(fullTextAttr); | |||
return this; | |||
} | |||
throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); | |||
}, | |||
name(n: string) { | |||
theItemName = n; | |||
theCollectionName = theCollectionName ?? pluralize(theItemName).toLowerCase(); | |||
theRouteName = theRouteName ?? theCollectionName; | |||
resourceState.itemName = n; | |||
resourceState.collectionName = resourceState.collectionName ?? pluralize(n).toLowerCase(); | |||
resourceState.routeName = resourceState.routeName ?? resourceState.collectionName; | |||
return this; | |||
}, | |||
collection(n: string) { | |||
theCollectionName = n; | |||
theRouteName = theRouteName ?? theCollectionName; | |||
resourceState.collectionName = n; | |||
resourceState.routeName = resourceState.routeName ?? n; | |||
return this; | |||
}, | |||
route(n: string) { | |||
theRouteName = n; | |||
resourceState.routeName = n; | |||
return this; | |||
}, | |||
get idAttr() { | |||
return theIdAttr; | |||
return resourceState.idAttr; | |||
}, | |||
get collectionName() { | |||
return theCollectionName; | |||
return resourceState.collectionName; | |||
}, | |||
get itemName() { | |||
return theItemName; | |||
return resourceState.itemName; | |||
}, | |||
get routeName() { | |||
return theRouteName; | |||
return resourceState.routeName; | |||
}, | |||
get schema() { | |||
return schema; | |||
@@ -306,18 +251,78 @@ interface HandlerState { | |||
export interface ApplicationState { | |||
resources: Set<ResourceWithDataSource>; | |||
languages: Map<string, MessageCollection>; | |||
serializers: Map<string, SerializerPair>; | |||
encodings: Map<string, EncodingPair>; | |||
} | |||
type MessageBody = string | string[] | (string | string[])[]; | |||
export interface MessageCollection { | |||
statusMessages: { | |||
unableToInitializeResourceDataSource(resource: Resource): string; | |||
unableToFetchResourceCollection(resource: Resource): string; | |||
unableToFetchResource(resource: Resource): string; | |||
languageNotAcceptable(): string; | |||
encodingNotAcceptable(): string; | |||
mediaTypeNotAcceptable(): string; | |||
methodNotAllowed(): string; | |||
urlNotFound(): string; | |||
badRequest(): string; | |||
ok(): string; | |||
resourceCollectionFetched(resource: Resource): string; | |||
resourceFetched(resource: Resource): string; | |||
resourceNotFound(resource: Resource): string; | |||
deleteNonExistingResource(resource: Resource): string; | |||
unableToSerializeResponse(): string; | |||
unableToEncodeResponse(): string; | |||
unableToDeleteResource(resource: Resource): string; | |||
resourceDeleted(resource: Resource): string; | |||
unableToDeserializeRequest(): string; | |||
patchNonExistingResource(resource: Resource): string; | |||
unableToPatchResource(resource: Resource): string; | |||
invalidResourcePatch(resource: Resource): string; | |||
invalidResource(resource: Resource): string; | |||
resourcePatched(resource: Resource): string; | |||
resourceCreated(resource: Resource): string; | |||
resourceReplaced(resource: Resource): string; | |||
}, | |||
bodies: { | |||
languageNotAcceptable(): MessageBody; | |||
encodingNotAcceptable(): MessageBody; | |||
mediaTypeNotAcceptable(): MessageBody; | |||
} | |||
} | |||
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 { | |||
handlerState: HandlerState; | |||
backendState: BackendState; | |||
appState: ApplicationState; | |||
appParams: ApplicationParams; | |||
serverParams: CreateServerParams; | |||
requestBodyEncodingPair: EncodingPair; | |||
responseBodySerializerPair: SerializerPair; | |||
responseMediaType: string; | |||
responseBodyLanguage: [string, MessageCollection]; | |||
responseBodyEncoding: [string, EncodingPair]; | |||
responseBodyMediaType: [string, SerializerPair]; | |||
errorResponseBodyLanguage: [string, MessageCollection]; | |||
errorResponseBodyEncoding: [string, EncodingPair]; | |||
errorResponseBodyMediaType: [string, SerializerPair]; | |||
resource: ResourceWithDataSource; | |||
resourceId: string; | |||
query: URLSearchParams; | |||
@@ -327,20 +332,41 @@ export interface Middleware { | |||
(args: MiddlewareArgs): RequestListenerWithReturn<HandlerState | Promise<HandlerState>> | |||
} | |||
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(mimeTypePrefix: string, serializerPair: SerializerPair): this; | |||
language(languageCode: string, messageCollection: MessageCollection): this; | |||
encoding(encoding: string, encodingPair: EncodingPair): this; | |||
resource(resRaw: Partial<Resource>): this; | |||
createServer(serverParams?: CreateServerParams): http.Server | https.Server; | |||
createBackend(): Backend; | |||
createClient(): Client; | |||
} | |||
export const application = (appParams: ApplicationParams): Application => { | |||
const appState: ApplicationState = { | |||
resources: new Set<ResourceWithDataSource>(), | |||
languages: new Map<string, MessageCollection>(), | |||
serializers: new Map<string, SerializerPair>(), | |||
encodings: new Map<string, EncodingPair>() | |||
encodings: new Map<string, EncodingPair>(), | |||
}; | |||
appState.languages.set(en.code, en.messages); | |||
appState.encodings.set(utf8.name, utf8); | |||
appState.serializers.set(applicationJson.name, applicationJson); | |||
return { | |||
contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { | |||
appState.serializers.set(mimeTypePrefix, serializerPair); | |||
@@ -350,148 +376,253 @@ export const application = (appParams: ApplicationParams): Application => { | |||
appState.encodings.set(encoding, encodingPair); | |||
return this; | |||
}, | |||
language(languageCode: string, messageCollection: MessageCollection) { | |||
appState.languages.set(languageCode, messageCollection); | |||
return this; | |||
}, | |||
resource(resRaw: Partial<Resource>) { | |||
const res = resRaw as Partial<ResourceWithDataSource>; | |||
res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource); | |||
if (typeof res.dataSource === 'undefined') { | |||
throw new Error(`Resource ${res.itemName} must have a data source.`); | |||
throw new Error(`Resource ${res.state!.itemName} must have a data source.`); | |||
} | |||
appState.resources.add(res as ResourceWithDataSource); | |||
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 availableMediaTypes = Array.from(appState.serializers.keys()); | |||
const responseMediaType = negotiator.mediaType(availableMediaTypes); | |||
if (typeof responseMediaType === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; | |||
res.end(); | |||
return; | |||
} | |||
const responseBodySerializerPair = appState.serializers.get(responseMediaType); | |||
if (typeof responseBodySerializerPair === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; | |||
res.end(); | |||
return; | |||
} | |||
const availableEncodings = Array.from(appState.encodings.keys()); | |||
const encoding = negotiator.encoding(availableEncodings); | |||
if (typeof encoding === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; | |||
res.end(); | |||
return; | |||
} | |||
const requestBodyEncodingPair = appState.encodings.get(encoding); | |||
if (typeof requestBodyEncodingPair === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; | |||
res.end(); | |||
return; | |||
} | |||
const middlewareArgs: Omit<MiddlewareArgs, 'resource' | 'resourceId'> = { | |||
handlerState: { | |||
handled: false | |||
}, | |||
appState, | |||
appParams, | |||
serverParams, | |||
responseBodySerializerPair, | |||
responseMediaType, | |||
query, | |||
requestBodyEncodingPair, | |||
}; | |||
const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); | |||
if (methodAndUrl.handled) { | |||
return; | |||
createClient(): Client { | |||
const clientState = { | |||
contentType: applicationJson.name, | |||
encoding: utf8.name, | |||
language: en.code | |||
}; | |||
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.code, | |||
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 languageCandidate = negotiator.language(Array.from(appState.languages.keys())) ?? backendState.fallback.language; | |||
const encodingCandidate = negotiator.encoding(Array.from(appState.encodings.keys())) ?? backendState.fallback.encoding; | |||
const contentTypeCandidate = negotiator.mediaType(Array.from(appState.serializers.keys())) ?? backendState.fallback.serializer; | |||
const availableLanguages = Array.from(appState.languages.entries()); | |||
const fallbackMessageCollection = en.messages as MessageCollection; | |||
const fallbackSerializerPair = applicationJson as SerializerPair; | |||
const fallbackEncoding = utf8 as EncodingPair; | |||
const errorLanguageCode = backendState.errorHeaders.language ?? backendState.fallback.language; | |||
const errorMessageCollection = appState.languages.get(errorLanguageCode) ?? fallbackMessageCollection; | |||
const errorContentType = backendState.errorHeaders.serializer ?? backendState.fallback.serializer; | |||
const errorSerializerPair = appState.serializers.get(errorContentType) ?? fallbackSerializerPair; | |||
const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding; | |||
const errorEncoding = appState.encodings.get(errorEncodingKey) ?? fallbackEncoding; | |||
const [currentLanguageCode, currentLanguageMessages] = availableLanguages.find(([code]) => code === languageCandidate) ?? []; | |||
if (typeof currentLanguageCode === 'undefined' || 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 availableMediaTypes = Array.from(appState.serializers.entries()); | |||
const [currentContentTypeMimeType, responseMediaTypeEntry] = availableMediaTypes.find(([key]) => key === contentTypeCandidate) ?? []; | |||
if (typeof currentContentTypeMimeType === 'undefined' || typeof responseMediaTypeEntry === '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 availableEncodings = Array.from(appState.encodings.entries()); | |||
const [currentEncoding, responseBodyEncodingEntry] = availableEncodings.find(([key]) => key === encodingCandidate) ?? []; | |||
if (typeof currentEncoding === 'undefined' || 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, 'resource' | 'resourceId'> = { | |||
handlerState: { | |||
handled: false | |||
}, | |||
appState, | |||
appParams, | |||
backendState, | |||
serverParams, | |||
query, | |||
responseBodyEncoding: [currentEncoding, responseBodyEncodingEntry], | |||
responseBodyMediaType: [currentContentTypeMimeType, responseMediaTypeEntry], | |||
responseBodyLanguage: [currentLanguageCode, currentLanguageMessages], | |||
errorResponseBodyMediaType: [errorContentType, errorSerializerPair], | |||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
}; | |||
const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); | |||
if (methodAndUrl.handled) { | |||
return; | |||
} | |||
if (url === '/') { | |||
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); | |||
if (middlewareState.handled) { | |||
return; | |||
} | |||
if (url === '/') { | |||
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); | |||
if (middlewareState.handled) { | |||
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; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: 'HEAD, GET' | |||
}); | |||
res.end(); | |||
return; | |||
} | |||
const [, resourceRouteName, resourceId = ''] = url.split('/'); | |||
const resource = Array.from(appState.resources).find((r) => r.routeName === resourceRouteName); | |||
if (typeof resource === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = 'URL Not Found'; | |||
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.end(); | |||
return; | |||
return server; | |||
} | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = 'URL Not Found'; | |||
res.end(); | |||
return; | |||
}); | |||
return server; | |||
} | |||
} satisfies Backend; | |||
}, | |||
}; | |||
}; |
@@ -8,7 +8,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
data: T[] = []; | |||
constructor(private readonly resource: Resource, baseDir = '') { | |||
this.path = join(baseDir, `${this.resource.collectionName}.jsonl`); | |||
this.path = join(baseDir, `${this.resource.state.collectionName}.jsonl`); | |||
} | |||
async initialize() { | |||
@@ -21,12 +21,16 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
} | |||
} | |||
async getTotalCount() { | |||
return this.data.length; | |||
} | |||
async getMultiple() { | |||
return [...this.data]; | |||
} | |||
async getSingle(id: string) { | |||
const foundData = this.data.find((s) => this.resource.idSerializer(s[this.resource.idAttr as string]) === id); | |||
const foundData = this.data.find((s) => this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id); | |||
if (foundData) { | |||
return { | |||
@@ -42,8 +46,8 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
...data | |||
} as Record<string, unknown>; | |||
if (this.resource.idAttr in newData) { | |||
newData[this.resource.idAttr] = this.resource.idDeserializer(newData[this.resource.idAttr] as string); | |||
if (this.resource.state.idAttr in newData) { | |||
newData[this.resource.state.idAttr] = this.resource.state.idDeserializer(newData[this.resource.state.idAttr] as string); | |||
} | |||
const newCollection = [ | |||
@@ -59,7 +63,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
async delete(id: string) { | |||
const oldDataLength = this.data.length; | |||
const newData = this.data.filter((s) => !(this.resource.idSerializer(s[this.resource.idAttr as string]) === id)); | |||
const newData = this.data.filter((s) => !(this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id)); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
@@ -70,12 +74,12 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
const existing = await this.getSingle(id); | |||
const dataToEmplace = { | |||
...data, | |||
[this.resource.idAttr]: this.resource.idDeserializer(id), | |||
[this.resource.state.idAttr]: this.resource.state.idDeserializer(id), | |||
}; | |||
if (existing) { | |||
const newData = this.data.map((d) => { | |||
if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) { | |||
if (this.resource.state.idSerializer(d[this.resource.state.idAttr as string]) === id) { | |||
return dataToEmplace; | |||
} | |||
@@ -104,7 +108,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||
} | |||
const newData = this.data.map((d) => { | |||
if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) { | |||
if (this.resource.state.idSerializer(d[this.resource.state.idAttr as string]) === id) { | |||
return newItem; | |||
} | |||
@@ -1,3 +1,5 @@ | |||
export const encode = (str: string) => Buffer.from(str, 'utf-8'); | |||
export const decode = (buf: Buffer) => buf.toString('utf-8'); | |||
export const name = 'utf-8'; |
@@ -1,14 +1,19 @@ | |||
import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {Middleware} from './core'; | |||
import {getBody, getDeserializerObjects, getMethod, getUrl} from './utils'; | |||
import {getBody, getDeserializerObjects} from './utils'; | |||
import {IncomingMessage, ServerResponse} from 'http'; | |||
export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, res: ServerResponse) => { | |||
export const handleHasMethodAndUrl: Middleware = ({ | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
}) => (req: IncomingMessage, res: ServerResponse) => { | |||
if (!req.method) { | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE' | |||
'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed(); | |||
res.end(); | |||
return { | |||
handled: true | |||
@@ -17,6 +22,7 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, | |||
if (!req.url) { | |||
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | |||
res.statusMessage = errorMessageCollection.statusMessages.badRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
@@ -31,25 +37,68 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, | |||
export const handleGetRoot: Middleware = ({ | |||
appState, | |||
appParams, | |||
responseBodySerializerPair, | |||
serverParams, | |||
responseMediaType, | |||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
responseBodyEncoding: [encodingKey, encoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
}) => (_req: IncomingMessage, res: ServerResponse) => { | |||
const singleResDatum = { | |||
name: appParams.name | |||
}; | |||
const theFormatted = responseBodySerializerPair.serialize(singleResDatum); | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseMediaType, | |||
let serialized; | |||
try { | |||
serialized = responseBodySerializerPair.serialize(singleResDatum); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let theFormatted; | |||
try { | |||
theFormatted = encoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const theHeaders: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType, | |||
'Content-Language': languageCode, | |||
'Content-Encoding': encodingKey, | |||
}; | |||
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 | |||
'X-Resource-Link': Array.from(appState.resources) | |||
theHeaders['X-Resource-Link'] = availableResources | |||
.map((r) => | |||
`<${serverParams.baseUrl}/${r.routeName}>; name="${r.collectionName}"`, | |||
`<${serverParams.baseUrl}/${r.state.routeName}>; name="${r.state.collectionName}"`, | |||
) | |||
.join(', ') | |||
}); | |||
.join(', '); | |||
} | |||
res.writeHead(constants.HTTP_STATUS_OK, theHeaders); | |||
res.statusMessage = responseBodyMessageCollection.statusMessages.ok(); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
@@ -57,130 +106,242 @@ export const handleGetRoot: Middleware = ({ | |||
}; | |||
export const handleGetCollection: Middleware = ({ | |||
appState, | |||
serverParams, | |||
responseBodySerializerPair, | |||
responseMediaType, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const baseUrl = serverParams.baseUrl ?? ''; | |||
const { url } = getUrl(req, baseUrl); | |||
const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); | |||
if (mainResourceId !== '') { | |||
resource, | |||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
responseBodyEncoding: [encodingKey, encoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
backendState, | |||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: false | |||
} | |||
handled: true | |||
}; | |||
} | |||
const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); | |||
if (typeof theResource === 'undefined') { | |||
let resData: Object[]; | |||
let totalItemCount: number | undefined; | |||
try { | |||
// TODO querying mechanism | |||
resData = await resource.dataSource.getMultiple(); // TODO paginated responses per resource | |||
if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResourceCollection(resource); | |||
res.end(); | |||
return { | |||
handled: false | |||
handled: true | |||
}; | |||
} | |||
let serialized; | |||
try { | |||
await theResource.dataSource.initialize(); | |||
// TODO querying mechanism | |||
const resData = await theResource.dataSource.getMultiple(); // TODO paginated responses per resource | |||
const theFormatted = responseBodySerializerPair.serialize(resData); | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseMediaType, | |||
'X-Resource-Total-Item-Count': resData.length | |||
serialized = responseBodySerializerPair.serialize(resData); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.end(theFormatted); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let theFormatted; | |||
try { | |||
theFormatted = encoding.encode(serialized); | |||
} catch { | |||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
const headers: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType, | |||
'Content-Language': languageCode, | |||
'Content-Encoding': encodingKey, | |||
}; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
res.writeHead(constants.HTTP_STATUS_OK, headers); | |||
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCollectionFetched(resource); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
}; | |||
}; | |||
export const handleGetItem: Middleware = ({ | |||
appState, | |||
serverParams, | |||
responseBodySerializerPair, | |||
responseMediaType, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const method = getMethod(req); | |||
if (method !== 'GET') { | |||
resourceId, | |||
resource, | |||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
responseBodyEncoding: [encodingKey, encoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: false | |||
handled: true | |||
}; | |||
} | |||
const baseUrl = serverParams.baseUrl ?? ''; | |||
const { url } = getUrl(req, baseUrl); | |||
const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); | |||
if (mainResourceId === '') { | |||
let singleResDatum: Object | null = null; | |||
try { | |||
singleResDatum = await resource.dataSource.getSingle(resourceId); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource); | |||
res.end(); | |||
return { | |||
handled: false | |||
} | |||
handled: true | |||
}; | |||
} | |||
const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); | |||
if (typeof theResource === 'undefined') { | |||
let serialized: string | null; | |||
try { | |||
serialized = singleResDatum === null ? null : responseBodySerializerPair.serialize(singleResDatum); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: false | |||
handled: true, | |||
}; | |||
} | |||
let theFormatted; | |||
try { | |||
await theResource.dataSource.initialize(); | |||
const singleResDatum = await theResource.dataSource.getSingle(mainResourceId); | |||
if (singleResDatum) { | |||
const theFormatted = responseBodySerializerPair.serialize(singleResDatum); | |||
res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': responseMediaType}); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = `${theResource.itemName} Not Found`; | |||
theFormatted = serialized === null ? null : encoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true | |||
handled: true, | |||
}; | |||
} catch (err) { | |||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | |||
res.end(); | |||
} | |||
if (theFormatted) { | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseBodyMediaType, | |||
'Content-Language': languageCode, | |||
'Content-Encoding': encodingKey, | |||
}); | |||
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceFetched(resource) | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.resourceNotFound(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
}; | |||
export const handleDeleteItem: Middleware = ({ | |||
resource, | |||
resourceId, | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
backendState, | |||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | |||
try { | |||
await resource.dataSource.initialize(); | |||
const response = await resource.dataSource.delete(resourceId); | |||
if (typeof response !== 'undefined' && !response && resource.throws404OnDeletingNotFound) { | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = `${resource.itemName} Not Found`; | |||
} else { | |||
res.statusCode = constants.HTTP_STATUS_NO_CONTENT; | |||
} | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let response; | |||
try { | |||
response = await resource.dataSource.delete(resourceId); | |||
} catch { | |||
// TODO error handling | |||
// what if item is already deleted? Should we hide it by returning no content or throw a 404? | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
const throwOnNotFound = !response && backendState.throws404OnDeletingNotFound; | |||
res.writeHead( | |||
throwOnNotFound | |||
? constants.HTTP_STATUS_NOT_FOUND | |||
: constants.HTTP_STATUS_NO_CONTENT, | |||
throwOnNotFound | |||
? { | |||
'Content-Language': errorLanguageCode, | |||
// TODO provide more details | |||
} | |||
: { | |||
'Content-Language': languageCode, | |||
} | |||
); | |||
res.statusMessage = ( | |||
throwOnNotFound | |||
? errorMessageCollection.statusMessages.deleteNonExistingResource(resource) | |||
: responseBodyMessageCollection.statusMessages.resourceDeleted(resource) | |||
); | |||
if (throwOnNotFound) { | |||
// TODO provide error message | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | |||
res.end(); | |||
return { | |||
handled: true | |||
@@ -189,25 +350,59 @@ export const handleDeleteItem: Middleware = ({ | |||
export const handlePatchItem: Middleware = ({ | |||
appState, | |||
responseBodySerializerPair, | |||
responseMediaType, | |||
resource, | |||
resourceId, | |||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
responseBodyEncoding: [encodingKey, encoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], | |||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | |||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; | |||
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
let existing: object | null; | |||
try { | |||
existing = await resource.dataSource.getSingle(resourceId); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
await resource.dataSource.initialize(); | |||
const existing = await resource.dataSource.getSingle(resourceId); | |||
if (!existing) { | |||
res.statusCode = constants.HTTP_STATUS_NOT_FOUND; | |||
res.statusMessage = `${resource.itemName} Not Found`; | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.patchNonExistingResource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
@@ -231,54 +426,113 @@ export const handlePatchItem: Middleware = ({ | |||
); | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | |||
res.statusMessage = `Invalid ${resource.itemName}`; | |||
if (Array.isArray(err.issues)) { | |||
// TODO better error reporting, localizable messages | |||
const theFormatted = responseBodySerializerPair.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)) | |||
); | |||
res.end(theFormatted); | |||
} else { | |||
const headers: Record<string, string> = { | |||
'Content-Language': languageCode, | |||
}; | |||
if (!Array.isArray(err.issues)) { | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) | |||
res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
const serialized = errorSerializerPair.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
); | |||
const theFormatted = errorEncoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { | |||
...headers, | |||
'Content-Type': errorMediaType, | |||
'Content-Encoding': errorEncodingKey, | |||
}) | |||
res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
handled: true, | |||
}; | |||
} | |||
const params = bodyDeserialized as Record<string, unknown>; | |||
let newObject: object | null; | |||
try { | |||
const params = bodyDeserialized as Record<string, unknown>; | |||
await resource.dataSource.initialize(); | |||
const newObject = await resource.dataSource.patch(resourceId, params); | |||
const theFormatted = responseBodySerializerPair.serialize(newObject); | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseMediaType, | |||
newObject = await resource.dataSource.patch(resourceId, params); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.end(theFormatted); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToPatchResource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let serialized; | |||
try { | |||
serialized = responseBodySerializerPair.serialize(newObject); | |||
} catch { | |||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | |||
res.statusMessage = `Could Not Return ${resource.itemName}`; | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
let theFormatted; | |||
try { | |||
theFormatted = encoding.encode(serialized); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseBodyMediaType, | |||
'Content-Language': languageCode, | |||
'Content-Encoding': encodingKey, | |||
}); | |||
res.statusMessage = responseBodyMessageCollection.statusMessages.resourcePatched(resource); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
}; | |||
// TODO finish the rest of the handlers!!! | |||
}; | |||
export const handleCreateItem: Middleware = ({ | |||
appState, | |||
serverParams, | |||
responseMediaType, | |||
responseBodySerializerPair, | |||
backendState, | |||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
responseBodyEncoding: [encodingKey, encoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], | |||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||
resource, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | |||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; | |||
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
@@ -287,43 +541,87 @@ export const handleCreateItem: Middleware = ({ | |||
let bodyDeserialized: unknown; | |||
try { | |||
bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, resource.schema); | |||
bodyDeserialized = await getBody( | |||
req, | |||
requestBodyDeserializerPair, | |||
requestBodyEncodingPair, | |||
resource.schema | |||
); | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | |||
res.statusMessage = `Invalid ${resource.itemName}`; | |||
if (Array.isArray(err.issues)) { | |||
// TODO better error reporting, localizable messages | |||
const theFormatted = responseBodySerializerPair.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)) | |||
); | |||
res.end(theFormatted); | |||
} else { | |||
const headers: Record<string, string> = { | |||
'Content-Language': errorLanguageCode, | |||
}; | |||
if (!Array.isArray(err.issues)) { | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) | |||
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
const serialized = errorSerializerPair.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
); | |||
const theFormatted = errorEncoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { | |||
...headers, | |||
'Content-Type': errorMediaType, | |||
'Content-Encoding': errorEncodingKey, | |||
}) | |||
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
handled: true, | |||
}; | |||
} | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
try { | |||
// TODO error handling for each process | |||
const newId = await resource.newId(resource.dataSource); | |||
const params = bodyDeserialized as Record<string, unknown>; | |||
params[resource.idAttr] = newId; | |||
params[resource.state.idAttr] = newId; | |||
const newObject = await resource.dataSource.create(params); | |||
const theFormatted = responseBodySerializerPair.serialize(newObject); | |||
res.writeHead(constants.HTTP_STATUS_CREATED, { | |||
'Content-Type': responseMediaType, | |||
'Location': `${serverParams.baseUrl}/${resource.routeName}/${newId}` | |||
}); | |||
let totalItemCount: number | undefined; | |||
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
const headers: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType, | |||
'Content-Language': languageCode, | |||
'Content-Encoding': encodingKey, | |||
'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}` | |||
}; | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
const serialized = responseBodySerializerPair.serialize(newObject); | |||
const theFormatted = encoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_CREATED, headers); | |||
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCreated(resource); | |||
res.end(theFormatted); | |||
} catch { | |||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | |||
res.statusMessage = `Could Not Return ${resource.itemName}`; | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}) | |||
res.statusMessage = `Could Not Return ${resource.state.itemName}`; | |||
res.end(); | |||
} | |||
return { | |||
@@ -334,14 +632,22 @@ export const handleCreateItem: Middleware = ({ | |||
export const handleEmplaceItem: Middleware = ({ | |||
appState, | |||
serverParams, | |||
responseBodySerializerPair, | |||
responseMediaType, | |||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||
responseBodyEncoding: [encodingKey, encoding], | |||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], | |||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||
resource, | |||
resourceId, | |||
backendState, | |||
}) => async (req: IncomingMessage, res: ServerResponse) => { | |||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | |||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | |||
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; | |||
res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest(); | |||
res.end(); | |||
return { | |||
handled: true | |||
@@ -351,7 +657,6 @@ export const handleEmplaceItem: Middleware = ({ | |||
let bodyDeserialized: unknown; | |||
try { | |||
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema | |||
//console.log(schema); | |||
bodyDeserialized = await getBody( | |||
req, | |||
requestBodyDeserializerPair, | |||
@@ -360,9 +665,9 @@ export const handleEmplaceItem: Middleware = ({ | |||
? v.merge([ | |||
schema as v.ObjectSchema<any>, | |||
v.object({ | |||
[resource.idAttr]: v.transform( | |||
[resource.state.idAttr]: v.transform( | |||
v.any(), | |||
input => resource.idSerializer(input), | |||
input => resource.state.idSerializer(input), | |||
v.literal(resourceId) | |||
) | |||
}) | |||
@@ -371,44 +676,81 @@ export const handleEmplaceItem: Middleware = ({ | |||
); | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | |||
res.statusMessage = `Invalid ${resource.itemName}`; | |||
if (Array.isArray(err.issues)) { | |||
// TODO better error reporting, localizable messages | |||
const theFormatted = responseBodySerializerPair.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)) | |||
); | |||
res.end(theFormatted); | |||
} else { | |||
const headers: Record<string, string> = { | |||
'Content-Language': errorLanguageCode, | |||
}; | |||
if (!Array.isArray(err.issues)) { | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) | |||
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); | |||
res.end(); | |||
return { | |||
handled: true, | |||
}; | |||
} | |||
// TODO better error reporting, localizable messages | |||
// TODO handle error handlers' errors | |||
const serialized = errorSerializerPair.serialize( | |||
err.issues.map((i) => ( | |||
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` | |||
)), | |||
); | |||
const theFormatted = errorEncoding.encode(serialized); | |||
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { | |||
...headers, | |||
'Content-Type': errorMediaType, | |||
'Content-Encoding': errorEncodingKey, | |||
}) | |||
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); | |||
res.end(theFormatted); | |||
return { | |||
handled: true | |||
handled: true, | |||
}; | |||
} | |||
try { | |||
await resource.dataSource.initialize(); | |||
} catch { | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { | |||
'Content-Language': errorLanguageCode, | |||
}); | |||
res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); | |||
res.end(); | |||
return { | |||
handled: true | |||
}; | |||
} | |||
try { | |||
// TODO error handling for each process | |||
const params = bodyDeserialized as Record<string, unknown>; | |||
const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); | |||
const theFormatted = responseBodySerializerPair.serialize(newObject); | |||
const serialized = responseBodySerializerPair.serialize(newObject); | |||
const theFormatted = encoding.encode(serialized); | |||
const headers: Record<string, string> = { | |||
'Content-Type': responseBodyMediaType, | |||
'Content-Language': languageCode, | |||
'Content-Encoding': encodingKey, | |||
}; | |||
let totalItemCount: number | undefined; | |||
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
} | |||
if (isCreated) { | |||
res.writeHead(constants.HTTP_STATUS_CREATED, { | |||
'Content-Type': responseMediaType, | |||
'Location': `${serverParams.baseUrl}/${resource.routeName}/${resourceId}` | |||
}); | |||
} else { | |||
res.writeHead(constants.HTTP_STATUS_OK, { | |||
'Content-Type': responseMediaType, | |||
}); | |||
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 | |||
? responseBodyMessageCollection.statusMessages.resourceCreated(resource) | |||
: responseBodyMessageCollection.statusMessages.resourceReplaced(resource) | |||
); | |||
res.end(theFormatted); | |||
} catch { | |||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | |||
res.statusMessage = `Could Not Return ${resource.itemName}`; | |||
res.statusMessage = `Could Not Return ${resource.state.itemName}`; | |||
res.end(); | |||
} | |||
return { | |||
@@ -0,0 +1,97 @@ | |||
import {MessageCollection, Resource} from '../../core'; | |||
export const messages: MessageCollection = { | |||
statusMessages: { | |||
unableToSerializeResponse(): string { | |||
return 'Unable To Serialize Response'; | |||
}, | |||
unableToEncodeResponse(): string { | |||
return 'Unable To Encode Response'; | |||
}, | |||
unableToInitializeResourceDataSource(resource: Resource): string { | |||
return `Unable To Initialize ${resource.state.itemName} Data Source`; | |||
}, | |||
unableToFetchResourceCollection(resource: Resource): string { | |||
return `Unable To Fetch ${resource.state.itemName} Collection`; | |||
}, | |||
unableToFetchResource(resource: Resource): string { | |||
return `Unable To Fetch ${resource.state.itemName}`; | |||
}, | |||
unableToDeleteResource(resource: Resource): string { | |||
return `Unable To Delete ${resource.state.itemName}`; | |||
}, | |||
languageNotAcceptable(): string { | |||
return 'Language Not Acceptable'; | |||
}, | |||
encodingNotAcceptable(): string { | |||
return 'Encoding Not Acceptable'; | |||
}, | |||
mediaTypeNotAcceptable(): string { | |||
return 'Media Type Not Acceptable'; | |||
}, | |||
methodNotAllowed(): string { | |||
return 'Method Not Allowed'; | |||
}, | |||
urlNotFound(): string { | |||
return 'URL Not Found'; | |||
}, | |||
badRequest(): string { | |||
return 'Bad Request'; | |||
}, | |||
ok(): string { | |||
return 'OK'; | |||
}, | |||
resourceCollectionFetched(resource: Resource): string { | |||
return `${resource.state.itemName} Collection Fetched`; | |||
}, | |||
resourceFetched(resource: Resource): string { | |||
return `${resource.state.itemName} Fetched`; | |||
}, | |||
resourceNotFound(resource: Resource): string { | |||
return `${resource.state.itemName} Not Found`; | |||
}, | |||
deleteNonExistingResource(resource: Resource): string { | |||
return `Delete Non-Existing ${resource.state.itemName}`; | |||
}, | |||
resourceDeleted(resource: Resource): string { | |||
return `${resource.state.itemName} Deleted`; | |||
}, | |||
unableToDeserializeRequest(): string { | |||
return 'Unable To Deserialize Request'; | |||
}, | |||
patchNonExistingResource(resource: Resource): string { | |||
return `Patch Non-Existing ${resource.state.itemName}`; | |||
}, | |||
unableToPatchResource(resource: Resource): string { | |||
return `Unable To Patch ${resource.state.itemName}`; | |||
}, | |||
invalidResourcePatch(resource: Resource): string { | |||
return `Invalid ${resource.state.itemName} Patch`; | |||
}, | |||
invalidResource(resource: Resource): string { | |||
return `Invalid ${resource.state.itemName}`; | |||
}, | |||
resourcePatched(resource: Resource): string { | |||
return `${resource.state.itemName} Patched`; | |||
}, | |||
resourceCreated(resource: Resource): string { | |||
return `${resource.state.itemName} Created`; | |||
}, | |||
resourceReplaced(resource: Resource): string { | |||
return `${resource.state.itemName} Replaced`; | |||
} | |||
}, | |||
bodies: { | |||
languageNotAcceptable() { | |||
return []; | |||
}, | |||
encodingNotAcceptable() { | |||
return []; | |||
}, | |||
mediaTypeNotAcceptable() { | |||
return [] | |||
} | |||
} | |||
}; | |||
export const code = 'en'; |
@@ -1,3 +1,5 @@ | |||
export const serialize = (obj: unknown) => JSON.stringify(obj); | |||
export const deserialize = (str: string) => JSON.parse(str); | |||
export const name = 'application/json'; |
@@ -96,7 +96,11 @@ describe('yasumi', () => { | |||
.encoding(ACCEPT_ENCODING, encodings.utf8) | |||
.resource(Piano); | |||
server = app.createServer({ | |||
const backend = app | |||
.createBackend() | |||
.throws404OnDeletingNotFound(); | |||
server = backend.createServer({ | |||
baseUrl: '/api' | |||
}); | |||
@@ -126,10 +130,16 @@ describe('yasumi', () => { | |||
})); | |||
describe('serving collections', () => { | |||
beforeEach(() => { | |||
Piano.canFetchCollection(); | |||
}); | |||
afterEach(() => { | |||
Piano.canFetchCollection(false); | |||
}); | |||
it('returns data', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowFetchCollection(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
@@ -143,7 +153,6 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeFetchCollection(); | |||
reject(err); | |||
}); | |||
@@ -159,14 +168,12 @@ describe('yasumi', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual([]); | |||
Piano.revokeFetchCollection(); | |||
resolve(); | |||
}); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeFetchCollection(); | |||
reject(err); | |||
}); | |||
@@ -186,10 +193,16 @@ describe('yasumi', () => { | |||
await writeFile(resourcePath, JSON.stringify(data)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canFetchItem(); | |||
}); | |||
afterEach(() => { | |||
Piano.canFetchItem(false); | |||
}); | |||
it('returns data', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowFetchItem(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
@@ -203,7 +216,6 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeFetchItem(); | |||
reject(err); | |||
}); | |||
@@ -219,14 +231,12 @@ describe('yasumi', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual(data); | |||
Piano.revokeFetchItem(); | |||
resolve(); | |||
}); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeFetchItem(); | |||
reject(err); | |||
}); | |||
@@ -236,8 +246,6 @@ describe('yasumi', () => { | |||
it('throws on item not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowFetchItem(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
@@ -251,19 +259,16 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeFetchItem(); | |||
Piano.canFetchItem(false); | |||
reject(err); | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
Piano.revokeFetchItem(); | |||
resolve(); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeFetchItem(); | |||
reject(err); | |||
}); | |||
@@ -287,11 +292,17 @@ describe('yasumi', () => { | |||
await writeFile(resourcePath, JSON.stringify(data)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canCreate(); | |||
}); | |||
afterEach(() => { | |||
Piano.canCreate(false); | |||
}); | |||
// FIXME ID de/serialization problems | |||
it('returns data', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowCreate(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
@@ -306,7 +317,6 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeCreate(); | |||
reject(err); | |||
}); | |||
@@ -323,16 +333,15 @@ describe('yasumi', () => { | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual({ | |||
...newData, | |||
id: '2' | |||
id: 2 | |||
}); | |||
Piano.revokeCreate(); | |||
resolve(); | |||
}); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeCreate(); | |||
reject(err); | |||
}); | |||
@@ -357,15 +366,21 @@ describe('yasumi', () => { | |||
await writeFile(resourcePath, JSON.stringify(data)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canPatch(); | |||
}); | |||
afterEach(() => { | |||
Piano.canPatch(false); | |||
}); | |||
it('returns data', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowPatch(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: '/api/pianos/1', | |||
path: `/api/pianos/${data.id}`, | |||
method: 'PATCH', | |||
headers: { | |||
'Accept': ACCEPT, | |||
@@ -375,7 +390,6 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokePatch(); | |||
reject(err); | |||
}); | |||
@@ -394,14 +408,12 @@ describe('yasumi', () => { | |||
...data, | |||
...newData, | |||
}); | |||
Piano.revokePatch(); | |||
resolve(); | |||
}); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokePatch(); | |||
reject(err); | |||
}); | |||
@@ -412,8 +424,6 @@ describe('yasumi', () => { | |||
it('throws on item to patch not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowPatch(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
@@ -428,19 +438,15 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokePatch(); | |||
reject(err); | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
Piano.revokePatch(); | |||
resolve(); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokePatch(); | |||
reject(err); | |||
}); | |||
@@ -466,16 +472,22 @@ describe('yasumi', () => { | |||
await writeFile(resourcePath, JSON.stringify(data)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canEmplace(); | |||
}); | |||
afterEach(() => { | |||
Piano.canEmplace(false); | |||
}); | |||
// FIXME IDs not properly being de/serialized | |||
it('returns data for replacement', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowEmplace(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: '/api/pianos/1', | |||
path: `/api/pianos/${newData.id}`, | |||
method: 'PUT', | |||
headers: { | |||
'Accept': ACCEPT, | |||
@@ -485,7 +497,6 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeEmplace(); | |||
reject(err); | |||
}); | |||
@@ -501,14 +512,12 @@ describe('yasumi', () => { | |||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | |||
const resData = JSON.parse(resBufferJson); | |||
expect(resData).toEqual(newData); | |||
Piano.revokeEmplace(); | |||
resolve(); | |||
}); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeEmplace(); | |||
reject(err); | |||
}); | |||
@@ -520,7 +529,6 @@ describe('yasumi', () => { | |||
it('returns data for creation', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
const id = 2; | |||
Piano.allowEmplace(); | |||
const req = request( | |||
{ | |||
@@ -536,7 +544,6 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeEmplace(); | |||
reject(err); | |||
}); | |||
@@ -555,14 +562,12 @@ describe('yasumi', () => { | |||
...newData, | |||
id, | |||
}); | |||
Piano.revokeEmplace(); | |||
resolve(); | |||
}); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeEmplace(); | |||
reject(err); | |||
}); | |||
@@ -586,15 +591,21 @@ describe('yasumi', () => { | |||
await writeFile(resourcePath, JSON.stringify(data)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canDelete(); | |||
}); | |||
afterEach(() => { | |||
Piano.canDelete(false); | |||
}); | |||
it('returns data', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowDelete(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: '/api/pianos/1', | |||
path: `/api/pianos/${data.id}`, | |||
method: 'DELETE', | |||
headers: { | |||
'Accept': ACCEPT, | |||
@@ -603,18 +614,15 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeDelete(); | |||
reject(err); | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
Piano.revokeDelete(); | |||
resolve(); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeDelete(); | |||
reject(err); | |||
}); | |||
@@ -624,8 +632,6 @@ describe('yasumi', () => { | |||
it('throws on item not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
Piano.allowDelete(); | |||
const req = request( | |||
{ | |||
host: HOST, | |||
@@ -639,18 +645,15 @@ describe('yasumi', () => { | |||
}, | |||
(res) => { | |||
res.on('error', (err) => { | |||
Piano.revokeDelete(); | |||
reject(err); | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
Piano.revokeDelete(); | |||
resolve(); | |||
}, | |||
); | |||
req.on('error', (err) => { | |||
Piano.revokeDelete(); | |||
reject(err); | |||
}); | |||