@@ -9,10 +9,10 @@ export const autoIncrement = async (dataSource: DataSource) => { | |||||
); | ); | ||||
if (Number.isFinite(highestId)) { | 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'); | 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', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 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( | const User = resource(v.object( | ||||
{ | { | ||||
@@ -56,7 +56,9 @@ const app = application({ | |||||
.resource(Piano) | .resource(Piano) | ||||
.resource(User); | .resource(User); | ||||
const server = app.createServer({ | |||||
const backend = app.createBackend(); | |||||
const server = backend.createServer({ | |||||
baseUrl: '/api' | baseUrl: '/api' | ||||
}); | }); | ||||
@@ -65,8 +67,8 @@ server.listen(3000); | |||||
setTimeout(() => { | setTimeout(() => { | ||||
// Allow user operations after 5 seconds from startup | // Allow user operations after 5 seconds from startup | ||||
User | User | ||||
.allowFetchItem() | |||||
.allowFetchCollection() | |||||
.allowCreate() | |||||
.allowPatch(); | |||||
.canFetchItem() | |||||
.canFetchCollection() | |||||
.canCreate() | |||||
.canPatch(); | |||||
}, 5000); | }, 5000); |
@@ -14,9 +14,17 @@ import { | |||||
import Negotiator from 'negotiator'; | import Negotiator from 'negotiator'; | ||||
import {getMethod, getUrl} from './utils'; | import {getMethod, getUrl} from './utils'; | ||||
import {EncodingPair} from './encodings'; | 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> { | export interface DataSource<T = object> { | ||||
initialize(): Promise<unknown>; | initialize(): Promise<unknown>; | ||||
getTotalCount?(): Promise<number>; | |||||
getMultiple(): Promise<T[]>; | getMultiple(): Promise<T[]>; | ||||
getSingle(id: string): Promise<T | null>; | getSingle(id: string): Promise<T | null>; | ||||
create(data: Partial<T>): Promise<T>; | create(data: Partial<T>): Promise<T>; | ||||
@@ -30,52 +38,36 @@ export interface ApplicationParams { | |||||
dataSource?: (resource: Resource) => DataSource; | 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; | id(newIdAttr: string, params: IdParams): this; | ||||
fullText(fullTextAttr: string): this; | fullText(fullTextAttr: string): this; | ||||
name(n: string): this; | name(n: string): this; | ||||
collection(n: string): this; | collection(n: string): this; | ||||
route(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> { | export interface ResourceWithDataSource<T extends BaseSchema = any> extends Resource<T> { | ||||
dataSource: DataSource; | dataSource: DataSource; | ||||
} | } | ||||
@@ -93,145 +85,98 @@ interface IdParams { | |||||
const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { | const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { | ||||
const middlewares = [] as [string, Middleware][]; | const middlewares = [] as [string, Middleware][]; | ||||
if (mainResourceId === '') { | if (mainResourceId === '') { | ||||
if (resource.canFetchCollection) { | |||||
if (resource.state.canFetchCollection) { | |||||
middlewares.push(['GET', handleGetCollection]); | middlewares.push(['GET', handleGetCollection]); | ||||
} | } | ||||
if (resource.canCreate) { | |||||
if (resource.state.canCreate) { | |||||
middlewares.push(['POST', handleCreateItem]); | middlewares.push(['POST', handleCreateItem]); | ||||
} | } | ||||
return middlewares; | return middlewares; | ||||
} | } | ||||
if (resource.canFetchItem) { | |||||
if (resource.state.canFetchItem) { | |||||
middlewares.push(['GET', handleGetItem]); | middlewares.push(['GET', handleGetItem]); | ||||
} | } | ||||
if (resource.canEmplace) { | |||||
if (resource.state.canEmplace) { | |||||
middlewares.push(['PUT', handleEmplaceItem]); | middlewares.push(['PUT', handleEmplaceItem]); | ||||
} | } | ||||
if (resource.canPatch) { | |||||
if (resource.state.canPatch) { | |||||
middlewares.push(['PATCH', handlePatchItem]); | middlewares.push(['PATCH', handlePatchItem]); | ||||
} | } | ||||
if (resource.canDelete) { | |||||
if (resource.state.canDelete) { | |||||
middlewares.push(['DELETE', handleDeleteItem]); | middlewares.push(['DELETE', handleDeleteItem]); | ||||
} | } | ||||
return middlewares; | 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> => { | 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 { | 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; | return this; | ||||
}, | }, | ||||
revokeCreate() { | |||||
canCreate = false; | |||||
canFetchItem(b = true) { | |||||
resourceState.canFetchItem = b; | |||||
return this; | return this; | ||||
}, | }, | ||||
revokePatch() { | |||||
canPatch = false; | |||||
canCreate(b = true) { | |||||
resourceState.canCreate = b; | |||||
return this; | return this; | ||||
}, | }, | ||||
revokeEmplace() { | |||||
canEmplace = false; | |||||
canPatch(b = true) { | |||||
resourceState.canPatch = b; | |||||
return this; | 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; | return this; | ||||
}, | }, | ||||
get checksSerializersOnDelete() { | |||||
return checkSerializersOnDelete; | |||||
}, | |||||
shouldThrow404OnDeletingNotFound(b = true) { | |||||
throw404OnDeletingNotFound = b; | |||||
canDelete(b = true) { | |||||
resourceState.canDelete = b; | |||||
return this; | return this; | ||||
}, | }, | ||||
get throws404OnDeletingNotFound() { | |||||
return throw404OnDeletingNotFound; | |||||
}, | |||||
get idSerializer() { | |||||
return theIdSerializer; | |||||
}, | |||||
get idDeserializer() { | |||||
return theIdDeserializer; | |||||
}, | |||||
id(newIdAttr: string, params: IdParams) { | 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; | return this; | ||||
}, | }, | ||||
newId(dataSource: DataSource) { | newId(dataSource: DataSource) { | ||||
return theIdGenerationStrategy(dataSource); | |||||
return resourceState?.idGenerationStrategy?.(dataSource); | |||||
}, | }, | ||||
fullText(fullTextAttr: string) { | fullText(fullTextAttr: string) { | ||||
if ( | if ( | ||||
@@ -245,38 +190,38 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => { | |||||
) | ) | ||||
.entries[fullTextAttr]?.type === 'string' | .entries[fullTextAttr]?.type === 'string' | ||||
) { | ) { | ||||
fullTextAttrs.add(fullTextAttr); | |||||
resourceState.fullTextAttrs?.add(fullTextAttr); | |||||
return this; | return this; | ||||
} | } | ||||
throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); | throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); | ||||
}, | }, | ||||
name(n: string) { | 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; | return this; | ||||
}, | }, | ||||
collection(n: string) { | collection(n: string) { | ||||
theCollectionName = n; | |||||
theRouteName = theRouteName ?? theCollectionName; | |||||
resourceState.collectionName = n; | |||||
resourceState.routeName = resourceState.routeName ?? n; | |||||
return this; | return this; | ||||
}, | }, | ||||
route(n: string) { | route(n: string) { | ||||
theRouteName = n; | |||||
resourceState.routeName = n; | |||||
return this; | return this; | ||||
}, | }, | ||||
get idAttr() { | get idAttr() { | ||||
return theIdAttr; | |||||
return resourceState.idAttr; | |||||
}, | }, | ||||
get collectionName() { | get collectionName() { | ||||
return theCollectionName; | |||||
return resourceState.collectionName; | |||||
}, | }, | ||||
get itemName() { | get itemName() { | ||||
return theItemName; | |||||
return resourceState.itemName; | |||||
}, | }, | ||||
get routeName() { | get routeName() { | ||||
return theRouteName; | |||||
return resourceState.routeName; | |||||
}, | }, | ||||
get schema() { | get schema() { | ||||
return schema; | return schema; | ||||
@@ -306,18 +251,78 @@ interface HandlerState { | |||||
export interface ApplicationState { | export interface ApplicationState { | ||||
resources: Set<ResourceWithDataSource>; | resources: Set<ResourceWithDataSource>; | ||||
languages: Map<string, MessageCollection>; | |||||
serializers: Map<string, SerializerPair>; | serializers: Map<string, SerializerPair>; | ||||
encodings: Map<string, EncodingPair>; | 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 { | interface MiddlewareArgs { | ||||
handlerState: HandlerState; | handlerState: HandlerState; | ||||
backendState: BackendState; | |||||
appState: ApplicationState; | appState: ApplicationState; | ||||
appParams: ApplicationParams; | appParams: ApplicationParams; | ||||
serverParams: CreateServerParams; | 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; | resource: ResourceWithDataSource; | ||||
resourceId: string; | resourceId: string; | ||||
query: URLSearchParams; | query: URLSearchParams; | ||||
@@ -327,20 +332,41 @@ export interface Middleware { | |||||
(args: MiddlewareArgs): RequestListenerWithReturn<HandlerState | Promise<HandlerState>> | (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 { | export interface Application { | ||||
contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this; | contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this; | ||||
language(languageCode: string, messageCollection: MessageCollection): this; | |||||
encoding(encoding: string, encodingPair: EncodingPair): this; | encoding(encoding: string, encodingPair: EncodingPair): this; | ||||
resource(resRaw: Partial<Resource>): this; | resource(resRaw: Partial<Resource>): this; | ||||
createServer(serverParams?: CreateServerParams): http.Server | https.Server; | |||||
createBackend(): Backend; | |||||
createClient(): Client; | |||||
} | } | ||||
export const application = (appParams: ApplicationParams): Application => { | export const application = (appParams: ApplicationParams): Application => { | ||||
const appState: ApplicationState = { | const appState: ApplicationState = { | ||||
resources: new Set<ResourceWithDataSource>(), | resources: new Set<ResourceWithDataSource>(), | ||||
languages: new Map<string, MessageCollection>(), | |||||
serializers: new Map<string, SerializerPair>(), | 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 { | return { | ||||
contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { | contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { | ||||
appState.serializers.set(mimeTypePrefix, serializerPair); | appState.serializers.set(mimeTypePrefix, serializerPair); | ||||
@@ -350,148 +376,253 @@ export const application = (appParams: ApplicationParams): Application => { | |||||
appState.encodings.set(encoding, encodingPair); | appState.encodings.set(encoding, encodingPair); | ||||
return this; | return this; | ||||
}, | }, | ||||
language(languageCode: string, messageCollection: MessageCollection) { | |||||
appState.languages.set(languageCode, messageCollection); | |||||
return this; | |||||
}, | |||||
resource(resRaw: Partial<Resource>) { | resource(resRaw: Partial<Resource>) { | ||||
const res = resRaw as Partial<ResourceWithDataSource>; | const res = resRaw as Partial<ResourceWithDataSource>; | ||||
res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource); | res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource); | ||||
if (typeof res.dataSource === 'undefined') { | 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); | appState.resources.add(res as ResourceWithDataSource); | ||||
return this; | 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; | 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[] = []; | data: T[] = []; | ||||
constructor(private readonly resource: Resource, baseDir = '') { | 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() { | async initialize() { | ||||
@@ -21,12 +21,16 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
} | } | ||||
} | } | ||||
async getTotalCount() { | |||||
return this.data.length; | |||||
} | |||||
async getMultiple() { | async getMultiple() { | ||||
return [...this.data]; | return [...this.data]; | ||||
} | } | ||||
async getSingle(id: string) { | 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) { | if (foundData) { | ||||
return { | return { | ||||
@@ -42,8 +46,8 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
...data | ...data | ||||
} as Record<string, unknown>; | } 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 = [ | const newCollection = [ | ||||
@@ -59,7 +63,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
async delete(id: string) { | async delete(id: string) { | ||||
const oldDataLength = this.data.length; | 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')); | 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 existing = await this.getSingle(id); | ||||
const dataToEmplace = { | const dataToEmplace = { | ||||
...data, | ...data, | ||||
[this.resource.idAttr]: this.resource.idDeserializer(id), | |||||
[this.resource.state.idAttr]: this.resource.state.idDeserializer(id), | |||||
}; | }; | ||||
if (existing) { | if (existing) { | ||||
const newData = this.data.map((d) => { | 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; | return dataToEmplace; | ||||
} | } | ||||
@@ -104,7 +108,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
} | } | ||||
const newData = this.data.map((d) => { | 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; | return newItem; | ||||
} | } | ||||
@@ -1,3 +1,5 @@ | |||||
export const encode = (str: string) => Buffer.from(str, 'utf-8'); | export const encode = (str: string) => Buffer.from(str, 'utf-8'); | ||||
export const decode = (buf: Buffer) => buf.toString('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 { constants } from 'http2'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Middleware} from './core'; | import {Middleware} from './core'; | ||||
import {getBody, getDeserializerObjects, getMethod, getUrl} from './utils'; | |||||
import {getBody, getDeserializerObjects} from './utils'; | |||||
import {IncomingMessage, ServerResponse} from 'http'; | 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) { | if (!req.method) { | ||||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -17,6 +22,7 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, | |||||
if (!req.url) { | if (!req.url) { | ||||
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; | ||||
res.statusMessage = errorMessageCollection.statusMessages.badRequest(); | |||||
res.end(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -31,25 +37,68 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, | |||||
export const handleGetRoot: Middleware = ({ | export const handleGetRoot: Middleware = ({ | ||||
appState, | appState, | ||||
appParams, | appParams, | ||||
responseBodySerializerPair, | |||||
serverParams, | serverParams, | ||||
responseMediaType, | |||||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||||
responseBodyEncoding: [encodingKey, encoding], | |||||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||||
}) => (_req: IncomingMessage, res: ServerResponse) => { | }) => (_req: IncomingMessage, res: ServerResponse) => { | ||||
const singleResDatum = { | const singleResDatum = { | ||||
name: appParams.name | 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 | // we are using custom headers for links because the standard Link header | ||||
// is referring to the document metadata (e.g. author, next page, etc) | // is referring to the document metadata (e.g. author, next page, etc) | ||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link | // 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) => | .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); | res.end(theFormatted); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -57,130 +106,242 @@ export const handleGetRoot: Middleware = ({ | |||||
}; | }; | ||||
export const handleGetCollection: 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 { | 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 { | return { | ||||
handled: false | |||||
handled: true | |||||
}; | }; | ||||
} | } | ||||
let serialized; | |||||
try { | 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 { | } 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(); | 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 { | return { | ||||
handled: true | handled: true | ||||
}; | }; | ||||
}; | }; | ||||
export const handleGetItem: Middleware = ({ | 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 { | 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 { | 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 { | return { | ||||
handled: false | |||||
handled: true, | |||||
}; | }; | ||||
} | } | ||||
let theFormatted; | |||||
try { | 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(); | res.end(); | ||||
return { | 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 { | return { | ||||
handled: true | 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 = ({ | export const handleDeleteItem: Middleware = ({ | ||||
resource, | resource, | ||||
resourceId, | resourceId, | ||||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||||
backendState, | |||||
}) => async (_req: IncomingMessage, res: ServerResponse) => { | }) => async (_req: IncomingMessage, res: ServerResponse) => { | ||||
try { | try { | ||||
await resource.dataSource.initialize(); | 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
}; | }; | ||||
} | |||||
let response; | |||||
try { | |||||
response = await resource.dataSource.delete(resourceId); | |||||
} catch { | } 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -189,25 +350,59 @@ export const handleDeleteItem: Middleware = ({ | |||||
export const handlePatchItem: Middleware = ({ | export const handlePatchItem: Middleware = ({ | ||||
appState, | appState, | ||||
responseBodySerializerPair, | |||||
responseMediaType, | |||||
resource, | resource, | ||||
resourceId, | resourceId, | ||||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||||
responseBodyEncoding: [encodingKey, encoding], | |||||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||||
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], | |||||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||||
}) => async (req: IncomingMessage, res: ServerResponse) => { | }) => async (req: IncomingMessage, res: ServerResponse) => { | ||||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | ||||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
}; | }; | ||||
} | } | ||||
await resource.dataSource.initialize(); | |||||
const existing = await resource.dataSource.getSingle(resourceId); | |||||
if (!existing) { | 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -231,54 +426,113 @@ export const handlePatchItem: Middleware = ({ | |||||
); | ); | ||||
} catch (errRaw) { | } catch (errRaw) { | ||||
const err = errRaw as v.ValiError; | 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(); | 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 { | return { | ||||
handled: true | |||||
handled: true, | |||||
}; | }; | ||||
} | } | ||||
const params = bodyDeserialized as Record<string, unknown>; | |||||
let newObject: object | null; | |||||
try { | 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 { | } 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(); | 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 { | return { | ||||
handled: true | handled: true | ||||
}; | }; | ||||
// TODO finish the rest of the handlers!!! | |||||
}; | }; | ||||
export const handleCreateItem: Middleware = ({ | export const handleCreateItem: Middleware = ({ | ||||
appState, | appState, | ||||
serverParams, | serverParams, | ||||
responseMediaType, | |||||
responseBodySerializerPair, | |||||
backendState, | |||||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||||
responseBodyEncoding: [encodingKey, encoding], | |||||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||||
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], | |||||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||||
resource, | resource, | ||||
}) => async (req: IncomingMessage, res: ServerResponse) => { | }) => async (req: IncomingMessage, res: ServerResponse) => { | ||||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | ||||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -287,43 +541,87 @@ export const handleCreateItem: Middleware = ({ | |||||
let bodyDeserialized: unknown; | let bodyDeserialized: unknown; | ||||
try { | try { | ||||
bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, resource.schema); | |||||
bodyDeserialized = await getBody( | |||||
req, | |||||
requestBodyDeserializerPair, | |||||
requestBodyEncodingPair, | |||||
resource.schema | |||||
); | |||||
} catch (errRaw) { | } catch (errRaw) { | ||||
const err = errRaw as v.ValiError; | 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(); | 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 { | return { | ||||
handled: true | |||||
handled: true, | |||||
}; | }; | ||||
} | } | ||||
try { | try { | ||||
await resource.dataSource.initialize(); | 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 newId = await resource.newId(resource.dataSource); | ||||
const params = bodyDeserialized as Record<string, unknown>; | const params = bodyDeserialized as Record<string, unknown>; | ||||
params[resource.idAttr] = newId; | |||||
params[resource.state.idAttr] = newId; | |||||
const newObject = await resource.dataSource.create(params); | 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); | res.end(theFormatted); | ||||
} catch { | } 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(); | res.end(); | ||||
} | } | ||||
return { | return { | ||||
@@ -334,14 +632,22 @@ export const handleCreateItem: Middleware = ({ | |||||
export const handleEmplaceItem: Middleware = ({ | export const handleEmplaceItem: Middleware = ({ | ||||
appState, | appState, | ||||
serverParams, | serverParams, | ||||
responseBodySerializerPair, | |||||
responseMediaType, | |||||
responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], | |||||
responseBodyLanguage: [languageCode, responseBodyMessageCollection], | |||||
responseBodyEncoding: [encodingKey, encoding], | |||||
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], | |||||
errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], | |||||
errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], | |||||
resource, | resource, | ||||
resourceId, | resourceId, | ||||
backendState, | |||||
}) => async (req: IncomingMessage, res: ServerResponse) => { | }) => async (req: IncomingMessage, res: ServerResponse) => { | ||||
const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); | ||||
if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { | 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(); | res.end(); | ||||
return { | return { | ||||
handled: true | handled: true | ||||
@@ -351,7 +657,6 @@ export const handleEmplaceItem: Middleware = ({ | |||||
let bodyDeserialized: unknown; | let bodyDeserialized: unknown; | ||||
try { | try { | ||||
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema | const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema | ||||
//console.log(schema); | |||||
bodyDeserialized = await getBody( | bodyDeserialized = await getBody( | ||||
req, | req, | ||||
requestBodyDeserializerPair, | requestBodyDeserializerPair, | ||||
@@ -360,9 +665,9 @@ export const handleEmplaceItem: Middleware = ({ | |||||
? v.merge([ | ? v.merge([ | ||||
schema as v.ObjectSchema<any>, | schema as v.ObjectSchema<any>, | ||||
v.object({ | v.object({ | ||||
[resource.idAttr]: v.transform( | |||||
[resource.state.idAttr]: v.transform( | |||||
v.any(), | v.any(), | ||||
input => resource.idSerializer(input), | |||||
input => resource.state.idSerializer(input), | |||||
v.literal(resourceId) | v.literal(resourceId) | ||||
) | ) | ||||
}) | }) | ||||
@@ -371,44 +676,81 @@ export const handleEmplaceItem: Middleware = ({ | |||||
); | ); | ||||
} catch (errRaw) { | } catch (errRaw) { | ||||
const err = errRaw as v.ValiError; | 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(); | 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 { | return { | ||||
handled: true | |||||
handled: true, | |||||
}; | }; | ||||
} | } | ||||
try { | try { | ||||
await resource.dataSource.initialize(); | 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 params = bodyDeserialized as Record<string, unknown>; | ||||
const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); | 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) { | 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); | res.end(theFormatted); | ||||
} catch { | } catch { | ||||
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; | 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(); | res.end(); | ||||
} | } | ||||
return { | 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 serialize = (obj: unknown) => JSON.stringify(obj); | ||||
export const deserialize = (str: string) => JSON.parse(str); | export const deserialize = (str: string) => JSON.parse(str); | ||||
export const name = 'application/json'; |
@@ -96,7 +96,11 @@ describe('yasumi', () => { | |||||
.encoding(ACCEPT_ENCODING, encodings.utf8) | .encoding(ACCEPT_ENCODING, encodings.utf8) | ||||
.resource(Piano); | .resource(Piano); | ||||
server = app.createServer({ | |||||
const backend = app | |||||
.createBackend() | |||||
.throws404OnDeletingNotFound(); | |||||
server = backend.createServer({ | |||||
baseUrl: '/api' | baseUrl: '/api' | ||||
}); | }); | ||||
@@ -126,10 +130,16 @@ describe('yasumi', () => { | |||||
})); | })); | ||||
describe('serving collections', () => { | describe('serving collections', () => { | ||||
beforeEach(() => { | |||||
Piano.canFetchCollection(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canFetchCollection(false); | |||||
}); | |||||
it('returns data', () => { | it('returns data', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowFetchCollection(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
@@ -143,7 +153,6 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeFetchCollection(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -159,14 +168,12 @@ describe('yasumi', () => { | |||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | ||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual([]); | expect(resData).toEqual([]); | ||||
Piano.revokeFetchCollection(); | |||||
resolve(); | resolve(); | ||||
}); | }); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeFetchCollection(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -186,10 +193,16 @@ describe('yasumi', () => { | |||||
await writeFile(resourcePath, JSON.stringify(data)); | await writeFile(resourcePath, JSON.stringify(data)); | ||||
}); | }); | ||||
beforeEach(() => { | |||||
Piano.canFetchItem(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canFetchItem(false); | |||||
}); | |||||
it('returns data', () => { | it('returns data', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowFetchItem(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
@@ -203,7 +216,6 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeFetchItem(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -219,14 +231,12 @@ describe('yasumi', () => { | |||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | ||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual(data); | expect(resData).toEqual(data); | ||||
Piano.revokeFetchItem(); | |||||
resolve(); | resolve(); | ||||
}); | }); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeFetchItem(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -236,8 +246,6 @@ describe('yasumi', () => { | |||||
it('throws on item not found', () => { | it('throws on item not found', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowFetchItem(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
@@ -251,19 +259,16 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeFetchItem(); | |||||
Piano.canFetchItem(false); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | ||||
Piano.revokeFetchItem(); | |||||
resolve(); | resolve(); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeFetchItem(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -287,11 +292,17 @@ describe('yasumi', () => { | |||||
await writeFile(resourcePath, JSON.stringify(data)); | await writeFile(resourcePath, JSON.stringify(data)); | ||||
}); | }); | ||||
beforeEach(() => { | |||||
Piano.canCreate(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canCreate(false); | |||||
}); | |||||
// FIXME ID de/serialization problems | // FIXME ID de/serialization problems | ||||
it('returns data', () => { | it('returns data', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowCreate(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
@@ -306,7 +317,6 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeCreate(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -323,16 +333,15 @@ describe('yasumi', () => { | |||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual({ | expect(resData).toEqual({ | ||||
...newData, | ...newData, | ||||
id: '2' | |||||
id: 2 | |||||
}); | }); | ||||
Piano.revokeCreate(); | |||||
resolve(); | resolve(); | ||||
}); | }); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeCreate(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -357,15 +366,21 @@ describe('yasumi', () => { | |||||
await writeFile(resourcePath, JSON.stringify(data)); | await writeFile(resourcePath, JSON.stringify(data)); | ||||
}); | }); | ||||
beforeEach(() => { | |||||
Piano.canPatch(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canPatch(false); | |||||
}); | |||||
it('returns data', () => { | it('returns data', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowPatch(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
port: PORT, | port: PORT, | ||||
path: '/api/pianos/1', | |||||
path: `/api/pianos/${data.id}`, | |||||
method: 'PATCH', | method: 'PATCH', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | 'Accept': ACCEPT, | ||||
@@ -375,7 +390,6 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokePatch(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -394,14 +408,12 @@ describe('yasumi', () => { | |||||
...data, | ...data, | ||||
...newData, | ...newData, | ||||
}); | }); | ||||
Piano.revokePatch(); | |||||
resolve(); | resolve(); | ||||
}); | }); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokePatch(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -412,8 +424,6 @@ describe('yasumi', () => { | |||||
it('throws on item to patch not found', () => { | it('throws on item to patch not found', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowPatch(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
@@ -428,19 +438,15 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokePatch(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | ||||
Piano.revokePatch(); | |||||
resolve(); | resolve(); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokePatch(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -466,16 +472,22 @@ describe('yasumi', () => { | |||||
await writeFile(resourcePath, JSON.stringify(data)); | await writeFile(resourcePath, JSON.stringify(data)); | ||||
}); | }); | ||||
beforeEach(() => { | |||||
Piano.canEmplace(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canEmplace(false); | |||||
}); | |||||
// FIXME IDs not properly being de/serialized | // FIXME IDs not properly being de/serialized | ||||
it('returns data for replacement', () => { | it('returns data for replacement', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowEmplace(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
port: PORT, | port: PORT, | ||||
path: '/api/pianos/1', | |||||
path: `/api/pianos/${newData.id}`, | |||||
method: 'PUT', | method: 'PUT', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | 'Accept': ACCEPT, | ||||
@@ -485,7 +497,6 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeEmplace(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -501,14 +512,12 @@ describe('yasumi', () => { | |||||
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); | ||||
const resData = JSON.parse(resBufferJson); | const resData = JSON.parse(resBufferJson); | ||||
expect(resData).toEqual(newData); | expect(resData).toEqual(newData); | ||||
Piano.revokeEmplace(); | |||||
resolve(); | resolve(); | ||||
}); | }); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeEmplace(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -520,7 +529,6 @@ describe('yasumi', () => { | |||||
it('returns data for creation', () => { | it('returns data for creation', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
const id = 2; | const id = 2; | ||||
Piano.allowEmplace(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
@@ -536,7 +544,6 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeEmplace(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -555,14 +562,12 @@ describe('yasumi', () => { | |||||
...newData, | ...newData, | ||||
id, | id, | ||||
}); | }); | ||||
Piano.revokeEmplace(); | |||||
resolve(); | resolve(); | ||||
}); | }); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeEmplace(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -586,15 +591,21 @@ describe('yasumi', () => { | |||||
await writeFile(resourcePath, JSON.stringify(data)); | await writeFile(resourcePath, JSON.stringify(data)); | ||||
}); | }); | ||||
beforeEach(() => { | |||||
Piano.canDelete(); | |||||
}); | |||||
afterEach(() => { | |||||
Piano.canDelete(false); | |||||
}); | |||||
it('returns data', () => { | it('returns data', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowDelete(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
port: PORT, | port: PORT, | ||||
path: '/api/pianos/1', | |||||
path: `/api/pianos/${data.id}`, | |||||
method: 'DELETE', | method: 'DELETE', | ||||
headers: { | headers: { | ||||
'Accept': ACCEPT, | 'Accept': ACCEPT, | ||||
@@ -603,18 +614,15 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeDelete(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | ||||
Piano.revokeDelete(); | |||||
resolve(); | resolve(); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeDelete(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
@@ -624,8 +632,6 @@ describe('yasumi', () => { | |||||
it('throws on item not found', () => { | it('throws on item not found', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
Piano.allowDelete(); | |||||
const req = request( | const req = request( | ||||
{ | { | ||||
host: HOST, | host: HOST, | ||||
@@ -639,18 +645,15 @@ describe('yasumi', () => { | |||||
}, | }, | ||||
(res) => { | (res) => { | ||||
res.on('error', (err) => { | res.on('error', (err) => { | ||||
Piano.revokeDelete(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | ||||
Piano.revokeDelete(); | |||||
resolve(); | resolve(); | ||||
}, | }, | ||||
); | ); | ||||
req.on('error', (err) => { | req.on('error', (err) => { | ||||
Piano.revokeDelete(); | |||||
reject(err); | reject(err); | ||||
}); | }); | ||||