@@ -1,5 +1,5 @@ | |||
{ | |||
"name": "yasumi", | |||
"name": "@modal-sh/yasumi", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
@@ -1,3 +1,8 @@ | |||
{ | |||
"target": "es2018" | |||
"target": "es2018", | |||
"entrypoints": { | |||
".": "src/index.ts", | |||
"./backend": "src/backend/index.ts", | |||
"./client": "src/client/index.ts" | |||
} | |||
} |
@@ -1,7 +1,6 @@ | |||
import {ApplicationState, ContentNegotiation, Resource} from '../common'; | |||
import {DataSource} from './data-source'; | |||
import {BaseSchema} from 'valibot'; | |||
import {Middleware} from './http/server'; | |||
import {ApplicationState, ContentNegotiation, Language, LanguageStatusMessageMap, Resource} from '../common'; | |||
import {DataSource} from './data-source'; | |||
export interface BackendState { | |||
app: ApplicationState; | |||
@@ -15,6 +14,28 @@ export interface BackendState { | |||
export interface RequestContext {} | |||
export interface Middleware {} | |||
export class MiddlewareError extends Error {} | |||
export interface MiddlewareResponseErrorParams extends Omit<Response, 'statusMessage'> { | |||
cause?: unknown; | |||
} | |||
export abstract class MiddlewareResponseError extends MiddlewareError implements Response { | |||
readonly statusMessage: Response['statusMessage']; | |||
readonly statusCode: Response['statusCode']; | |||
readonly headers: Response['headers']; | |||
constructor(statusMessage: keyof Language['statusMessages'], params: MiddlewareResponseErrorParams) { | |||
super(statusMessage, { cause: params.cause }); | |||
this.statusCode = params.statusCode; | |||
this.headers = params.headers; | |||
this.statusMessage = statusMessage; | |||
} | |||
} | |||
export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>; | |||
export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator; | |||
@@ -27,3 +48,14 @@ export interface AllowedMiddlewareSpecification<Schema extends BaseSchema = Base | |||
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => BaseSchema; | |||
allowed: (resource: Resource<Schema>) => boolean; | |||
} | |||
export interface Response { | |||
// type of response | |||
statusCode: number; | |||
// description of response | |||
statusMessage?: keyof LanguageStatusMessageMap; | |||
// metadata of the response | |||
headers?: Record<string, string>; | |||
} |
@@ -1,6 +1,6 @@ | |||
import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common'; | |||
import http from 'http'; | |||
import {createServer, CreateServerParams} from './http/server'; | |||
import {createServer, CreateServerParams} from './servers/http'; | |||
import https from 'https'; | |||
import {BackendState} from './common'; | |||
import {DataSource} from './data-source'; | |||
@@ -1,9 +1,15 @@ | |||
import http from 'http'; | |||
import {AllowedMiddlewareSpecification, BackendState, RequestContext} from '../common'; | |||
import {Language, Resource, LanguageStatusMessageMap} from '../../common'; | |||
import https from 'https'; | |||
import {constants} from 'http2'; | |||
import * as v from 'valibot'; | |||
import { | |||
AllowedMiddlewareSpecification, | |||
BackendState, | |||
Middleware, | |||
RequestContext, | |||
Response | |||
} from '../../common'; | |||
import {Resource} from '../../../common'; | |||
import { | |||
handleGetRoot, handleOptions, | |||
} from './handlers/default'; | |||
@@ -15,79 +21,11 @@ import { | |||
handleGetItem, | |||
handlePatchItem, | |||
} from './handlers/resource'; | |||
import {getBody} from './utils'; | |||
import {getBody, isTextMediaType} from './utils'; | |||
import {decorateRequestWithBackend} from './decorators/backend'; | |||
import {decorateRequestWithMethod} from './decorators/method'; | |||
import {decorateRequestWithUrl} from './decorators/url'; | |||
declare module '../common' { | |||
interface RequestContext extends http.IncomingMessage { | |||
body?: unknown; | |||
} | |||
} | |||
export interface Response { | |||
statusCode: number; | |||
statusMessage?: keyof LanguageStatusMessageMap; | |||
headers?: Record<string, string>; | |||
} | |||
interface ResponseParams { | |||
statusCode: Response['statusCode']; | |||
statusMessage?: Response['statusMessage']; | |||
headers?: Response['headers']; | |||
} | |||
export class MiddlewareError extends Error {} | |||
interface PlainResponseParams<T = unknown> extends ResponseParams { | |||
body?: T; | |||
} | |||
interface HttpMiddlewareErrorParams<T = unknown> extends Omit<PlainResponseParams<T>, 'statusMessage'> { | |||
cause?: unknown | |||
} | |||
export class PlainResponse<T = unknown> implements Response { | |||
readonly statusCode: Response['statusCode']; | |||
readonly statusMessage?: keyof LanguageStatusMessageMap; | |||
readonly headers: Response['headers']; | |||
readonly body?: T; | |||
constructor(args: PlainResponseParams<T>) { | |||
this.statusCode = args.statusCode; | |||
this.statusMessage = args.statusMessage; | |||
this.headers = args.headers; | |||
this.body = args.body; | |||
} | |||
} | |||
export class HttpMiddlewareError extends MiddlewareError { | |||
readonly response: PlainResponse; | |||
constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams) { | |||
super(statusMessage, { cause: params.cause }); | |||
this.response = new PlainResponse({ | |||
...params, | |||
statusMessage, | |||
}); | |||
} | |||
} | |||
export interface CreateServerParams { | |||
basePath?: string; | |||
host?: string; | |||
cert?: string; | |||
key?: string; | |||
requestTimeout?: number; | |||
// CQRS | |||
streamResponses?: boolean; | |||
} | |||
import {HttpMiddlewareError, PlainResponse} from './response'; | |||
type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource']; | |||
@@ -99,8 +37,14 @@ interface ResourceRequestContext extends Omit<RequestContext, 'resource'> { | |||
resource: ResourceWithDataSource; | |||
} | |||
export interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> { | |||
(req: Req): undefined | Response | Promise<undefined | Response>; | |||
declare module '../../common' { | |||
interface RequestContext extends http.IncomingMessage { | |||
body?: unknown; | |||
} | |||
interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> { | |||
(req: Req): undefined | Response | Promise<undefined | Response>; | |||
} | |||
} | |||
const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | |||
@@ -143,7 +87,6 @@ const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => | |||
: schema | |||
); | |||
}; | |||
// TODO add a way to define custom middlewares | |||
const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ | |||
{ | |||
@@ -184,6 +127,16 @@ const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [ | |||
}, | |||
]; | |||
export interface CreateServerParams { | |||
basePath?: string; | |||
host?: string; | |||
cert?: string; | |||
key?: string; | |||
requestTimeout?: number; | |||
// CQRS | |||
streamResponses?: boolean; | |||
} | |||
export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { | |||
const isHttps = 'key' in serverParams && 'cert' in serverParams; | |||
@@ -203,14 +156,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
decorateRequestWithBackend(backendState), | |||
]; | |||
const isTextMediaType = (mediaType: string) => ( | |||
mediaType.startsWith('text/') | |||
|| [ | |||
'application/json', | |||
'application/xml' | |||
].includes(mediaType) | |||
); | |||
const handleMiddlewares = async (currentHandlerState: Awaited<ReturnType<Middleware>>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => { | |||
const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware; | |||
const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; | |||
@@ -350,8 +295,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
return await effectiveRequestDecorators.reduce( | |||
async (resultRequestPromise, decorator) => { | |||
const resultRequest = await resultRequestPromise; | |||
return await decorator(resultRequest); | |||
const decoratedRequest = await decorator(resultRequest); | |||
// TODO log decorators | |||
return decoratedRequest; | |||
}, | |||
Promise.resolve(reqRaw as RequestContext) | |||
); | |||
@@ -373,11 +319,11 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this | |||
} catch (processRequestErrRaw) { | |||
const finalErr = processRequestErrRaw as HttpMiddlewareError; | |||
const headers = finalErr.response.headers ?? {}; | |||
const headers = finalErr.headers ?? {}; | |||
let encoded: Buffer | undefined; | |||
let serialized; | |||
try { | |||
serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; | |||
serialized = typeof finalErr.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.body) : undefined; | |||
} catch (cause) { | |||
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace( | |||
/\$RESOURCE/g, | |||
@@ -401,9 +347,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
`charset=${resourceReq.backend.cn.charset.name}`, | |||
].join('; '); | |||
const statusMessageKey = finalErr.response.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; | |||
const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined; | |||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; | |||
res.writeHead(finalErr.response.statusCode, headers); | |||
res.writeHead(finalErr.statusCode, headers); | |||
if (typeof encoded !== 'undefined') { | |||
res.end(encoded); | |||
return; | |||
@@ -503,7 +449,12 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
return; | |||
} | |||
throw new Error('Not implemented'); | |||
res.statusMessage = reqRaw.backend.cn.language.statusMessages.notImplemented.replace(/\$RESOURCE/g, | |||
reqRaw.resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_NOT_IMPLEMENTED, { | |||
'Content-Language': reqRaw.backend.cn.language.name, | |||
}); | |||
res.end(); | |||
}; | |||
server.on('request', handleRequest); |
@@ -1,8 +1,8 @@ | |||
import {ContentNegotiation} from '../../../../common'; | |||
import {RequestDecorator} from '../../../common'; | |||
import {ContentNegotiation} from '../../../../../common'; | |||
import {RequestDecorator} from '../../../../common'; | |||
import Negotiator from 'negotiator'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
cn: ContentNegotiation; | |||
} |
@@ -1,8 +1,8 @@ | |||
import {BackendState, ParamRequestDecorator} from '../../../common'; | |||
import {BackendState, ParamRequestDecorator} from '../../../../common'; | |||
import {decorateRequestWithContentNegotiation} from './content-negotiation'; | |||
import {decorateRequestWithResource} from './resource'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
backend: BackendState; | |||
} |
@@ -1,7 +1,7 @@ | |||
import {RequestDecorator} from '../../../common'; | |||
import {Resource} from '../../../../common'; | |||
import {Resource} from '../../../../../common'; | |||
import {RequestDecorator} from '../../../../common'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
resource?: Resource; | |||
resourceId?: string; |
@@ -1,4 +1,4 @@ | |||
import {RequestDecorator} from '../../../common'; | |||
import {RequestDecorator} from '../../../../common'; | |||
export const decorateRequestWithMethod: RequestDecorator = (req) => { | |||
req.method = req.method?.trim().toUpperCase() ?? ''; |
@@ -1,6 +1,6 @@ | |||
import {ParamRequestDecorator} from '../../../common'; | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
basePath: string; | |||
} |
@@ -1,6 +1,6 @@ | |||
import {ParamRequestDecorator} from '../../../common'; | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
host: string; | |||
} |
@@ -1,10 +1,10 @@ | |||
import {ParamRequestDecorator} from '../../../common'; | |||
import {CreateServerParams} from '../../server'; | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
import {CreateServerParams} from '../../index'; | |||
import {decorateRequestWithScheme} from './scheme'; | |||
import {decorateRequestWithHost} from './host'; | |||
import {decorateRequestWithBasePath} from './base-path'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
rawUrl?: string; | |||
query: URLSearchParams; |
@@ -1,6 +1,6 @@ | |||
import {ParamRequestDecorator} from '../../../common'; | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
declare module '../../../common' { | |||
declare module '../../../../common' { | |||
interface RequestContext { | |||
scheme: string; | |||
} |
@@ -1,7 +1,7 @@ | |||
import {HttpMiddlewareError, Middleware, PlainResponse} from '../server'; | |||
import {LinkMap} from '../utils'; | |||
import {constants} from 'http2'; | |||
import {AllowedMiddlewareSpecification} from '../../common'; | |||
import {AllowedMiddlewareSpecification, Middleware} from '../../../common'; | |||
import {LinkMap} from '../utils'; | |||
import {PlainResponse, HttpMiddlewareError} from '../response'; | |||
export const handleGetRoot: Middleware = (req) => { | |||
const { backend, basePath } = req; |
@@ -1,6 +1,7 @@ | |||
import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {HttpMiddlewareError, PlainResponse, Middleware} from '../server'; | |||
import {Middleware} from '../../../common'; | |||
import {HttpMiddlewareError, PlainResponse} from '../response'; | |||
export const handleGetCollection: Middleware = async (req) => { | |||
const { query, resource, backend } = req; |
@@ -0,0 +1 @@ | |||
export * from './core'; |
@@ -0,0 +1,36 @@ | |||
import {Language, LanguageStatusMessageMap} from '../../../common'; | |||
import {MiddlewareResponseError, Response} from '../../common'; | |||
interface PlainResponseParams<T = unknown> extends Response { | |||
body?: T; | |||
} | |||
interface HttpMiddlewareErrorParams<T = unknown> extends Omit<PlainResponseParams<T>, 'statusMessage'> { | |||
cause?: unknown | |||
} | |||
export class HttpMiddlewareError<T = unknown> extends MiddlewareResponseError implements PlainResponseParams<T> { | |||
body?: T; | |||
constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams<T>) { | |||
super(statusMessage, params); | |||
this.body = params.body; | |||
} | |||
} | |||
export class PlainResponse<T = unknown> implements Response { | |||
readonly statusCode: Response['statusCode']; | |||
readonly statusMessage?: keyof LanguageStatusMessageMap; | |||
readonly headers: Response['headers']; | |||
readonly body?: T; | |||
constructor(args: PlainResponseParams<T>) { | |||
this.statusCode = args.statusCode; | |||
this.statusMessage = args.statusMessage; | |||
this.headers = args.headers; | |||
this.body = args.body; | |||
} | |||
} |
@@ -1,5 +1,13 @@ | |||
import {IncomingMessage} from 'http'; | |||
export const isTextMediaType = (mediaType: string) => ( | |||
mediaType.startsWith('text/') | |||
|| [ | |||
'application/json', | |||
'application/xml' | |||
].includes(mediaType) | |||
); | |||
export const getBody = ( | |||
req: IncomingMessage, | |||
) => new Promise<Buffer>((resolve, reject) => { |
@@ -33,6 +33,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||
'resourcePatched', | |||
'resourceCreated', | |||
'resourceReplaced', | |||
'notImplemented', | |||
] as const; | |||
export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number]; | |||
@@ -90,6 +91,7 @@ export const FALLBACK_LANGUAGE = { | |||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | |||
resourceIdNotGiven: '$RESOURCE ID Not Given', | |||
unableToCreateResource: 'Unable To Create $RESOURCE', | |||
notImplemented: 'Not Implemented' | |||
}, | |||
bodies: { | |||
languageNotAcceptable: [], | |||
@@ -1,3 +1 @@ | |||
export * from './common'; | |||
export * as dataSources from './backend/data-sources'; |
@@ -0,0 +1,100 @@ | |||
import {describe, afterAll, afterEach, beforeAll, beforeEach, it} from 'vitest'; | |||
import {mkdtemp, rm} from 'fs/promises'; | |||
import {join} from 'path'; | |||
import {tmpdir} from 'os'; | |||
import {application, resource, Resource, validation as v} from '../../src'; | |||
import {dataSources} from '../../src/backend'; | |||
import {Server} from 'http'; | |||
import {autoIncrement} from '../fixtures'; | |||
const PORT = 3001; | |||
const HOST = '127.0.0.1'; | |||
const BASE_PATH = '/api'; | |||
const ACCEPT = 'application/json'; | |||
const ACCEPT_LANGUAGE = 'en'; | |||
const CONTENT_TYPE_CHARSET = 'utf-8'; | |||
const CONTENT_TYPE = ACCEPT; | |||
describe('decorators', () => { | |||
let baseDir: string; | |||
beforeAll(async () => { | |||
try { | |||
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-')); | |||
} catch { | |||
// noop | |||
} | |||
}); | |||
afterAll(async () => { | |||
try { | |||
await rm(baseDir, { | |||
recursive: true, | |||
}); | |||
} catch { | |||
// noop | |||
} | |||
}); | |||
let Piano: Resource; | |||
beforeEach(() => { | |||
Piano = resource(v.object( | |||
{ | |||
brand: v.string() | |||
}, | |||
v.never() | |||
)) | |||
.name('Piano' as const) | |||
.route('pianos' as const) | |||
.id('id' as const, { | |||
generationStrategy: autoIncrement, | |||
serialize: (id) => id?.toString() ?? '0', | |||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | |||
schema: v.number(), | |||
}); | |||
}); | |||
let server: Server; | |||
beforeEach(() => { | |||
const app = application({ | |||
name: 'piano-service', | |||
}) | |||
.resource(Piano); | |||
const backend = app | |||
.createBackend({ | |||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||
}) | |||
.throwsErrorOnDeletingNotFound(); | |||
server = backend.createHttpServer({ | |||
basePath: BASE_PATH | |||
}); | |||
return new Promise((resolve, reject) => { | |||
server.on('error', (err) => { | |||
reject(err); | |||
}); | |||
server.on('listening', () => { | |||
resolve(); | |||
}); | |||
server.listen({ | |||
port: PORT | |||
}); | |||
}); | |||
}); | |||
afterEach(() => new Promise((resolve, reject) => { | |||
server.close((err) => { | |||
if (err) { | |||
reject(err); | |||
} | |||
resolve(); | |||
}); | |||
})); | |||
it('decorates requests', () => { | |||
}); | |||
}); |
@@ -20,32 +20,17 @@ import { | |||
} from 'path'; | |||
import {request, Server} from 'http'; | |||
import {constants} from 'http2'; | |||
import {DataSource} from '../../src/backend/data-source'; | |||
import { dataSources } from '../../src/backend'; | |||
import {BackendBuilder, dataSources} from '../../src/backend'; | |||
import { application, resource, validation as v, Resource } from '../../src'; | |||
import { autoIncrement } from '../fixtures'; | |||
const PORT = 3000; | |||
const HOST = '127.0.0.1'; | |||
const BASE_PATH = '/api'; | |||
const ACCEPT = 'application/json'; | |||
const ACCEPT_LANGUAGE = 'en'; | |||
const CONTENT_TYPE_CHARSET = 'utf-8'; | |||
const CONTENT_TYPE = ACCEPT; | |||
const BASE_PATH = '/api'; | |||
const autoIncrement = async (dataSource: DataSource) => { | |||
const data = await dataSource.getMultiple() as Record<string, string>[]; | |||
const highestId = data.reduce<number>( | |||
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), | |||
-Infinity | |||
); | |||
if (Number.isFinite(highestId)) { | |||
return (highestId + 1); | |||
} | |||
return 1; | |||
}; | |||
describe('yasumi HTTP', () => { | |||
let baseDir: string; | |||
@@ -84,6 +69,7 @@ describe('yasumi HTTP', () => { | |||
}); | |||
}); | |||
let backend: BackendBuilder; | |||
let server: Server; | |||
beforeEach(() => { | |||
const app = application({ | |||
@@ -91,11 +77,9 @@ describe('yasumi HTTP', () => { | |||
}) | |||
.resource(Piano); | |||
const backend = app | |||
.createBackend({ | |||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||
}) | |||
.throwsErrorOnDeletingNotFound(); | |||
backend = app.createBackend({ | |||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||
}); | |||
server = backend.createHttpServer({ | |||
basePath: BASE_PATH | |||
@@ -1062,10 +1046,12 @@ describe('yasumi HTTP', () => { | |||
beforeEach(() => { | |||
Piano.canDelete(); | |||
backend.throwsErrorOnDeletingNotFound(); | |||
}); | |||
afterEach(() => { | |||
Piano.canDelete(false); | |||
backend.throwsErrorOnDeletingNotFound(false); | |||
}); | |||
it('throws on item not found', () => { | |||
@@ -0,0 +1,16 @@ | |||
import {DataSource} from '../src/backend/data-source'; | |||
export const autoIncrement = async (dataSource: DataSource) => { | |||
const data = await dataSource.getMultiple() as Record<string, string>[]; | |||
const highestId = data.reduce<number>( | |||
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), | |||
-Infinity | |||
); | |||
if (Number.isFinite(highestId)) { | |||
return (highestId + 1); | |||
} | |||
return 1; | |||
}; |