Author | SHA1 | Message | Date |
---|---|---|---|
TheoryOfNekomata | 517571a6d8 |
Update tests
Ensure new endpoints are following correct request/response content negotiation. |
6 months ago |
TheoryOfNekomata | 51348cf438 |
Implement create operation
Get create operation logic from old arch. |
6 months ago |
TheoryOfNekomata | 09c65a1dfd |
Update content negotiation logic
Ensure both backend and client honors the content negotiation headers. |
6 months ago |
TheoryOfNekomata | 5db84c2c81 |
Update content negotiation processing
Use content negotiation for HTTP extender. TODO: need to genericise the content negotiation logic to apply possibly to other extenders. |
6 months ago |
TheoryOfNekomata | de6cdd3d7e |
Update tests
Use example project to setup integration tests among the modules. |
6 months ago |
TheoryOfNekomata | 2865bb7d3a |
Update package definitions
Split modules to each package. |
6 months ago |
TheoryOfNekomata | 321be25d00 |
Update implemention
Genericise operation implementations. |
6 months ago |
TheoryOfNekomata | 528a3a47c0 |
Update operations
Turn operation class into a builder. |
6 months ago |
TheoryOfNekomata | 37034d23ae |
Update internals
Use Map instead of Set for easy referencing of endpoints and operations. |
6 months ago |
TheoryOfNekomata | feb105d292 |
Implement resource query
Add implementation for fetch method in tests. |
7 months ago |
TheoryOfNekomata | 615eb4ce5b |
Update structure
Organize exports. |
7 months ago |
TheoryOfNekomata | 392b842351 |
Implement recipe system
Add basic recipe system for resources. |
7 months ago |
TheoryOfNekomata | 9786d79b7a |
Test backend implementations
Allow basic functionality for HTTP servers. |
7 months ago |
TheoryOfNekomata | 9c08e00fdf |
Update status codes
Add other status codes. |
7 months ago |
TheoryOfNekomata | 0d3f10b08e |
Add HTTP-related exports
Bring back content negotiation exports as well as define a new response type system. |
7 months ago |
TheoryOfNekomata | f5b6700301 |
Implement new API
New API is much more flexible in implementation. |
7 months ago |
TheoryOfNekomata | b526819cc5 |
Set up new architecture
Make architecture more flexible. |
7 months ago |
@@ -1,37 +0,0 @@ | |||
# Frequently Asked Questions | |||
> Why another (JavaScript) framework? | |||
Yes. | |||
> No, but seriously? Why JavaScript? | |||
I find it comfortable to create stuff in TypeScript. However, the reader must pay attention to the underlying | |||
architecture instead of the specifics of the platform being implemented on. | |||
> Then why another framework? | |||
Most frameworks focus on the routing and the logic side of things. This is reasonable for most applications. However, | |||
the benefits of hypermedia becomes an afterthought, wherein attempts to produce rich structured data become ignored | |||
(as compliance with Web standards is not a requirement for building Web services, nevertheless some developers comply | |||
with select aspects of REST in order to create some rudimentary RPC service). | |||
> What's the point then? | |||
The goal of the framework is to empower developers on focusing on the business logic while not being hindered by | |||
considering proper REST (and ultimately HATEOAS) guidelines on the resulting implementation. | |||
> Why didn't you extend $FRAMEWORK instead? | |||
Frameworks tend to include a lot of built-in plugins/code. I opted on using a minimal setup for a truly compositional | |||
API (add any languages/media types/request handlers/request decorators etc.), however defaults are provided sensibly | |||
(default response is serialized as JSON because it's already a ubiquitous media type). | |||
> What is the expected output of this project? | |||
The choices include implementing a plugin to complement popular frameworks, as well as an entirely new framework with | |||
adapters to various common components of Web services (such as persistent storage). | |||
> What's the timeline for this project? | |||
Indefinite. However, I shall provide milestones if necessary for certain periods of time. |
@@ -1,26 +0,0 @@ | |||
meta { | |||
name: Create Resource | |||
type: http | |||
seq: 5 | |||
} | |||
post { | |||
url: http://localhost:3000/api/users | |||
body: json | |||
auth: none | |||
} | |||
headers { | |||
Content-Type: application/json | |||
} | |||
body:json { | |||
{ | |||
"firstName": "John", | |||
"middleName": "Smith", | |||
"lastName": "Doe", | |||
"birthday": "1986-04-20", | |||
"bio": "This is my profile!" | |||
} | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Delete Resource | |||
type: http | |||
seq: 8 | |||
} | |||
delete { | |||
url: http://localhost:3000/api/users/2 | |||
body: none | |||
auth: none | |||
} |
@@ -1,23 +0,0 @@ | |||
meta { | |||
name: Emplace Resource | |||
type: http | |||
seq: 6 | |||
} | |||
put { | |||
url: http://localhost:3000/api/users/2 | |||
body: json | |||
auth: none | |||
} | |||
body:json { | |||
{ | |||
"firstName": "John", | |||
"middleName": "Smith", | |||
"lastName": "Doe", | |||
"birthday": "1986-04-20", | |||
"bio": "This is my profile!", | |||
"id": 2 | |||
} | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Get Resource Collection Metadata | |||
type: http | |||
seq: 1 | |||
} | |||
head { | |||
url: http://localhost:3000/api/users | |||
body: none | |||
auth: none | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Get Resource Collection | |||
type: http | |||
seq: 3 | |||
} | |||
get { | |||
url: http://localhost:3000/api/users | |||
body: none | |||
auth: none | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Get Resource Metadata | |||
type: http | |||
seq: 2 | |||
} | |||
head { | |||
url: http://localhost:3000/api/users/1 | |||
body: none | |||
auth: none | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Get Resource | |||
type: http | |||
seq: 4 | |||
} | |||
get { | |||
url: http://localhost:3000/api/users/1 | |||
body: none | |||
auth: none | |||
} |
@@ -1,22 +0,0 @@ | |||
meta { | |||
name: Patch Resource | |||
type: http | |||
seq: 7 | |||
} | |||
patch { | |||
url: http://localhost:3000/api/users/1 | |||
body: json | |||
auth: none | |||
} | |||
headers { | |||
Content-Type: application/json | |||
} | |||
body:json { | |||
{ | |||
"bio": "This is my updated bio!" | |||
} | |||
} |
@@ -1,13 +0,0 @@ | |||
{ | |||
"version": "1", | |||
"name": "Yasumi", | |||
"type": "collection", | |||
"ignore": [ | |||
"node_modules", | |||
".git" | |||
], | |||
"presets": { | |||
"requestType": "http", | |||
"requestUrl": "http://localhost:3000" | |||
} | |||
} |
@@ -13,11 +13,12 @@ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.11.30", | |||
"@types/negotiator": "^0.6.3", | |||
"@types/node": "^20.11.0", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.4.3", | |||
"vitest": "^1.4.0" | |||
"typescript": "^5.3.3", | |||
"vitest": "^1.2.0" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
@@ -30,7 +31,7 @@ | |||
"test": "vitest" | |||
}, | |||
"private": false, | |||
"description": "HATEOAS-first backend framework", | |||
"description": "Core module for Yasumi.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
@@ -44,7 +45,7 @@ | |||
"access": "public" | |||
}, | |||
"dependencies": { | |||
"tsx": "^4.7.1", | |||
"negotiator": "^0.6.3", | |||
"valibot": "^0.30.0" | |||
}, | |||
"types": "./dist/types/common/index.d.ts", | |||
@@ -1,87 +1,65 @@ | |||
import {BaseSchema} from 'valibot'; | |||
import { | |||
ApplicationState, | |||
BaseResourceType, | |||
ContentNegotiation, | |||
App as BaseApp, | |||
AppOperations, | |||
BaseAppState, | |||
Endpoint, | |||
Language, | |||
LanguageStatusMessageMap, | |||
Resource, | |||
Response, | |||
} from '../common'; | |||
import {DataSource} from './data-source'; | |||
export interface Server { | |||
requestDecorator(requestDecorator: RequestDecorator): this; | |||
interface BackendParams<App extends BaseApp> { | |||
app: App; | |||
dataSource?: DataSource; | |||
} | |||
export interface BackendState { | |||
app: ApplicationState; | |||
dataSource: DataSource; | |||
cn: ContentNegotiation; | |||
showTotalItemCountOnGetCollection: boolean; | |||
throwsErrorOnDeletingNotFound: boolean; | |||
checksSerializersOnDelete: boolean; | |||
showTotalItemCountOnCreateItem: boolean; | |||
export interface ImplementationContext { | |||
endpoint: Endpoint; | |||
body?: unknown; | |||
params: Record<string, unknown>; | |||
query?: URLSearchParams; | |||
dataSource?: DataSource; | |||
language: Language; | |||
setLocation(location: string): void; | |||
} | |||
export interface RequestContext {} | |||
export type ImplementationFunction = (params: ImplementationContext) => Promise<Response | void>; | |||
export interface Middleware {} | |||
export class MiddlewareError extends Error {} | |||
export interface MiddlewareResponseErrorParams extends Omit<Response, 'statusMessage'> { | |||
cause?: unknown; | |||
export interface Backend<App extends BaseApp = BaseApp> { | |||
app: App; | |||
dataSource?: DataSource; | |||
implementations: Map<string, ImplementationFunction>; | |||
implementOperation<Operation extends AppOperations<App>>( | |||
operation: Operation, implementation: ImplementationFunction): this; | |||
} | |||
export abstract class MiddlewareResponseError extends MiddlewareError implements Response { | |||
readonly statusMessage: Response['statusMessage']; | |||
readonly statusCode: Response['statusCode']; | |||
readonly headers: Response['headers']; | |||
class BackendInstance<App extends BaseApp> implements Backend<App> { | |||
readonly app: App; | |||
readonly dataSource?: DataSource; | |||
readonly implementations: Map<string, ImplementationFunction>; | |||
constructor(statusMessage: keyof Language['statusMessages'], params: MiddlewareResponseErrorParams) { | |||
super(statusMessage, { cause: params.cause }); | |||
this.statusCode = params.statusCode; | |||
this.headers = params.headers; | |||
this.statusMessage = statusMessage; | |||
constructor(params: BackendParams<App>) { | |||
this.app = params.app; | |||
this.dataSource = params.dataSource; | |||
this.implementations = new Map<string, ImplementationFunction>(); | |||
} | |||
} | |||
export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>; | |||
export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator; | |||
// TODO put this in HTTP | |||
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'QUERY'; | |||
export interface AllowedMiddlewareSpecification<Schema extends BaseSchema = BaseSchema> { | |||
method: Method; | |||
middleware: Middleware; | |||
constructBodySchema?: (resource: Resource<BaseResourceType & { schema: Schema }>, resourceId?: string) => BaseSchema; | |||
allowed: (resource: Resource<BaseResourceType & { schema: Schema }>) => boolean; | |||
} | |||
export interface Response { | |||
// type of response | |||
statusCode: number; | |||
// description of response | |||
statusMessage?: keyof LanguageStatusMessageMap; | |||
// metadata of the response | |||
headers?: Record<string, string>; | |||
implementOperation<Operation extends AppOperations<App>>( | |||
operation: Operation, | |||
implementation: ImplementationFunction | |||
) { | |||
if (!this.implementations.has(operation)) { | |||
this.implementations.set(operation, implementation); | |||
} | |||
return this; | |||
} | |||
} | |||
export interface Backend<T extends DataSource = DataSource> { | |||
showTotalItemCountOnGetCollection(b?: boolean): this; | |||
showTotalItemCountOnCreateItem(b?: boolean): this; | |||
checksSerializersOnDelete(b?: boolean): this; | |||
throwsErrorOnDeletingNotFound(b?: boolean): this; | |||
use<BackendExtended extends this>(extender: (state: BackendState, t: this) => BackendExtended): BackendExtended; | |||
createServer<T extends Server = Server>(type: string, options?: {}): T; | |||
dataSource?: (resource: Resource) => T; | |||
} | |||
export const backend = <App extends BaseApp<AppName, State>, AppName extends string, State extends BaseAppState = BaseAppState>(params: BackendParams<App>): Backend<App> => { | |||
return new BackendInstance(params); | |||
}; | |||
export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => { | |||
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | |||
return allowedMethods.join(','); | |||
export class PlainResponse { | |||
constructor() { | |||
} | |||
} |
@@ -1,57 +0,0 @@ | |||
import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE} from '../common'; | |||
import {Backend, BackendState, Server} from './common'; | |||
import {DataSource} from './data-source'; | |||
export interface BackendExtender<D extends DataSource = DataSource, B extends Backend<D> = Backend<D>, BB extends B = B> { | |||
(state: BackendState, backend: B): BB; | |||
} | |||
export interface CreateBackendParams<T extends DataSource = DataSource> { | |||
app: ApplicationState; | |||
dataSource: T; | |||
} | |||
export const createBackend = (params: CreateBackendParams) => { | |||
const backendState: BackendState = { | |||
app: params.app, | |||
dataSource: params.dataSource, | |||
cn: { | |||
language: FALLBACK_LANGUAGE, | |||
charset: FALLBACK_CHARSET, | |||
mediaType: FALLBACK_MEDIA_TYPE, | |||
}, | |||
showTotalItemCountOnGetCollection: false, | |||
showTotalItemCountOnCreateItem: false, | |||
throwsErrorOnDeletingNotFound: false, | |||
checksSerializersOnDelete: false, | |||
}; | |||
return { | |||
showTotalItemCountOnGetCollection(b = true) { | |||
backendState.showTotalItemCountOnGetCollection = b; | |||
return this; | |||
}, | |||
showTotalItemCountOnCreateItem(b = true) { | |||
backendState.showTotalItemCountOnCreateItem = b; | |||
return this; | |||
}, | |||
throwsErrorOnDeletingNotFound(b = true) { | |||
backendState.throwsErrorOnDeletingNotFound = b; | |||
return this; | |||
}, | |||
checksSerializersOnDelete(b = true) { | |||
backendState.checksSerializersOnDelete = b; | |||
return this; | |||
}, | |||
createServer<T extends Server = Server>(_type: string, _options = {}) { | |||
return { | |||
requestDecorator() { | |||
return this; | |||
}, | |||
} as unknown as T; | |||
}, | |||
use(extender) { | |||
return extender(backendState, this); | |||
}, | |||
} satisfies Backend; | |||
}; |
@@ -1,11 +1,8 @@ | |||
import * as v from 'valibot'; | |||
import {Resource, QueryAndGrouping} from '../common'; | |||
import {QueryAndGrouping, validation as v} from '../common'; | |||
type IsCreated = boolean; | |||
type TotalCount = number; | |||
type DeleteResult = unknown; | |||
export interface EmplaceDetails { | |||
isCreated: boolean; | |||
} | |||
export type DataSourceQuery = QueryAndGrouping; | |||
@@ -13,17 +10,18 @@ export interface DataSource< | |||
ItemData extends object = object, | |||
ID extends unknown = unknown, | |||
Query extends DataSourceQuery = DataSourceQuery, | |||
Emplace extends EmplaceDetails = EmplaceDetails, | |||
DeleteResult = unknown, | |||
> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Query): Promise<TotalCount>; | |||
getTotalCount?(query?: Query): Promise<number>; | |||
getMultiple(query?: Query): Promise<ItemData[]>; | |||
getById(id: ID): Promise<ItemData | null>; | |||
getSingle?(query?: Query): Promise<ItemData | null>; | |||
create(data: ItemData): Promise<ItemData>; | |||
delete(id: ID): Promise<DeleteResult>; | |||
emplace(id: ID, data: ItemData): Promise<[ItemData, IsCreated]>; | |||
emplace(id: ID, data: ItemData): Promise<[ItemData, Emplace]>; | |||
patch(id: ID, data: Partial<ItemData>): Promise<ItemData | null>; | |||
prepareResource(resource: Resource): void; | |||
newId(): Promise<ID>; | |||
} | |||
@@ -1,3 +1,3 @@ | |||
export * from './core'; | |||
export * from './common'; | |||
export * from './data-source'; | |||
export * from './server'; |
@@ -0,0 +1,16 @@ | |||
import {ServiceParams} from '../common'; | |||
import {Backend as BaseBackend} from './common'; | |||
export interface ServerRequestContext {} | |||
export interface ServerResponseContext {} | |||
export interface ServerParams<Backend extends BaseBackend = BaseBackend> { | |||
backend: Backend; | |||
} | |||
export interface Server<Backend extends BaseBackend = BaseBackend> { | |||
backend: Backend; | |||
serve(params: ServiceParams): Promise<void>; | |||
close(): Promise<void>; | |||
} |
@@ -1,53 +1,29 @@ | |||
import { | |||
ApplicationState, | |||
Charset, | |||
FALLBACK_CHARSET, | |||
FALLBACK_LANGUAGE, | |||
FALLBACK_MEDIA_TYPE, | |||
ServiceParams, | |||
App as BaseApp, | |||
Endpoint, | |||
GetEndpointParams, | |||
Operation, | |||
Language, | |||
MediaType, | |||
Charset, | |||
} from '../common'; | |||
export interface ClientState { | |||
app: ApplicationState; | |||
mediaType: MediaType; | |||
charset: Charset; | |||
language: Language; | |||
export interface ClientParams<App extends BaseApp> { | |||
app: App; | |||
// todo only select from app's registered languages/media types/charsets | |||
language?: Language['name']; | |||
mediaType?: MediaType['name']; | |||
charset?: Charset['name']; | |||
fetch?: typeof fetch; | |||
} | |||
export interface ClientBuilder { | |||
language(languageCode: ClientState['language']['name']): this; | |||
charset(charset: ClientState['charset']['name']): this; | |||
mediaType(mediaType: ClientState['mediaType']['name']): this; | |||
} | |||
export interface ClientConnection {} | |||
export interface CreateClientParams { | |||
app: ApplicationState; | |||
export interface Client<App extends BaseApp = BaseApp, Connection extends ClientConnection = ClientConnection> { | |||
app: App; | |||
connect(params: ServiceParams): Promise<Connection>; | |||
disconnect(connection?: Connection): Promise<void>; | |||
at<TheEndpoint extends Endpoint = Endpoint>(endpoint: TheEndpoint, params?: Record<GetEndpointParams<TheEndpoint>, unknown>): this; | |||
makeRequest(operation: Operation): ReturnType<typeof fetch>; | |||
} | |||
export const createClient = (params: CreateClientParams) => { | |||
const clientState: ClientState = { | |||
app: params.app, | |||
mediaType: FALLBACK_MEDIA_TYPE, | |||
charset: FALLBACK_CHARSET, | |||
language: FALLBACK_LANGUAGE | |||
}; | |||
return { | |||
mediaType(mediaTypeName) { | |||
const mediaType = clientState.app.mediaTypes.get(mediaTypeName); | |||
clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE; | |||
return this; | |||
}, | |||
charset(charsetName) { | |||
const charset = clientState.app.charsets.get(charsetName); | |||
clientState.charset = charset ?? FALLBACK_CHARSET; | |||
return this; | |||
}, | |||
language(languageCode) { | |||
const language = clientState.app.languages.get(languageCode); | |||
clientState.language = language ?? FALLBACK_LANGUAGE; | |||
return this; | |||
} | |||
} satisfies ClientBuilder; | |||
}; |
@@ -1,100 +1,121 @@ | |||
import {BaseResourceType, Resource} from './resource'; | |||
import {Endpoint, EndpointOperations} from './endpoint'; | |||
import {Operation} from './operation'; | |||
import {NamedSet, PredicateMap} from './common'; | |||
import {FALLBACK_LANGUAGE, Language} from './language'; | |||
import {FALLBACK_MEDIA_TYPE, MediaType, PATCH_CONTENT_TYPES} from './media-type'; | |||
import {FALLBACK_MEDIA_TYPE, MediaType} from './media-type'; | |||
import {Charset, FALLBACK_CHARSET} from './charset'; | |||
import * as v from 'valibot'; | |||
import {Backend, createBackend, CreateBackendParams} from '../backend'; | |||
import {ClientBuilder, createClient, CreateClientParams} from '../client'; | |||
type ApplicationMap<T extends { name: string }> = Map<T['name'], T>; | |||
export interface ApplicationState { | |||
name: string; | |||
resources: Set<Resource<any>>; | |||
languages: ApplicationMap<Language>; | |||
mediaTypes: ApplicationMap<MediaType>; | |||
charsets: ApplicationMap<Charset>; | |||
export interface BaseAppState { | |||
endpoints: unknown; | |||
operations: unknown; | |||
} | |||
export interface ApplicationParams { | |||
name: string; | |||
} | |||
export type AppOperations<T extends App> = ( | |||
T extends App<string, infer R> | |||
? R extends BaseAppState | |||
? R['operations'] extends [] | |||
? R['operations'] extends readonly string[] | |||
? R['operations'][number] | |||
: string | |||
: string | |||
: never | |||
: never | |||
); | |||
export interface Application< | |||
Resources extends Resource[] = [], | |||
MediaTypes extends MediaType[] = [], | |||
Charsets extends Charset[] = [], | |||
Languages extends Language[] = [] | |||
> { | |||
mediaType<MediaTypeName extends string = string>(mediaType: MediaType<MediaTypeName>): Application< | |||
Resources, [...MediaTypes, MediaType<MediaTypeName>], Charsets, Languages | |||
>; | |||
language<LanguageName extends string = string>(language: Language<LanguageName>): Application< | |||
Resources, MediaTypes, Charsets, [...Languages, Language<LanguageName>] | |||
>; | |||
charset<CharsetName extends string = string>(charset: Charset<CharsetName>): Application< | |||
Resources, MediaTypes, [...Charsets, Charset<CharsetName>], Languages | |||
export interface App<AppName extends string = string, AppState extends BaseAppState = BaseAppState> { | |||
name: AppName; | |||
endpoints: NamedSet<Endpoint>; | |||
operations: NamedSet<Operation>; | |||
languages: NamedSet<Language>; | |||
mediaTypes: NamedSet<MediaType>; | |||
charsets: NamedSet<Charset>; | |||
// todo add stateful types with these methods | |||
language(language: Language): this; | |||
mediaType(mediaType: MediaType): this; | |||
charset(charset: Charset): this; | |||
operation<NewOperation extends Operation>(newOperation: NewOperation): App< | |||
AppName, | |||
{ | |||
endpoints: AppState['endpoints'], | |||
operations: AppState['operations'] extends Array<unknown> ? [...AppState['operations'], NewOperation['name']] : [NewOperation['name']] | |||
} | |||
>; | |||
resource<ResourceType extends BaseResourceType = BaseResourceType>(resRaw: Resource<ResourceType>): Application< | |||
[...Resources, Resource<ResourceType>], MediaTypes, Charsets, Languages | |||
endpoint<NewEndpoint extends Endpoint = Endpoint>( | |||
newEndpoint: EndpointOperations<NewEndpoint> extends AppOperations<this> | |||
? NewEndpoint | |||
: never | |||
): App< | |||
AppName, | |||
{ | |||
endpoints: AppState['endpoints'] extends Array<unknown> ? [...AppState['endpoints'], NewEndpoint] : [NewEndpoint], | |||
operations: AppState['operations'] | |||
} | |||
>; | |||
createBackend(params: Omit<CreateBackendParams, 'app'>): Backend; | |||
createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder; | |||
} | |||
export const application = (appParams: ApplicationParams): Application => { | |||
const appState: ApplicationState = { | |||
name: appParams.name, | |||
resources: new Set<Resource>(), | |||
languages: new Map<Language['name'], Language>([ | |||
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | |||
]), | |||
mediaTypes: new Map<MediaType['name'], MediaType>([ | |||
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], | |||
...( | |||
PATCH_CONTENT_TYPES.map((name) => [ | |||
name as MediaType['name'], | |||
{ | |||
serialize: (s: unknown) => JSON.stringify(s), | |||
deserialize: (s: string) => JSON.parse(s), | |||
name: name as MediaType['name'], | |||
} satisfies MediaType | |||
] as [MediaType['name'], MediaType]) | |||
), | |||
]), | |||
charsets: new Map<Charset['name'], Charset>([ | |||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | |||
]), | |||
}; | |||
interface AppParams<Name extends string = string> { | |||
name: Name; | |||
} | |||
class AppInstance<Params extends AppParams, State extends BaseAppState> implements App<Params['name'], State> { | |||
readonly name: Params['name']; | |||
readonly endpoints: NamedSet<Endpoint>; | |||
readonly operations: NamedSet<Operation>; | |||
readonly languages: NamedSet<Language>; | |||
readonly mediaTypes: NamedSet<MediaType>; | |||
readonly charsets: NamedSet<Charset>; | |||
constructor(params: Params) { | |||
this.name = params.name; | |||
this.endpoints = new Map<Endpoint['name'], Endpoint>(); | |||
this.operations = new PredicateMap<Operation['name'], Operation>((newOperation, s) => ( | |||
s.method === newOperation.method | |||
)); | |||
this.languages = new Map<Language['name'], Language>([ | |||
[FALLBACK_LANGUAGE.name.toLowerCase(), FALLBACK_LANGUAGE], | |||
]); | |||
this.mediaTypes = new Map<MediaType['name'], MediaType>([ | |||
[FALLBACK_MEDIA_TYPE.name.toLowerCase(), FALLBACK_MEDIA_TYPE], | |||
]); | |||
this.charsets = new Map<Charset['name'], Charset>([ | |||
[FALLBACK_CHARSET.name.toLowerCase(), FALLBACK_CHARSET], | |||
]); | |||
} | |||
language(language: Language): this { | |||
this.languages.set(language.name.toLowerCase(), language); | |||
return this; | |||
} | |||
mediaType(mediaType: MediaType): this { | |||
this.mediaTypes.set(mediaType.name.toLowerCase(), mediaType); | |||
return this; | |||
} | |||
charset(charset: Charset): this { | |||
this.charsets.set(charset.name.toLowerCase(), charset); | |||
return this; | |||
} | |||
operation<NewOperation extends Operation>(newOperation: NewOperation) { | |||
this.operations.set(newOperation.name.toLowerCase(), newOperation); | |||
return this; | |||
} | |||
endpoint<NewEndpoint extends Endpoint = Endpoint>(newEndpoint: NewEndpoint) { | |||
const nameNormalized = newEndpoint.name.toLowerCase() | |||
if (this.endpoints.has(nameNormalized)) { | |||
throw new Error(`Cannot add duplicate endpoint with name: ${nameNormalized}`); | |||
} | |||
this.endpoints.set(nameNormalized, newEndpoint); | |||
return this; | |||
} | |||
} | |||
return { | |||
mediaType(mediaType: MediaType) { | |||
appState.mediaTypes.set(mediaType.name, mediaType); | |||
return this; | |||
}, | |||
charset(charset: Charset) { | |||
appState.charsets.set(charset.name, charset); | |||
return this; | |||
}, | |||
language(language: Language) { | |||
appState.languages.set(language.name, language); | |||
return this; | |||
}, | |||
resource<T extends v.BaseSchema>(resRaw: Resource<BaseResourceType & { schema: T }>) { | |||
appState.resources.add(resRaw); | |||
return this; | |||
}, | |||
createBackend(params: Omit<CreateBackendParams, 'app'>) { | |||
return createBackend({ | |||
...params, | |||
app: appState | |||
}); | |||
}, | |||
createClient(params: Omit<CreateClientParams, 'app'>) { | |||
return createClient({ | |||
...params, | |||
app: appState | |||
}); | |||
}, | |||
}; | |||
export const app = <Params extends AppParams>(params: Params): App<Params['name'], { | |||
endpoints: []; | |||
operations: []; | |||
}> => { | |||
return new AppInstance(params); | |||
}; |
@@ -1,3 +1,6 @@ | |||
import {isTextMediaType} from './media-type'; | |||
import Negotiator from 'negotiator'; | |||
export interface Charset<Name extends string = string> { | |||
name: Name; | |||
encode: (str: string) => Buffer; | |||
@@ -9,3 +12,17 @@ export const FALLBACK_CHARSET = { | |||
decode: (buf: Buffer) => buf.toString('utf-8'), | |||
name: 'utf-8' as const, | |||
} satisfies Charset; | |||
export const getCharset = (availableCharsets: string[], mediaTypeString: string, charset?: string): Charset['name'] | undefined => { | |||
if (typeof charset === 'undefined') { | |||
return isTextMediaType(mediaTypeString) ? FALLBACK_CHARSET['name'] : undefined; | |||
} | |||
const negotiator = new Negotiator({ | |||
headers: { | |||
'accept': `${mediaTypeString};charset=${charset}`, | |||
}, | |||
}); | |||
return negotiator.charset(availableCharsets); | |||
}; |
@@ -0,0 +1,26 @@ | |||
type ObjectWithName<Name extends string = string> = { | |||
name: Name; | |||
}; | |||
export type NamedSet<T extends ObjectWithName> = Map<T['name'], T>; | |||
export class PredicateMap<K, V> extends Map<K, V> { | |||
static get [Symbol.species]() { | |||
return Map; | |||
} | |||
constructor(private readonly predicate: (newItem: V, existingItem: V) => boolean, arg0?: ConstructorParameters<typeof Map<K, V>>[0]) { | |||
super(arg0); | |||
} | |||
set(key: K, value: V) { | |||
for (const a of this.values()) { | |||
if (this.predicate(value, a)) { | |||
return this; | |||
} | |||
} | |||
super.set(key, value); | |||
return this; | |||
} | |||
} |
@@ -0,0 +1,48 @@ | |||
import {App} from './app'; | |||
import {getLanguage} from './language'; | |||
import {getMediaType, parseAcceptString} from './media-type'; | |||
import {getCharset} from './charset'; | |||
interface ContentNegotiationHeaders { | |||
'accept-language'?: string; | |||
accept?: string; | |||
} | |||
export const getContentNegotiationParams = (app: App, headers: ContentNegotiationHeaders) => { | |||
const languageString = getLanguage(Array.from(app.languages.keys()), headers['accept-language']); | |||
const language = ( | |||
typeof languageString !== 'undefined' | |||
? app.languages.get(languageString) | |||
: undefined | |||
); | |||
const parsedAccept = parseAcceptString(headers['accept']); | |||
const mediaTypeString = ( | |||
typeof parsedAccept !== 'undefined' | |||
? getMediaType(Array.from(app.mediaTypes.keys()), parsedAccept.mediaType) | |||
: undefined | |||
); | |||
const mediaType = ( | |||
typeof mediaTypeString !== 'undefined' | |||
? app.mediaTypes.get(mediaTypeString) | |||
: undefined | |||
); | |||
const charsetString = ( | |||
typeof parsedAccept !== 'undefined' && typeof mediaType !== 'undefined' | |||
? getCharset(Array.from(app.charsets.keys()), mediaType.name, parsedAccept.params.charset) | |||
: undefined | |||
); | |||
const charset = ( | |||
typeof charsetString !== 'undefined' | |||
? app.charsets.get(charsetString) | |||
: undefined | |||
); | |||
return { | |||
language, | |||
mediaType, | |||
charset, | |||
}; | |||
}; |
@@ -1,187 +0,0 @@ | |||
import * as v from 'valibot'; | |||
import {getObjectSchema} from './utils'; | |||
import { | |||
InvalidOperationError, | |||
InvalidPathValueError, | |||
InvalidSchemaInPathError, | |||
PathValueTestFailedError, | |||
} from './error'; | |||
import {append, get, remove, set} from './object'; | |||
const ADD_DELTA_SCHEMA = v.object({ | |||
op: v.literal('add'), | |||
path: v.string(), | |||
value: v.unknown() | |||
}); | |||
const REMOVE_DELTA_SCHEMA = v.object({ | |||
op: v.literal('remove'), | |||
path: v.string(), | |||
}); | |||
const REPLACE_DELTA_SCHEMA = v.object({ | |||
op: v.literal('replace'), | |||
path: v.string(), | |||
value: v.unknown() | |||
}); | |||
const MOVE_DELTA_SCHEMA = v.object({ | |||
op: v.literal('move'), | |||
path: v.string(), | |||
from: v.string(), | |||
}); | |||
const COPY_DELTA_SCHEMA = v.object({ | |||
op: v.literal('copy'), | |||
path: v.string(), | |||
from: v.string(), | |||
}); | |||
const TEST_DELTA_SCHEMA = v.object({ | |||
op: v.literal('test'), | |||
path: v.string(), | |||
value: v.unknown() | |||
}); | |||
export const DELTA_SCHEMA = v.union([ | |||
ADD_DELTA_SCHEMA, | |||
REMOVE_DELTA_SCHEMA, | |||
REPLACE_DELTA_SCHEMA, | |||
MOVE_DELTA_SCHEMA, | |||
COPY_DELTA_SCHEMA, | |||
TEST_DELTA_SCHEMA, | |||
]); | |||
export type Delta = v.Output<typeof DELTA_SCHEMA>; | |||
const applyReplaceDelta = <T extends v.BaseSchema = v.BaseSchema>( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: v.Output<typeof REPLACE_DELTA_SCHEMA>, | |||
pathSchema: T, | |||
) => { | |||
if (!v.is(pathSchema, deltaItem.value)) { | |||
throw new InvalidPathValueError(); | |||
} | |||
set(mutablePreviousObject, deltaItem.path, deltaItem.value); | |||
}; | |||
const applyAddDelta = <T extends v.BaseSchema = v.BaseSchema>( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: v.Output<typeof ADD_DELTA_SCHEMA>, | |||
pathSchema: T, | |||
) => { | |||
if (pathSchema.type !== 'array') { | |||
throw new InvalidOperationError(); | |||
} | |||
const arraySchema = pathSchema as unknown as v.ArraySchema<any>; | |||
if (!v.is(arraySchema.item, deltaItem.value)) { | |||
throw new InvalidPathValueError(); | |||
} | |||
append(mutablePreviousObject, deltaItem.path, deltaItem.value); | |||
} | |||
const applyRemoveDelta = ( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: v.Output<typeof REMOVE_DELTA_SCHEMA>, | |||
) => { | |||
remove(mutablePreviousObject, deltaItem.path); | |||
}; | |||
const applyCopyDelta = <T extends v.BaseSchema = v.BaseSchema>( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: v.Output<typeof COPY_DELTA_SCHEMA>, | |||
pathSchema: T, | |||
) => { | |||
const value = get(mutablePreviousObject, deltaItem.from); | |||
if (!v.is(pathSchema, value)) { | |||
throw new InvalidPathValueError(); | |||
} | |||
set(mutablePreviousObject, deltaItem.path, value); | |||
}; | |||
const applyMoveDelta = <T extends v.BaseSchema = v.BaseSchema>( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: v.Output<typeof MOVE_DELTA_SCHEMA>, | |||
pathSchema: T, | |||
) => { | |||
const value = get(mutablePreviousObject, deltaItem.from); | |||
if (!v.is(pathSchema, value)) { | |||
throw new InvalidPathValueError(); | |||
} | |||
remove(mutablePreviousObject, deltaItem.from) | |||
set(mutablePreviousObject, deltaItem.path, value); | |||
}; | |||
const applyTestDelta = ( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: v.Output<typeof TEST_DELTA_SCHEMA>, | |||
) => { | |||
const value = get(mutablePreviousObject, deltaItem.path); | |||
if (value !== deltaItem.value) { | |||
throw new PathValueTestFailedError(); | |||
} | |||
}; | |||
type ApplyDeltaFunction = <T extends v.BaseSchema = v.BaseSchema>( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: any, | |||
pathSchema: T | |||
) => void; | |||
const OPERATION_FUNCTION_MAP: Record<Delta['op'], ApplyDeltaFunction> = { | |||
replace: applyReplaceDelta, | |||
add: applyAddDelta, | |||
remove: applyRemoveDelta, | |||
copy: applyCopyDelta, | |||
move: applyMoveDelta, | |||
test: applyTestDelta, | |||
}; | |||
const mutateObject = <T extends v.BaseSchema = v.BaseSchema>( | |||
mutablePreviousObject: Record<string, unknown>, | |||
deltaItem: Delta, | |||
pathSchema: T, | |||
) => { | |||
const { [deltaItem.op]: applyDeltaFn } = OPERATION_FUNCTION_MAP; | |||
if (typeof applyDeltaFn !== 'function') { | |||
throw new InvalidOperationError(); | |||
} | |||
applyDeltaFn(mutablePreviousObject, deltaItem, pathSchema); | |||
}; | |||
export const applyDelta = async <T extends v.BaseSchema = v.BaseSchema>( | |||
existing: Record<string, unknown>, | |||
deltaCollection: Delta[], | |||
resourceSchema: T, | |||
) => { | |||
return await deltaCollection.reduce( | |||
async (resultObject, deltaItem) => { | |||
const mutablePreviousObject = await resultObject; | |||
if (resourceSchema.type !== 'object') { | |||
return mutablePreviousObject; | |||
} | |||
const resourceObjectSchema = resourceSchema as unknown as v.ObjectSchema<any>; | |||
const pathSchema = getObjectSchema(resourceObjectSchema, deltaItem.path); | |||
if (typeof pathSchema === 'undefined') { | |||
throw new InvalidSchemaInPathError(); | |||
} | |||
mutateObject(mutablePreviousObject, deltaItem, pathSchema); | |||
if (!v.is(resourceObjectSchema, mutablePreviousObject, { skipPipe: true })) { | |||
throw new InvalidOperationError(); | |||
} | |||
return mutablePreviousObject; | |||
}, | |||
Promise.resolve(existing), | |||
); | |||
}; |
@@ -1,7 +0,0 @@ | |||
export class InvalidSchemaInPathError extends Error {} | |||
export class InvalidPathValueError extends Error {} | |||
export class InvalidOperationError extends Error {} | |||
export class PathValueTestFailedError extends Error {} |
@@ -1,2 +0,0 @@ | |||
export * from './error'; | |||
export * from './core'; |
@@ -1,77 +0,0 @@ | |||
import {tokenizePath} from './utils'; | |||
export const set = (origObject: Record<string, unknown>, path: string, value: unknown) => { | |||
if (path.length <= 0) { | |||
return origObject; | |||
} | |||
const pathFragments = tokenizePath(path); | |||
let cursor = origObject; | |||
let thisPath = pathFragments.shift() as string; | |||
while (pathFragments.length > 0) { | |||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||
thisPath = pathFragments.shift() as string; | |||
} | |||
if (typeof cursor === 'undefined') { | |||
throw new Error(`Could not set path: ${path}`); | |||
} | |||
cursor[thisPath] = value; | |||
return origObject; | |||
}; | |||
export const get = (origObject: Record<string, unknown>, path: string) => { | |||
if (path.length <= 0) { | |||
return origObject; | |||
} | |||
const pathFragments = tokenizePath(path); | |||
let cursor = origObject; | |||
let thisPath = pathFragments.shift() as string; | |||
while (pathFragments.length > 0) { | |||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||
thisPath = pathFragments.shift() as string; | |||
} | |||
return cursor?.[thisPath]; | |||
}; | |||
export const remove = (origObject: Record<string, unknown>, path: string) => { | |||
if (path.length <= 0) { | |||
return origObject; | |||
} | |||
const pathFragments = tokenizePath(path); | |||
let cursor = origObject; | |||
let thisPath = pathFragments.shift() as string; | |||
while (pathFragments.length > 0) { | |||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||
thisPath = pathFragments.shift() as string; | |||
} | |||
if (typeof cursor === 'undefined') { | |||
throw new Error(`Could not remove on path: ${path}`); | |||
} | |||
delete cursor[thisPath]; | |||
return origObject; | |||
}; | |||
export const append = (origObject: Record<string, unknown>, path: string, value: unknown) => { | |||
if (path.length <= 0) { | |||
return origObject; | |||
} | |||
const pathFragments = tokenizePath(path); | |||
let cursor = origObject; | |||
let thisPath = pathFragments.shift() as string; | |||
while (pathFragments.length > 0) { | |||
cursor = cursor?.[thisPath] as Record<string, unknown>; | |||
thisPath = pathFragments.shift() as string; | |||
} | |||
if (!Array.isArray(cursor?.[thisPath])) { | |||
throw new Error(`Could not append on path: ${path}`); | |||
} | |||
(cursor[thisPath] as unknown[]).push(value); | |||
return origObject; | |||
}; |
@@ -1,23 +0,0 @@ | |||
import * as v from 'valibot'; | |||
export const DELTA_PATH_SEPARATOR = '/' as const; | |||
export const tokenizePath = (path: string) => { | |||
return path.split(DELTA_PATH_SEPARATOR); | |||
}; | |||
export const combinePathFragments = (pathFragments: string[]) => { | |||
return pathFragments.join(DELTA_PATH_SEPARATOR); | |||
}; | |||
export const getObjectSchema = (schema?: v.ObjectSchema<any>, path?: string): v.BaseSchema => { | |||
if (typeof path !== 'string') { | |||
return schema as v.BaseSchema; | |||
} | |||
if (path.length <= 0) { | |||
return schema as v.BaseSchema; | |||
} | |||
const pathFragments = tokenizePath(path); | |||
const thisPath = pathFragments.shift() as string; | |||
return getObjectSchema(schema?.entries?.[thisPath], combinePathFragments(pathFragments)); | |||
}; |
@@ -0,0 +1,195 @@ | |||
import {DataSource} from '../backend/data-source'; | |||
import {validation as v} from '.'; | |||
import {NamedSet} from './common'; | |||
export type EndpointQueue = [Endpoint, Record<string, unknown> | undefined][]; | |||
export const serializeEndpointQueue = (endpointQueue: EndpointQueue) => { | |||
return endpointQueue | |||
.map(([endpoint, param]) => { | |||
if (typeof param === 'undefined') { | |||
return `/${endpoint.name}`; | |||
} | |||
return [ | |||
endpoint.name, | |||
...Array.from(endpoint.params).map((s) => param[s] ?? '_') | |||
] | |||
.map((s) => `/${s}`) | |||
.join(''); | |||
}) | |||
.join('') | |||
}; | |||
export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: NamedSet<Endpoint>) => { | |||
// why we don't get the url without query params as parameter, because we might need the query params in the future | |||
const [urlWithoutQueryParams] = urlWithoutBase.split('?'); | |||
const fragments = urlWithoutQueryParams.split('/').filter((s) => s.trim().length > 0); | |||
return fragments.reduce( | |||
(theEndpointQueueRaw, s) => { | |||
const theEndpointQueue = theEndpointQueueRaw as EndpointQueue; | |||
const [lastEndpoint, lastEndpointParams] = theEndpointQueue.at(-1) ?? []; | |||
const endpoint = endpoints.get(s); | |||
if (typeof endpoint !== 'undefined') { | |||
if (typeof lastEndpoint === 'undefined') { | |||
return [ | |||
...theEndpointQueue, | |||
[endpoint, {}] | |||
]; | |||
} | |||
} | |||
if (typeof lastEndpoint === 'undefined') { | |||
throw new Error(`Invalid URL: ${urlWithoutBase}`); | |||
} | |||
const lastEndpointParamsOrdering = Array.from(lastEndpoint.params); | |||
const lastEndpointParamsOrderingLength = lastEndpointParamsOrdering.length; | |||
if (lastEndpointParamsOrderingLength > 0) { | |||
if (typeof lastEndpointParams === 'undefined') { | |||
return [ | |||
...theEndpointQueue.slice(0, -1), | |||
[lastEndpoint, { | |||
[lastEndpointParamsOrdering[0]]: s | |||
}] | |||
]; | |||
} | |||
const nextIndex = Object.keys(lastEndpointParams).length; | |||
if (nextIndex === lastEndpointParamsOrderingLength) { | |||
throw new Error(`Invalid URL: ${urlWithoutBase}`); | |||
} | |||
return [ | |||
...theEndpointQueue.slice(0, -1), | |||
[lastEndpoint, { | |||
...lastEndpointParams, | |||
[lastEndpointParamsOrdering[nextIndex]]: s | |||
}] | |||
]; | |||
} | |||
throw new Error(`Invalid URL: ${urlWithoutBase}`); | |||
}, | |||
[] as unknown | |||
) as EndpointQueue; | |||
}; | |||
interface BaseEndpointState { | |||
operations: unknown; | |||
params: unknown; | |||
} | |||
type OpValueType = undefined | boolean | Record<string, boolean> | readonly string[]; | |||
export interface Endpoint< | |||
Name extends string = string, | |||
Schema extends v.BaseSchema = v.BaseSchema, | |||
State extends BaseEndpointState = BaseEndpointState | |||
> { | |||
name: Name; | |||
dataSource?: DataSource; | |||
schema: Schema; | |||
params: Set<string>; | |||
operations: Set<string>; | |||
metadata: Map<string, unknown>; | |||
can<OpName extends string = string, OpValue extends OpValueType = OpValueType>( | |||
op: OpName, | |||
value?: OpValue | |||
): Endpoint< | |||
Name, | |||
Schema, | |||
{ | |||
operations: State['operations'] extends string[] ? readonly [...State['operations'], OpName] : [OpName]; | |||
params: State['params'] | |||
} | |||
>; | |||
param<ParamName extends string = string>(name: ParamName): Endpoint< | |||
Name, | |||
Schema, | |||
{ | |||
operations: State['operations']; | |||
params: State['params'] extends string[] ? readonly [...State['params'], ParamName]: [ParamName]; | |||
} | |||
>; | |||
id<IdAttr extends string = string>(id: IdAttr): this; | |||
} | |||
export type GetEndpointParams<T extends Endpoint> = T extends Endpoint<any, infer R> ? ( | |||
R extends { params: Record<number, any> } ? R['params'][number] : never | |||
) : never; | |||
interface EndpointParams<Name extends string = string, Schema extends v.BaseSchema = v.BaseSchema> { | |||
name: Name; | |||
schema: Schema; | |||
dataSource?: DataSource; | |||
} | |||
class EndpointInstance< | |||
Params extends EndpointParams, | |||
State extends BaseEndpointState | |||
> implements Endpoint<Params['name'], Params['schema'], State> { | |||
readonly name: Params['name']; | |||
readonly dataSource: Params['dataSource']; | |||
readonly operations: Set<string>; | |||
readonly params: Set<string>; | |||
readonly schema: Params['schema']; | |||
readonly metadata: Map<string, unknown>; | |||
constructor(params: Params) { | |||
this.name = params.name; | |||
this.schema = params.schema; | |||
this.operations = new Set<string>(); | |||
this.params = new Set<string>(); | |||
this.dataSource = params.dataSource; | |||
this.metadata = new Map<string, unknown>(); | |||
} | |||
can<OpName extends string = string, OpValue extends OpValueType = OpValueType>( | |||
op: OpName, | |||
value = true as OpValue | |||
) { | |||
if (value) { | |||
this.operations.add(op); | |||
} else { | |||
// todo remove operation at type level | |||
this.operations.delete(op); | |||
} | |||
return this; | |||
} | |||
param<ParamName extends string = string>( | |||
name: ParamName | |||
) { | |||
this.params.add(name); | |||
return this; | |||
} | |||
id<IdAttr extends string = string>(id: IdAttr) { | |||
this.metadata.set('idAttr', id); | |||
return this; | |||
} | |||
} | |||
export const endpoint = <Params extends EndpointParams>(params: Params): Endpoint< | |||
Params['name'], | |||
Params['schema'], | |||
{ | |||
operations: []; | |||
params: []; | |||
} | |||
> => { | |||
return new EndpointInstance(params); | |||
}; | |||
export type EndpointOperations<T extends Endpoint> = T extends Endpoint<string, v.BaseSchema, infer R> ? ( | |||
R extends BaseEndpointState | |||
? R['operations'] extends [] | |||
? R['operations'] extends readonly string[] | |||
? R['operations'][number] | |||
: string | |||
: string | |||
: never | |||
) : never; |
@@ -1,18 +1,14 @@ | |||
import {Language} from './language'; | |||
import {Charset} from './charset'; | |||
import {MediaType} from './media-type'; | |||
export * from './app'; | |||
export * from './charset'; | |||
export * from './delta'; | |||
export * from './media-type'; | |||
export * from './resource'; | |||
export * from './common'; | |||
export * from './content-negotiation'; | |||
export * from './endpoint'; | |||
export * from './language'; | |||
export * from './media-type'; | |||
export * from './operation'; | |||
export * from './queries'; | |||
export * as validation from './validation'; | |||
export interface ContentNegotiation { | |||
language: Language; | |||
mediaType: MediaType; | |||
charset: Charset; | |||
} | |||
export * from './recipe'; | |||
export * from './response'; | |||
export * from './service'; | |||
export * as statusCodes from './status-codes'; | |||
export * as validation from 'valibot'; |
@@ -1,3 +1,5 @@ | |||
import Negotiator from 'negotiator'; | |||
export type MessageBody = string | string[] | (string | string[])[]; | |||
export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [ | |||
@@ -13,7 +15,7 @@ export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [ | |||
'badRequest', | |||
'deleteNonExistingResource', | |||
'unableToCreateResource', | |||
'unableToBindResourceDataSource', | |||
'dataSourceMethodNotImplemented', | |||
'unableToGenerateIdFromResourceDataSource', | |||
'unableToAssignIdFromResourceDataSource', | |||
'unableToEmplaceResource', | |||
@@ -65,41 +67,41 @@ export const FALLBACK_LANGUAGE = { | |||
statusMessages: { | |||
unableToSerializeResponse: 'Unable To Serialize Response', | |||
unableToEncodeResponse: 'Unable To Encode Response', | |||
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source', | |||
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | |||
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | |||
unableToFetchResource: 'Unable To Fetch $RESOURCE', | |||
unableToDeleteResource: 'Unable To Delete $RESOURCE', | |||
dataSourceMethodNotImplemented: 'Unable To Bind Resource Data Source', | |||
unableToInitializeResourceDataSource: 'Unable To Initialize Resource Data Source', | |||
unableToFetchResourceCollection: 'Unable To Fetch Resource Collection', | |||
unableToFetchResource: 'Unable To Fetch Resource', | |||
unableToDeleteResource: 'Unable To Delete Resource', | |||
languageNotAcceptable: 'Language Not Acceptable', | |||
characterSetNotAcceptable: 'Character Set Not Acceptable', | |||
unableToDeserializeResource: 'Unable To Deserialize $RESOURCE', | |||
unableToDecodeResource: 'Unable To Decode $RESOURCE', | |||
unableToDeserializeResource: 'Unable To Deserialize Resource', | |||
unableToDecodeResource: 'Unable To Decode Resource', | |||
mediaTypeNotAcceptable: 'Media Type Not Acceptable', | |||
methodNotAllowed: 'Method Not Allowed', | |||
urlNotFound: 'URL Not Found', | |||
badRequest: 'Bad Request', | |||
ok: 'OK', | |||
provideOptions: 'Provide Options', | |||
resourceCollectionFetched: '$RESOURCE Collection Fetched', | |||
resourceCollectionQueried: '$RESOURCE Collection Queried', | |||
resourceFetched: '$RESOURCE Fetched', | |||
resourceNotFound: '$RESOURCE Not Found', | |||
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', | |||
resourceDeleted: '$RESOURCE Deleted', | |||
resourceCollectionFetched: 'Resource Collection Fetched', | |||
resourceCollectionQueried: 'Resource Collection Queried', | |||
resourceFetched: 'Resource Fetched', | |||
resourceNotFound: 'Resource Not Found', | |||
deleteNonExistingResource: 'Delete Non-Existing Resource', | |||
resourceDeleted: 'Resource Deleted', | |||
unableToDeserializeRequest: 'Unable To Deserialize Request', | |||
patchNonExistingResource: 'Patch Non-Existing $RESOURCE', | |||
unableToPatchResource: 'Unable To Patch $RESOURCE', | |||
invalidResourcePatch: 'Invalid $RESOURCE Patch', | |||
invalidResourcePatchType: 'Invalid $RESOURCE Patch Type', | |||
invalidResource: 'Invalid $RESOURCE', | |||
resourcePatched: '$RESOURCE Patched', | |||
resourceCreated: '$RESOURCE Created', | |||
resourceReplaced: '$RESOURCE Replaced', | |||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | |||
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source', | |||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | |||
resourceIdNotGiven: '$RESOURCE ID Not Given', | |||
unableToCreateResource: 'Unable To Create $RESOURCE', | |||
patchNonExistingResource: 'Patch Non-Existing Resource', | |||
unableToPatchResource: 'Unable To Patch Resource', | |||
invalidResourcePatch: 'Invalid Resource Patch', | |||
invalidResourcePatchType: 'Invalid Resource Patch Type', | |||
invalidResource: 'Invalid Resource', | |||
resourcePatched: 'Resource Patched', | |||
resourceCreated: 'Resource Created', | |||
resourceReplaced: 'Resource Replaced', | |||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From Resource Data Source', | |||
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From Resource Data Source', | |||
unableToEmplaceResource: 'Unable To Emplace Resource', | |||
resourceIdNotGiven: 'Resource ID Not Given', | |||
unableToCreateResource: 'Unable To Create Resource', | |||
notImplemented: 'Not Implemented', | |||
internalServerError: 'Internal Server Error', | |||
}, | |||
@@ -209,7 +211,7 @@ export const FALLBACK_LANGUAGE = { | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToBindResourceDataSource: [ | |||
dataSourceMethodNotImplemented: [ | |||
'The resource could not be associated from the data source.', | |||
[ | |||
'Try the request again at a later time.', | |||
@@ -340,3 +342,17 @@ export const FALLBACK_LANGUAGE = { | |||
], | |||
}, | |||
} satisfies Language; | |||
export const getLanguage = (availableLanguageNames: string[], languageString?: string): Language['name'] | undefined => { | |||
if (typeof languageString === 'undefined') { | |||
return FALLBACK_LANGUAGE['name']; | |||
} | |||
const negotiator = new Negotiator({ | |||
headers: { | |||
'accept-language': languageString, | |||
}, | |||
}); | |||
return negotiator.language(availableLanguageNames); | |||
}; |
@@ -1,6 +1,8 @@ | |||
import Negotiator from 'negotiator'; | |||
export interface MediaType< | |||
Name extends string = string, | |||
T extends object = object, | |||
T extends object | null = object | null, | |||
SerializeOpts extends {} = {}, | |||
DeserializeOpts extends {} = {} | |||
> { | |||
@@ -26,12 +28,60 @@ export const getAcceptPostString = (mediaTypes: Map<string, MediaType>) => Array | |||
.filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType)) | |||
.join(','); | |||
export const isTextMediaType = (mediaType: string) => ( | |||
mediaType.startsWith('text/') | |||
|| [ | |||
'application/json', | |||
'application/xml', | |||
'application/x-www-form-urlencoded', | |||
...PATCH_CONTENT_TYPES, | |||
].includes(mediaType) | |||
); | |||
export const parseMediaType = (mediaType: string) => { | |||
const [type, subtypeEntirety] = mediaType.split('/').map((s) => s.trim()); | |||
return { | |||
type, | |||
// application/foo+json | |||
subtype: subtypeEntirety.split('+'), | |||
}; | |||
}; | |||
export const isTextMediaType = (mediaType: string) => { | |||
const { type, subtype } = parseMediaType(mediaType); | |||
if (type === 'text') { | |||
return true; | |||
} | |||
if (type === 'application' && subtype.length > 0) { | |||
const lastSubtype = subtype.at(-1) as string; | |||
return [ | |||
'json', | |||
'xml' | |||
].includes(lastSubtype); | |||
} | |||
return false; | |||
}; | |||
export const getMediaType = (availableMediaTypes: string[], mediaTypeString?: string): MediaType['name'] | undefined => { | |||
if (typeof mediaTypeString === 'undefined') { | |||
return FALLBACK_MEDIA_TYPE['name']; | |||
} | |||
const negotiator = new Negotiator({ | |||
headers: { | |||
'accept': mediaTypeString, | |||
}, | |||
}); | |||
return negotiator.mediaType(availableMediaTypes); | |||
}; | |||
export const parseAcceptString = (acceptString?: string) => { | |||
if (typeof acceptString !== 'string') { | |||
return undefined; | |||
} | |||
// TODO parse multiple accept types | |||
const [mediaType, ...acceptParams] = acceptString.split(';'); | |||
const params = Object.fromEntries( | |||
acceptParams.map((s) => { | |||
const [key, ...values] = s.split('='); | |||
return [key.trim(), values.map((v) => v.trim()).join('=')]; | |||
}) | |||
); | |||
return { | |||
mediaType, | |||
params, | |||
}; | |||
}; |
@@ -0,0 +1,85 @@ | |||
export const AVAILABLE_METHODS = [ | |||
'HEAD', | |||
'GET', | |||
'POST', | |||
'PUT', | |||
'PATCH', | |||
'DELETE', | |||
'OPTIONS' | |||
] as const; | |||
export const AVAILABLE_EXTENSION_METHODS = [ | |||
'QUERY' | |||
] as const; | |||
export type Method = typeof AVAILABLE_METHODS[number]; | |||
export type MethodWithExtensions = Method | typeof AVAILABLE_EXTENSION_METHODS[number]; | |||
export interface BaseOperationParams< | |||
Name extends string = string, | |||
Method extends MethodWithExtensions = MethodWithExtensions, | |||
> { | |||
name: Name; | |||
method?: Method; | |||
headers?: ConstructorParameters<typeof Headers>[0]; | |||
} | |||
export interface Operation<Params extends BaseOperationParams = BaseOperationParams> { | |||
name: Params['name']; | |||
method: Params['method']; | |||
headers?: Headers; | |||
searchParams?: URLSearchParams; | |||
search: (...args: ConstructorParameters<typeof URLSearchParams>) => Operation<Params>; | |||
setBody: (b: unknown) => Operation<Params>; | |||
body?: unknown; | |||
} | |||
class OperationInstance<Params extends BaseOperationParams = BaseOperationParams> implements Operation<Params> { | |||
readonly name: Params['name']; | |||
readonly method: Params['method']; | |||
readonly headers?: Headers; | |||
theSearchParams?: URLSearchParams; | |||
// todo add type safety, depend on method when allowing to have body | |||
theBody?: unknown; | |||
constructor(params: Params) { | |||
this.name = params.name; | |||
this.method = params.method ?? 'GET'; | |||
this.headers = typeof params.headers !== 'undefined' ? new Headers(params.headers) : undefined; | |||
} | |||
search(...args: ConstructorParameters<typeof URLSearchParams>): Operation<Params> { | |||
this.theSearchParams = new URLSearchParams(...args); | |||
return this; | |||
} | |||
get searchParams() { | |||
return Object.freeze(this.theSearchParams); | |||
} | |||
get body() { | |||
return Object.freeze(this.theBody); | |||
} | |||
setBody(b: unknown): Operation<Params> { | |||
switch (this.method) { | |||
case 'PATCH': | |||
case 'PUT': | |||
case 'POST': | |||
case 'QUERY': | |||
this.theBody = b; | |||
break; | |||
default: | |||
break; | |||
} | |||
return this; | |||
} | |||
} | |||
export const operation = <Params extends BaseOperationParams = BaseOperationParams>( | |||
params: Params | |||
): Operation<Params> => { | |||
return new OperationInstance(params); | |||
}; |
@@ -1,4 +0,0 @@ | |||
export class DeserializeError extends Error {} | |||
export class SerializeError extends Error {} |
@@ -1,3 +1,2 @@ | |||
export * from './common'; | |||
export * from './errors'; | |||
export * as queryMediaTypes from './media-types'; | |||
export * from './parsing'; |
@@ -1 +0,0 @@ | |||
export * as applicationXWwwFormUrlencoded from './application/x-www-form-urlencoded'; |
@@ -1,14 +1,14 @@ | |||
import { | |||
QueryMediaType, | |||
QueryAndGrouping, | |||
QueryAnyExpression, | |||
QueryOrGrouping, | |||
} from '../../common'; | |||
} from './common'; | |||
import { | |||
DeserializeError, | |||
SerializeError, | |||
} from '../../errors'; | |||
export class QueryParsingError extends Error {} | |||
export class DeserializeError extends QueryParsingError {} | |||
export class SerializeError extends QueryParsingError {} | |||
interface ProcessEntryBase { | |||
type: string; | |||
@@ -119,8 +119,6 @@ const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<strin | |||
// we can also make this function act as a "sanitizer" | |||
} | |||
interface SerializeOptions {} | |||
interface DeserializeOptions { | |||
processEntries?: Record<string, ProcessEntry>; | |||
} | |||
@@ -137,17 +135,11 @@ const doesGroupHaveExpression = (ex2: QueryAnyExpression, key: string) => { | |||
return false; | |||
}; | |||
export const deserialize: QueryMediaType< | |||
typeof name, | |||
SerializeOptions, | |||
DeserializeOptions | |||
>['deserialize'] = (s: string, options = {} as DeserializeOptions) => { | |||
const q = new URLSearchParams(s); | |||
return Array.from(q.entries()).reduce( | |||
export const fromUrlSearchParams = (q: URLSearchParams, options = {} as DeserializeOptions) => ( | |||
Array.from(q.entries()).reduce( | |||
(queries, [key, value]) => { | |||
const defaultOr = { | |||
type: 'or', | |||
type: 'or' as const, | |||
expressions: [], | |||
} as QueryOrGrouping; | |||
const existingOr = queries.expressions.find((ex) => ( | |||
@@ -162,7 +154,7 @@ export const deserialize: QueryMediaType< | |||
expressions: [ | |||
...queries.expressions, | |||
{ | |||
type: 'or', | |||
type: 'or' as const, | |||
expressions: [ | |||
newExpression, | |||
], | |||
@@ -187,11 +179,11 @@ export const deserialize: QueryMediaType< | |||
}; | |||
}, | |||
{ | |||
type: 'and', | |||
type: 'and' as const, | |||
expressions: [], | |||
} as QueryAndGrouping | |||
) | |||
}; | |||
); | |||
class SerializeInvalidExpressionError extends SerializeError {} | |||
@@ -234,15 +226,10 @@ const serializeExpression = (ex2: QueryAnyExpression) => { | |||
throw new SerializeInvalidExpressionError(`Unknown type for rhs: ${ex2.lhs} ${ex2.operator} <rhs>`); | |||
}; | |||
export const serialize: QueryMediaType< | |||
typeof name, | |||
SerializeOptions, | |||
DeserializeOptions | |||
>['serialize'] = (q: QueryAndGrouping) => ( | |||
export const toUrlSearchParams = (q: QueryAndGrouping) => ( | |||
new URLSearchParams( | |||
q.expressions.flatMap((ex) => ( | |||
ex.expressions.map((ex2) => serializeExpression(ex2)) | |||
)) | |||
)) as [string, string][] | |||
) | |||
.toString() | |||
); |
@@ -0,0 +1,21 @@ | |||
import {Backend, DataSource} from '../backend'; | |||
import {Operation} from './operation'; | |||
import {App} from './app'; | |||
import {Endpoint} from './endpoint'; | |||
export interface RecipeState<AppName extends string = string, A extends App<AppName> = App<AppName>> { | |||
app: A; | |||
backend?: Backend<A>; | |||
dataSource?: DataSource; | |||
operations?: Record<string, Operation>; | |||
endpoints?: Record<string, Endpoint>; | |||
} | |||
export type Recipe<SA extends string = string, A extends App<SA> = App<SA>, B extends A = A> = (a: RecipeState<SA, A>) => RecipeState<SA, B>; | |||
export const composeRecipes = (recipes: Recipe[]): Recipe => (params) => ( | |||
recipes.reduce( | |||
(rr, r) => r(rr), | |||
params | |||
) | |||
); |
@@ -1,205 +0,0 @@ | |||
import * as v from 'valibot'; | |||
import {PatchContentType} from './media-type'; | |||
import {DataSource, ResourceIdConfig} from '../backend/data-source'; | |||
export const CAN_PATCH_VALID_VALUES = ['merge', 'delta'] as const; | |||
export type CanPatchSpec = typeof CAN_PATCH_VALID_VALUES[number]; | |||
export const PATCH_CONTENT_MAP_TYPE: Record<PatchContentType, CanPatchSpec> = { | |||
'application/merge-patch+json': 'merge', | |||
'application/json-patch+json': 'delta', | |||
}; | |||
type CanPatchObject = Record<CanPatchSpec, boolean>; | |||
export interface Relationship<SubjectSchema extends v.BaseSchema, ObjectSchema extends v.BaseSchema> { | |||
objectResource: Resource<BaseResourceType & { schema: ObjectSchema }>, | |||
name: string; | |||
// points to object ID | |||
subjectAttr: string; | |||
} | |||
export interface ResourceState< | |||
ItemName extends string = string, | |||
RouteName extends string = string | |||
> { | |||
shared: Map<string, unknown>; | |||
relationships: Map<string, Relationship<any, any>>; | |||
itemName: ItemName; | |||
routeName: RouteName; | |||
canCreate: boolean; | |||
canFetchCollection: boolean; | |||
canFetchItem: boolean; | |||
canPatch: CanPatchObject; | |||
canEmplace: boolean; | |||
canDelete: boolean; | |||
} | |||
type CanPatch = boolean | Partial<CanPatchObject> | CanPatchSpec[]; | |||
export interface BaseResourceType { | |||
schema: v.BaseSchema; | |||
name: string; | |||
routeName: string; | |||
idAttr: string; | |||
idSchema: v.BaseSchema; | |||
createdAtAttr: string; | |||
updatedAtAttr: string; | |||
} | |||
export interface Resource<ResourceType extends BaseResourceType = BaseResourceType> { | |||
schema: ResourceType['schema']; | |||
state: ResourceState<ResourceType['name'], ResourceType['routeName']>; | |||
name<NewName extends ResourceType['name']>(n: NewName): Resource<ResourceType & { name: NewName }>; | |||
route<NewRouteName extends ResourceType['routeName']>(n: NewRouteName): Resource<ResourceType & { routeName: NewRouteName }>; | |||
canFetchCollection(b?: boolean): this; | |||
canFetchItem(b?: boolean): this; | |||
canCreate(b?: boolean): this; | |||
canPatch(b?: CanPatch): this; | |||
canEmplace(b?: boolean): this; | |||
canDelete(b?: boolean): this; | |||
relatesTo<RelatedSchema extends v.BaseSchema>( | |||
resource: Resource<ResourceType & { schema: RelatedSchema }>, | |||
relationshipParams: Relationship<ResourceType['schema'], RelatedSchema>, | |||
): this; | |||
dataSource?: DataSource; | |||
id<NewIdAttr extends ResourceType['idAttr'], TheIdSchema extends ResourceType['idSchema']>( | |||
newIdAttr: NewIdAttr, | |||
params: ResourceIdConfig<TheIdSchema> | |||
): Resource<ResourceType & { idAttr: NewIdAttr, idSchema: TheIdSchema }>; | |||
addMetadata(id: string, value: unknown): this; | |||
setMetadata(id: string, value: unknown): this; | |||
createdAt<NewCreatedAtAttr extends ResourceType['createdAtAttr']>(n: NewCreatedAtAttr): Resource<ResourceType & { createdAtAttr: NewCreatedAtAttr }>; | |||
updatedAt<NewUpdatedAtAttr extends ResourceType['updatedAtAttr']>(n: NewUpdatedAtAttr): Resource<ResourceType & { updatedAtAttr: NewUpdatedAtAttr }>; | |||
} | |||
export const resource = <ResourceType extends BaseResourceType = BaseResourceType>(schema: ResourceType['schema']): Resource<ResourceType> => { | |||
const resourceState = { | |||
shared: new Map(), | |||
relationships: new Map<string, Relationship<any, any>>(), | |||
canCreate: false, | |||
canFetchCollection: false, | |||
canFetchItem: false, | |||
canPatch: { | |||
merge: false, | |||
delta: false, | |||
}, | |||
canEmplace: false, | |||
canDelete: false, | |||
} as ResourceState<ResourceType['name'], ResourceType['routeName']>; | |||
return { | |||
get state(): ResourceState<ResourceType['name'], ResourceType['routeName']> { | |||
return Object.freeze({ | |||
...resourceState, | |||
}); | |||
}, | |||
canFetchCollection(b = true) { | |||
resourceState.canFetchCollection = b; | |||
return this; | |||
}, | |||
canFetchItem(b = true) { | |||
resourceState.canFetchItem = b; | |||
return this; | |||
}, | |||
canCreate(b = true) { | |||
resourceState.canCreate = b; | |||
return this; | |||
}, | |||
canPatch(b = true as CanPatch) { | |||
if (typeof b === 'boolean') { | |||
resourceState.canPatch.merge = b; | |||
resourceState.canPatch.delta = b; | |||
return this; | |||
} | |||
if (typeof b === 'object') { | |||
if (Array.isArray(b)) { | |||
CAN_PATCH_VALID_VALUES.forEach((p) => { | |||
resourceState.canPatch[p] = b.includes(p); | |||
}); | |||
return this; | |||
} | |||
if (b !== null) { | |||
CAN_PATCH_VALID_VALUES.forEach((p) => { | |||
resourceState.canPatch[p] = b[p] ?? false; | |||
}); | |||
} | |||
} | |||
return this; | |||
}, | |||
canEmplace(b = true) { | |||
resourceState.canEmplace = b; | |||
return this; | |||
}, | |||
canDelete(b = true) { | |||
resourceState.canDelete = b; | |||
return this; | |||
}, | |||
id(idName, config) { | |||
resourceState.shared.set('idAttr', idName); | |||
resourceState.shared.set('idConfig', config); | |||
return this; | |||
}, | |||
addMetadata(key: string, value: unknown) { | |||
const fullTextAttrs = (resourceState.shared.get(key) ?? new Set()) as Set<unknown>; | |||
fullTextAttrs.add(value); | |||
this.setMetadata(key, fullTextAttrs); | |||
return this; | |||
}, | |||
setMetadata(key: string, value: unknown) { | |||
resourceState.shared.set(key, value); | |||
return this; | |||
}, | |||
name<NewName extends ResourceType['name']>(n: NewName) { | |||
resourceState.itemName = n; | |||
return this; | |||
}, | |||
route<NewRouteName extends ResourceType['routeName']>(n: NewRouteName) { | |||
resourceState.routeName = n; | |||
return this; | |||
}, | |||
get itemName() { | |||
return resourceState.itemName; | |||
}, | |||
get routeName() { | |||
return resourceState.routeName; | |||
}, | |||
get schema() { | |||
return schema; | |||
}, | |||
relatesTo<RelatedSchema extends v.BaseSchema>( | |||
objectResource: Resource<ResourceType & { schema: RelatedSchema }>, | |||
relationshipParams: Relationship<ResourceType['schema'], RelatedSchema>, | |||
) { | |||
resourceState.relationships.set(relationshipParams.name, { | |||
...relationshipParams, | |||
objectResource, | |||
}); | |||
return this; | |||
}, | |||
createdAt<NewCreatedAtAttr extends ResourceType['createdAtAttr']>(n: NewCreatedAtAttr) { | |||
resourceState.shared.set('createdAtAttr', n); | |||
return this; | |||
}, | |||
updatedAt<NewUpdatedAtAttr extends ResourceType['updatedAtAttr']>(n: NewUpdatedAtAttr) { | |||
resourceState.shared.set('updatedAtAttr', n); | |||
return this; | |||
}, | |||
} as Resource<ResourceType>; | |||
}; | |||
export type ResourceType<R extends Resource> = v.Output<R['schema']>; | |||
export const getAcceptPatchString = (canPatch: CanPatchObject) => { | |||
const validPatchTypes = Object.entries(canPatch) | |||
.filter(([, allowed]) => allowed) | |||
.map(([patchType]) => patchType); | |||
return Object.entries(PATCH_CONTENT_MAP_TYPE) | |||
.filter(([, patchType]) => validPatchTypes.includes(patchType)) | |||
.map(([contentType ]) => contentType) | |||
.join(','); | |||
} |
@@ -0,0 +1,99 @@ | |||
import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; | |||
import {parseAcceptString, parseMediaType} from './media-type'; | |||
type FetchResponse = Awaited<ReturnType<typeof fetch>>; | |||
export interface Response<B extends unknown = unknown> { | |||
statusCode: number; | |||
statusMessage: string; | |||
body?: B; | |||
} | |||
export interface ErrorResponse<B extends unknown = unknown> extends Error, Response<B> {} | |||
export interface HttpResponseConstructor<R extends Response> { | |||
new (...args: any[]): R; | |||
fromFetchResponse(response: FetchResponse): R; | |||
} | |||
export interface HttpResponseErrorConstructor<R extends Response> extends HttpResponseConstructor<R> { | |||
new (message?: string, options?: ErrorOptions): R; | |||
} | |||
export interface HttpSuccessResponseConstructor<R extends Response> extends HttpResponseConstructor<R> { | |||
new (response: Partial<Omit<Response, 'statusCode'>>): R; | |||
} | |||
export interface HttpErrorOptions<B extends unknown = unknown> extends ErrorOptions { | |||
body?: B; | |||
} | |||
export const HttpResponse = < | |||
B extends unknown = unknown, | |||
T extends StatusCode = StatusCode, | |||
R extends Response = T extends ErrorStatusCode ? ErrorResponse<B> : Response<B>, | |||
>(statusCode: T): T extends ErrorStatusCode ? HttpResponseErrorConstructor<R> : HttpSuccessResponseConstructor<R> => { | |||
if (isErrorStatusCode(statusCode)) { | |||
return class HttpErrorResponse extends Error implements ErrorResponse<B> { | |||
readonly statusMessage: string; | |||
readonly statusCode: T; | |||
readonly body?: B; | |||
constructor(message?: string, options?: HttpErrorOptions<B>) { | |||
super(message, options); | |||
this.name = this.statusMessage = message ?? ''; | |||
this.statusCode = statusCode; | |||
this.cause = options?.cause; | |||
this.body = options?.body; | |||
} | |||
} as unknown as HttpResponseErrorConstructor<R>; | |||
} | |||
return class HttpSuccessResponse implements Response<B> { | |||
readonly statusMessage: string; | |||
readonly statusCode: T; | |||
readonly body?: B; | |||
constructor(params: Partial<Omit<Response<B>, 'statusCode'>>) { | |||
this.statusCode = statusCode; | |||
this.statusMessage = params.statusMessage ?? ''; | |||
this.body = params.body; | |||
} | |||
static fromFetchResponse(response: FetchResponse) { | |||
return { | |||
statusCode: response.status, | |||
statusMessage: response.statusText, | |||
deserialize: async () => { | |||
if (response.status !== statusCode) { | |||
throw new Error(`Status codes do not match: ${response.status} !== ${statusCode}`); | |||
} | |||
const contentType = response.headers.get('Content-Type'); | |||
if (typeof contentType !== 'string') { | |||
return await response.arrayBuffer(); | |||
} | |||
const parsedAcceptString = parseAcceptString(contentType); | |||
if (typeof parsedAcceptString?.mediaType !== 'string') { | |||
return await response.arrayBuffer(); | |||
} | |||
const parsedMediaType = parseMediaType(parsedAcceptString.mediaType); | |||
const isJson = ( | |||
['application', 'text'].includes(parsedMediaType.type) | |||
&& parsedMediaType.subtype.at(-1) === 'json' | |||
); | |||
if (isJson) { | |||
return await response.json(); | |||
} | |||
if (parsedMediaType.type === 'text') { | |||
return await response.text(); | |||
} | |||
return await response.arrayBuffer(); | |||
}, | |||
}; | |||
} | |||
} as unknown as HttpSuccessResponseConstructor<R>; | |||
}; |
@@ -0,0 +1,5 @@ | |||
export interface ServiceParams { | |||
host?: string; | |||
port?: number; | |||
basePath?: string; | |||
} |
@@ -0,0 +1,205 @@ | |||
// https://github.com/prettymuchbryce/http-status-codes | |||
export const HTTP_STATUS_CONTINUE = 100 as const; | |||
export const HTTP_STATUS_SWITCHING_PROTOCOLS = 101 as const; | |||
export const HTTP_STATUS_PROCESSING = 102 as const; | |||
export const HTTP_STATUS_EARLY_HINTS = 103 as const; | |||
export const IN_TRANSIT_STATUS_CODES = [ | |||
HTTP_STATUS_CONTINUE, | |||
HTTP_STATUS_SWITCHING_PROTOCOLS, | |||
HTTP_STATUS_PROCESSING, | |||
HTTP_STATUS_EARLY_HINTS, | |||
] as const; | |||
export type InTransitStatusCode = typeof IN_TRANSIT_STATUS_CODES[number]; | |||
export const isInTransitStatusCode = (statusCode: StatusCode): statusCode is InTransitStatusCode => ( | |||
IN_TRANSIT_STATUS_CODES.includes(statusCode as InTransitStatusCode) | |||
); | |||
export const HTTP_STATUS_OK = 200 as const; | |||
export const HTTP_STATUS_CREATED = 201 as const; | |||
export const HTTP_STATUS_ACCEPTED = 202 as const; | |||
export const HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203 as const; | |||
export const HTTP_STATUS_NO_CONTENT = 204 as const; | |||
export const HTTP_STATUS_RESET_CONTENT = 205 as const; | |||
export const HTTP_STATUS_PARTIAL_CONTENT = 206 as const; | |||
export const HTTP_STATUS_MULTI_STATUS = 207 as const; | |||
export const HTTP_STATUS_ALREADY_REPORTED = 208 as const; | |||
export const HTTP_STATUS_IM_USED = 226 as const; | |||
export const RESOLVED_STATUS_CODES = [ | |||
HTTP_STATUS_OK, | |||
HTTP_STATUS_CREATED, | |||
HTTP_STATUS_ACCEPTED, | |||
HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION, | |||
HTTP_STATUS_NO_CONTENT, | |||
HTTP_STATUS_RESET_CONTENT, | |||
HTTP_STATUS_PARTIAL_CONTENT, | |||
HTTP_STATUS_MULTI_STATUS, | |||
HTTP_STATUS_ALREADY_REPORTED, | |||
HTTP_STATUS_IM_USED, | |||
] as const; | |||
export type ResolvedStatusCode = typeof RESOLVED_STATUS_CODES[number]; | |||
export const isResolvedStatusCode = (statusCode: StatusCode): statusCode is ResolvedStatusCode => ( | |||
RESOLVED_STATUS_CODES.includes(statusCode as ResolvedStatusCode) | |||
); | |||
export const HTTP_STATUS_MULTIPLE_CHOICES = 300 as const; | |||
export const HTTP_STATUS_MOVED_PERMANENTLY = 301 as const; | |||
export const HTTP_STATUS_FOUND = 302 as const; | |||
export const HTTP_STATUS_SEE_OTHER = 303 as const; | |||
export const HTTP_STATUS_NOT_MODIFIED = 304 as const; | |||
export const HTTP_STATUS_USE_PROXY = 305 as const; | |||
export const HTTP_STATUS_TEMPORARY_REDIRECT = 307 as const; | |||
export const HTTP_STATUS_PERMANENT_REDIRECT = 308 as const; | |||
export const REDIRECT_STATUS_CODES = [ | |||
HTTP_STATUS_MULTIPLE_CHOICES, | |||
HTTP_STATUS_MOVED_PERMANENTLY, | |||
HTTP_STATUS_FOUND, | |||
HTTP_STATUS_SEE_OTHER, | |||
HTTP_STATUS_NOT_MODIFIED, | |||
HTTP_STATUS_USE_PROXY, | |||
HTTP_STATUS_TEMPORARY_REDIRECT, | |||
HTTP_STATUS_PERMANENT_REDIRECT, | |||
] as const; | |||
export type RedirectStatusCode = typeof REDIRECT_STATUS_CODES[number]; | |||
export const isRedirectStatusCode = (statusCode: StatusCode): statusCode is RedirectStatusCode => ( | |||
REDIRECT_STATUS_CODES.includes(statusCode as RedirectStatusCode) | |||
); | |||
export const SUCCESS_STATUS_CODES = [ | |||
...RESOLVED_STATUS_CODES, | |||
...REDIRECT_STATUS_CODES, | |||
]; | |||
export type SuccessStatusCode = typeof SUCCESS_STATUS_CODES[number]; | |||
export const isSuccessStatusCode = (statusCode: StatusCode): statusCode is SuccessStatusCode => ( | |||
SUCCESS_STATUS_CODES.includes(statusCode as SuccessStatusCode) | |||
); | |||
export const HTTP_STATUS_BAD_REQUEST = 400 as const; | |||
export const HTTP_STATUS_UNAUTHORIZED = 401 as const; | |||
export const HTTP_STATUS_PAYMENT_REQUIRED = 402 as const; | |||
export const HTTP_STATUS_FORBIDDEN = 403 as const; | |||
export const HTTP_STATUS_NOT_FOUND = 404 as const; | |||
export const HTTP_STATUS_METHOD_NOT_ALLOWED = 405 as const; | |||
export const HTTP_STATUS_NOT_ACCEPTABLE = 406 as const; | |||
export const HTTP_STATUS_PROXY_AUTHENTICATION_REQUIRED = 407 as const; | |||
export const HTTP_STATUS_REQUEST_TIMEOUT = 408 as const; | |||
export const HTTP_STATUS_CONFLICT = 409 as const; | |||
export const HTTP_STATUS_GONE = 410 as const; | |||
export const HTTP_STATUS_LENGTH_REQUIRED = 411 as const; | |||
export const HTTP_STATUS_PRECONDITION_FAILED = 412 as const; | |||
export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413 as const; | |||
export const HTTP_STATUS_URI_TOO_LONG = 414 as const; | |||
export const HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE = 415 as const; | |||
export const HTTP_STATUS_RANGE_NOT_SATISFIABLE = 416 as const; | |||
export const HTTP_STATUS_EXPECTATION_FAILED = 417 as const; | |||
export const HTTP_STATUS_TEAPOT = 418 as const; | |||
export const HTTP_STATUS_INSUFFICIENT_SPACE_ON_RESOURCE = 419 as const; | |||
export const HTTP_STATUS_METHOD_FAILURE = 420 as const; | |||
export const HTTP_STATUS_MISDIRECTED_REQUEST = 421 as const; | |||
export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422 as const; | |||
export const HTTP_STATUS_LOCKED = 423 as const; | |||
export const HTTP_STATUS_FAILED_DEPENDENCY = 424 as const; | |||
export const HTTP_STATUS_TOO_EARLY = 425 as const; | |||
export const HTTP_STATUS_UPGRADE_REQUIRED = 426 as const; | |||
export const HTTP_STATUS_PRECONDITION_REQUIRED = 428 as const; | |||
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429 as const; | |||
export const HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 as const; | |||
export const HTTP_STATUS_UNAVAILABLE_FOR_LEGAL_REASONS = 451 as const; | |||
export const CLIENT_ERROR_STATUS_CODES = [ | |||
HTTP_STATUS_BAD_REQUEST, | |||
HTTP_STATUS_UNAUTHORIZED, | |||
HTTP_STATUS_PAYMENT_REQUIRED, | |||
HTTP_STATUS_FORBIDDEN, | |||
HTTP_STATUS_NOT_FOUND, | |||
HTTP_STATUS_METHOD_NOT_ALLOWED, | |||
HTTP_STATUS_NOT_ACCEPTABLE, | |||
HTTP_STATUS_PROXY_AUTHENTICATION_REQUIRED, | |||
HTTP_STATUS_REQUEST_TIMEOUT, | |||
HTTP_STATUS_CONFLICT, | |||
HTTP_STATUS_GONE, | |||
HTTP_STATUS_LENGTH_REQUIRED, | |||
HTTP_STATUS_PRECONDITION_FAILED, | |||
HTTP_STATUS_PAYLOAD_TOO_LARGE, | |||
HTTP_STATUS_URI_TOO_LONG, | |||
HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, | |||
HTTP_STATUS_RANGE_NOT_SATISFIABLE, | |||
HTTP_STATUS_EXPECTATION_FAILED, | |||
HTTP_STATUS_TEAPOT, | |||
HTTP_STATUS_INSUFFICIENT_SPACE_ON_RESOURCE, | |||
HTTP_STATUS_METHOD_FAILURE, | |||
HTTP_STATUS_MISDIRECTED_REQUEST, | |||
HTTP_STATUS_UNPROCESSABLE_ENTITY, | |||
HTTP_STATUS_LOCKED, | |||
HTTP_STATUS_FAILED_DEPENDENCY, | |||
HTTP_STATUS_TOO_EARLY, | |||
HTTP_STATUS_UPGRADE_REQUIRED, | |||
HTTP_STATUS_PRECONDITION_REQUIRED, | |||
HTTP_STATUS_TOO_MANY_REQUESTS, | |||
HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE, | |||
HTTP_STATUS_UNAVAILABLE_FOR_LEGAL_REASONS, | |||
] as const; | |||
export type ClientErrorStatusCode = typeof CLIENT_ERROR_STATUS_CODES[number]; | |||
export const isClientErrorStatusCode = (statusCode: StatusCode): statusCode is ClientErrorStatusCode => ( | |||
CLIENT_ERROR_STATUS_CODES.includes(statusCode as ClientErrorStatusCode) | |||
); | |||
export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 as const; | |||
export const HTTP_STATUS_NOT_IMPLEMENTED = 501 as const; | |||
export const HTTP_STATUS_BAD_GATEWAY = 502 as const; | |||
export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503 as const; | |||
export const HTTP_STATUS_GATEWAY_TIMEOUT = 504 as const; | |||
export const HTTP_STATUS_HTTP_VERSION_NOT_SUPPORTED = 505 as const; | |||
export const HTTP_STATUS_VARIANT_ALSO_NEGOTIATES = 506 as const; | |||
export const HTTP_STATUS_INSUFFICIENT_STORAGE = 507 as const; | |||
export const HTTP_STATUS_LOOP_DETECTED = 508 as const; | |||
export const HTTP_STATUS_BANDWIDTH_LIMIT_EXCEEDED = 509 as const; | |||
export const HTTP_STATUS_NOT_EXTENDED = 510 as const; | |||
export const HTTP_STATUS_NETWORK_AUTHENTICATION_REQUIRED = 511 as const; | |||
export const SERVER_ERROR_STATUS_CODES = [ | |||
HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
HTTP_STATUS_NOT_IMPLEMENTED, | |||
HTTP_STATUS_BAD_GATEWAY, | |||
HTTP_STATUS_SERVICE_UNAVAILABLE, | |||
HTTP_STATUS_GATEWAY_TIMEOUT, | |||
HTTP_STATUS_HTTP_VERSION_NOT_SUPPORTED, | |||
HTTP_STATUS_VARIANT_ALSO_NEGOTIATES, | |||
HTTP_STATUS_INSUFFICIENT_STORAGE, | |||
HTTP_STATUS_LOOP_DETECTED, | |||
HTTP_STATUS_BANDWIDTH_LIMIT_EXCEEDED, | |||
HTTP_STATUS_NOT_EXTENDED, | |||
HTTP_STATUS_NETWORK_AUTHENTICATION_REQUIRED, | |||
] as const; | |||
export type ServerErrorStatusCode = typeof SERVER_ERROR_STATUS_CODES[number]; | |||
export const isServerErrorStatusCode = (statusCode: StatusCode): statusCode is ServerErrorStatusCode => ( | |||
SERVER_ERROR_STATUS_CODES.includes(statusCode as ServerErrorStatusCode) | |||
); | |||
export const ERROR_STATUS_CODES = [ | |||
...CLIENT_ERROR_STATUS_CODES, | |||
...SERVER_ERROR_STATUS_CODES, | |||
] as const; | |||
export type ErrorStatusCode = typeof ERROR_STATUS_CODES[number]; | |||
export const isErrorStatusCode = (statusCode: StatusCode): statusCode is ErrorStatusCode => ( | |||
ERROR_STATUS_CODES.includes(statusCode as ErrorStatusCode) | |||
); | |||
export type StatusCode = InTransitStatusCode | SuccessStatusCode | ErrorStatusCode; |
@@ -1 +0,0 @@ | |||
export * from 'valibot'; |
@@ -1,271 +0,0 @@ | |||
import {describe, expect, it} from 'vitest'; | |||
import { queryMediaTypes } from '../../src/common'; | |||
describe('query', () => { | |||
it('returns a data source query collection from search params', () => { | |||
const q = new URLSearchParams(); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize(q.toString()); | |||
expect(collection.expressions).toBeInstanceOf(Array); | |||
}); | |||
it('coerces a numeric value', () => { | |||
const q = new URLSearchParams({ | |||
attr: '2', | |||
attr2: '2', | |||
}); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||
q.toString(), | |||
{ | |||
processEntries: { | |||
attr: { | |||
type: 'number' | |||
} | |||
}, | |||
}, | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 2, | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: '2', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
it('coerces a boolean value', () => { | |||
const q = new URLSearchParams({ | |||
attr: 'true', | |||
attr2: 'false', | |||
attr3: 'true', | |||
attr4: 'false', | |||
}); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||
q.toString(), | |||
{ | |||
processEntries: { | |||
attr: { | |||
type: 'boolean', | |||
}, | |||
attr2: { | |||
type: 'boolean', | |||
} | |||
}, | |||
}, | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: true, | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: false, | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr3', | |||
operator: '=', | |||
rhs: 'true', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr4', | |||
operator: '=', | |||
rhs: 'false', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
it('returns an equal query', () => { | |||
const q = new URLSearchParams({ | |||
attr: 'foo' | |||
}); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||
q.toString() | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
it('returns an AND operator query', () => { | |||
const q = new URLSearchParams([ | |||
['attr', 'foo'], | |||
['attr2', 'bar'], | |||
]); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||
q.toString() | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: 'bar', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
it('returns an OR operator query', () => { | |||
const q = new URLSearchParams([ | |||
['attr', 'foo'], | |||
['attr', 'bar'], | |||
]); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||
q.toString() | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'bar', | |||
} | |||
] | |||
}, | |||
], | |||
}); | |||
}); | |||
it('returns an query with appropriate grouping', () => { | |||
const q = new URLSearchParams([ | |||
['attr3', 'quux'], | |||
['attr', 'foo'], | |||
['attr4', 'quuux'], | |||
['attr', 'bar'], | |||
['attr2', 'baz'], | |||
]); | |||
const collection = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | |||
q.toString() | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr3', | |||
operator: '=', | |||
rhs: 'quux', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'bar', | |||
} | |||
] | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr4', | |||
operator: '=', | |||
rhs: 'quuux', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: 'baz', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
}); |
@@ -1,480 +0,0 @@ | |||
import {IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http'; | |||
import {Method, DataSource} from '../src/backend'; | |||
import {FALLBACK_LANGUAGE, Language} from '../src/common'; | |||
interface ClientParams { | |||
method: Method; | |||
path: string; | |||
headers?: IncomingHttpHeaders; | |||
body?: unknown; | |||
} | |||
type ResponseBody = Buffer | string | object; | |||
export interface TestClient { | |||
(params: ClientParams): Promise<[IncomingMessage, ResponseBody?]>; | |||
acceptMediaType(mediaType: string): this; | |||
acceptLanguage(language: string): this; | |||
acceptCharset(charset: string): this; | |||
contentType(mediaType: string): this; | |||
contentCharset(charset: string): this; | |||
} | |||
export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'>): TestClient => { | |||
const additionalHeaders: OutgoingHttpHeaders = {}; | |||
const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => { | |||
const { | |||
...etcAdditionalHeaders | |||
} = additionalHeaders; | |||
// odd that request() uses OutgoingHttpHeaders instead of IncomingHttpHeaders... | |||
const headers: OutgoingHttpHeaders = { | |||
...(options.headers ?? {}), | |||
...etcAdditionalHeaders, | |||
...(params.headers ?? {}), | |||
}; | |||
let contentTypeHeader: string | undefined; | |||
if (typeof params.body !== 'undefined') { | |||
contentTypeHeader = headers['content-type'] = params.headers?.['content-type'] ?? 'application/json'; | |||
} | |||
const req = request({ | |||
...options, | |||
method: params.method, | |||
path: params.path, | |||
headers, | |||
}); | |||
req.on('response', (res) => { | |||
// if (req.method.toUpperCase() === 'QUERY') { | |||
// res.statusMessage = ''; | |||
// res.statusCode = 200; | |||
// } | |||
res.on('error', (err) => { | |||
reject(err); | |||
}); | |||
let resBuffer: Buffer | undefined; | |||
res.on('data', (c) => { | |||
resBuffer = ( | |||
typeof resBuffer === 'undefined' | |||
? Buffer.from(c) | |||
: Buffer.concat([resBuffer, c]) | |||
); | |||
}); | |||
res.on('close', () => { | |||
const acceptHeader = Array.isArray(headers['accept']) ? headers['accept'].join('; ') : headers['accept']; | |||
const contentTypeBase = acceptHeader ?? 'application/octet-stream'; | |||
const [type, subtype] = contentTypeBase.split('/'); | |||
const allSubtypes = subtype.split('+'); | |||
if (typeof resBuffer !== 'undefined') { | |||
if (allSubtypes.includes('json')) { | |||
const acceptCharset = ( | |||
Array.isArray(headers['accept-charset']) | |||
? headers['accept-charset'].join('; ') | |||
: headers['accept-charset'] | |||
) as BufferEncoding | undefined; | |||
resolve([res, JSON.parse(resBuffer.toString(acceptCharset ?? 'utf-8'))]); | |||
return; | |||
} | |||
if (type === 'text') { | |||
const acceptCharset = ( | |||
Array.isArray(headers['accept-charset']) | |||
? headers['accept-charset'].join('; ') | |||
: headers['accept-charset'] | |||
) as BufferEncoding | undefined; | |||
resolve([res, resBuffer.toString(acceptCharset ?? 'utf-8')]); | |||
return; | |||
} | |||
resolve([res, resBuffer]); | |||
return; | |||
} | |||
resolve([res]); | |||
}); | |||
}); | |||
req.on('error', (err) => { | |||
reject(err); | |||
}) | |||
if (typeof params.body !== 'undefined') { | |||
const theContentTypeHeader = Array.isArray(contentTypeHeader) ? contentTypeHeader.join('; ') : contentTypeHeader?.toString(); | |||
const contentTypeAll = theContentTypeHeader ?? 'application/octet-stream'; | |||
const [contentTypeBase, ...contentTypeParams] = contentTypeAll.split(';').map((s) => s.replace(/\s+/g, '').trim()); | |||
const charsetParam = contentTypeParams.find((s) => s.startsWith('charset=')); | |||
const charset = charsetParam?.split('=')?.[1] as BufferEncoding | undefined; | |||
const [, subtype] = contentTypeBase.split('/'); | |||
const allSubtypes = subtype.split('+'); | |||
req.write( | |||
allSubtypes.includes('json') | |||
? JSON.stringify(params.body) | |||
: Buffer.from(params.body?.toString() ?? '', contentTypeBase === 'text' ? charset : undefined) | |||
); | |||
} | |||
req.end(); | |||
}); | |||
client.acceptMediaType = function acceptMediaType(mediaType: string) { | |||
additionalHeaders['accept'] = mediaType; | |||
return this; | |||
}; | |||
client.acceptLanguage = function acceptLanguage(language: string) { | |||
additionalHeaders['accept-language'] = language; | |||
return this; | |||
}; | |||
client.acceptCharset = function acceptCharset(charset: string) { | |||
additionalHeaders['accept-charset'] = charset; | |||
return this; | |||
}; | |||
client.contentType = function contentType(mediaType: string) { | |||
additionalHeaders['content-type'] = mediaType; | |||
return this; | |||
}; | |||
client.contentCharset = function contentCharset(charset: string) { | |||
additionalHeaders['content-type'] = `${additionalHeaders['content-type']}; charset="${charset}"`; | |||
return this; | |||
}; | |||
return client; | |||
}; | |||
export const dummyGenerationStrategy = () => Promise.resolve(); | |||
export class DummyError extends Error {} | |||
export class DummyDataSource implements DataSource { | |||
private resource?: { dataSource?: unknown }; | |||
async create(): Promise<object> { | |||
return {}; | |||
} | |||
async delete(): Promise<void> {} | |||
async emplace(): Promise<[object, boolean]> { | |||
return [{}, false]; | |||
} | |||
async getById(): Promise<object> { | |||
return {}; | |||
} | |||
async newId(): Promise<string> { | |||
return ''; | |||
} | |||
async getMultiple(): Promise<object[]> { | |||
return []; | |||
} | |||
async getSingle(): Promise<object> { | |||
return {}; | |||
} | |||
async getTotalCount(): Promise<number> { | |||
return 0; | |||
} | |||
async initialize(): Promise<void> {} | |||
async patch(): Promise<object> { | |||
return {}; | |||
} | |||
prepareResource(rr: unknown) { | |||
this.resource = rr as unknown as { dataSource: DummyDataSource }; | |||
this.resource.dataSource = this; | |||
} | |||
} | |||
export const TEST_LANGUAGE: Language = { | |||
name: FALLBACK_LANGUAGE.name, | |||
statusMessages: { | |||
resourceCollectionQueried: '$Resource Collection Queried', | |||
unableToSerializeResponse: 'Unable To Serialize Response', | |||
unableToEncodeResponse: 'Unable To Encode Response', | |||
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source', | |||
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | |||
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | |||
unableToFetchResource: 'Unable To Fetch $RESOURCE', | |||
unableToDeleteResource: 'Unable To Delete $RESOURCE', | |||
languageNotAcceptable: 'Language Not Acceptable', | |||
characterSetNotAcceptable: 'Character Set Not Acceptable', | |||
unableToDeserializeResource: 'Unable To Deserialize $RESOURCE', | |||
unableToDecodeResource: 'Unable To Decode $RESOURCE', | |||
mediaTypeNotAcceptable: 'Media Type Not Acceptable', | |||
methodNotAllowed: 'Method Not Allowed', | |||
urlNotFound: 'URL Not Found', | |||
badRequest: 'Bad Request', | |||
ok: 'OK', | |||
provideOptions: 'Provide Options', | |||
resourceCollectionFetched: '$RESOURCE Collection Fetched', | |||
resourceFetched: '$RESOURCE Fetched', | |||
resourceNotFound: '$RESOURCE Not Found', | |||
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', | |||
resourceDeleted: '$RESOURCE Deleted', | |||
unableToDeserializeRequest: 'Unable To Deserialize Request', | |||
patchNonExistingResource: 'Patch Non-Existing $RESOURCE', | |||
unableToPatchResource: 'Unable To Patch $RESOURCE', | |||
invalidResourcePatch: 'Invalid $RESOURCE Patch', | |||
invalidResourcePatchType: 'Invalid $RESOURCE Patch Type', | |||
invalidResource: 'Invalid $RESOURCE', | |||
resourcePatched: '$RESOURCE Patched', | |||
resourceCreated: '$RESOURCE Created', | |||
resourceReplaced: '$RESOURCE Replaced', | |||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | |||
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source', | |||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | |||
resourceIdNotGiven: '$RESOURCE ID Not Given', | |||
unableToCreateResource: 'Unable To Create $RESOURCE', | |||
notImplemented: 'Not Implemented', | |||
internalServerError: 'Internal Server Error', | |||
}, | |||
bodies: { | |||
badRequest: [ | |||
'An invalid request has been made.', | |||
[ | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
'Check if the request is appropriate for this endpoint.', | |||
], | |||
], | |||
languageNotAcceptable: [ | |||
'The server could not process a response suitable for the client\'s provided language requirement.', | |||
[ | |||
'Choose from the available languages on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
characterSetNotAcceptable: [ | |||
'The server could not process a response suitable for the client\'s provided character set requirement.', | |||
[ | |||
'Choose from the available character sets on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
mediaTypeNotAcceptable: [ | |||
'The server could not process a response suitable for the client\'s provided media type requirement.', | |||
[ | |||
'Choose from the available media types on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
deleteNonExistingResource: [ | |||
'The client has attempted to delete a resource that does not exist.', | |||
[ | |||
'Ensure that the resource still exists.', | |||
'Ensure that the correct method is provided.', | |||
], | |||
], | |||
internalServerError: [ | |||
'An unknown error has occurred within the service.', | |||
[ | |||
'Try the request again at a later time.', | |||
'Contact the administrator if the service remains in a degraded or non-functional state.', | |||
], | |||
], | |||
invalidResource: [ | |||
'The request has an invalid structure or is missing some attributes.', | |||
[ | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
], | |||
], | |||
invalidResourcePatch: [ | |||
'The request has an invalid patch data.', | |||
[ | |||
'Check if the appropriate patch type is specified on the request data.', | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
], | |||
], | |||
invalidResourcePatchType: [ | |||
'The request has an invalid or unsupported kind of patch data.', | |||
[ | |||
'Check if the appropriate patch type is specified on the request data.', | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
], | |||
], | |||
methodNotAllowed: [ | |||
'A request with an invalid or unsupported method has been made.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Check if the client is authorized to perform the method on this endpoint.', | |||
] | |||
], | |||
notImplemented: [ | |||
'The service does not have any implementation for the accessed endpoint.', | |||
[ | |||
'Try the request again at a later time.', | |||
'Contact the administrator if the service remains in a degraded or non-functional state.', | |||
], | |||
], | |||
patchNonExistingResource: [ | |||
'The client has attempted to patch a resource that does not exist.', | |||
[ | |||
'Ensure that the resource still exists.', | |||
'Ensure that the correct method is provided.', | |||
], | |||
], | |||
resourceIdNotGiven: [ | |||
'The resource ID is not provided for the accessed endpoint.', | |||
[ | |||
'Check if the resource ID is provided and valid in the URL.', | |||
'Check if the request method is appropriate for this endpoint.', | |||
], | |||
], | |||
unableToAssignIdFromResourceDataSource: [ | |||
'The resource could not be assigned an ID from the associated data source.', | |||
[ | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToBindResourceDataSource: [ | |||
'The resource could not be associated from the data source.', | |||
[ | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToCreateResource: [ | |||
'An error has occurred on creating the resource.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToDecodeResource: [ | |||
'The resource byte array could not be decoded for the provided character set.', | |||
[ | |||
'Choose from the available character sets on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
unableToDeleteResource: [ | |||
'An error has occurred on deleting the resource.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToDeserializeRequest: [ | |||
'The decoded request byte array could not be deserialized for the provided media type.', | |||
[ | |||
'Choose from the available media types on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
unableToDeserializeResource: [ | |||
'The decoded resource could not be deserialized for the provided media type.', | |||
[ | |||
'Choose from the available media types on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
unableToEmplaceResource: [ | |||
'An error has occurred on emplacing the resource.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToEncodeResponse: [ | |||
'The response data could not be encoded for the provided character set.', | |||
[ | |||
'Choose from the available character sets on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
unableToFetchResource: [ | |||
'An error has occurred on fetching the resource.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToFetchResourceCollection: [ | |||
'An error has occurred on fetching the resource collection.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToGenerateIdFromResourceDataSource: [ | |||
'The associated data source for the resource could not produce an ID.', | |||
[ | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToInitializeResourceDataSource: [ | |||
'The associated data source for the resource could not be connected for usage.', | |||
[ | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToPatchResource: [ | |||
'An error has occurred on patching the resource.', | |||
[ | |||
'Check if the request method is appropriate for this endpoint.', | |||
'Check if the request body has all the required attributes for this endpoint.', | |||
'Check if the request body has only the valid attributes for this endpoint.', | |||
'Check if the request body matches the schema for the resource associated with this endpoint.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
unableToSerializeResponse: [ | |||
'The response data could not be serialized for the provided media type.', | |||
[ | |||
'Choose from the available media types on this service.', | |||
'Contact the administrator to provide localization for the client\'s given requirements.', | |||
], | |||
], | |||
urlNotFound: [ | |||
'An endpoint in the provided URL could not be found.', | |||
[ | |||
'Check if the request URL is correct.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
resourceNotFound: [ | |||
'The resource in the provided URL could not be found.', | |||
[ | |||
'Check if the request URL is correct.', | |||
'Try the request again at a later time.', | |||
'Contact the administrator regarding missing configuration or unavailability of dependencies.', | |||
], | |||
], | |||
}, | |||
}; |
@@ -3,10 +3,7 @@ | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": [ | |||
"ESNext", | |||
"dom" | |||
], | |||
"lib": ["ESNext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
@@ -21,8 +18,6 @@ | |||
"esModuleInterop": true, | |||
"target": "es2018", | |||
"useDefineForClassFields": false, | |||
"declarationMap": true, | |||
"experimentalDecorators": true, | |||
"emitDecoratorMetadata": true | |||
"declarationMap": true | |||
} | |||
} |
@@ -33,7 +33,7 @@ | |||
"@modal-sh/yasumi": "workspace:*" | |||
}, | |||
"private": false, | |||
"description": "JSON lines file data source for yasumi.", | |||
"description": "JSON lines file data source for Yasumi.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Check Allowed Post Operations | |||
type: http | |||
seq: 10 | |||
} | |||
options { | |||
url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc | |||
body: none | |||
auth: none | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Check Allowed Posts Operations | |||
type: http | |||
seq: 11 | |||
} | |||
options { | |||
url: http://localhost:6969/api/posts | |||
body: none | |||
auth: none | |||
} |
@@ -1,19 +0,0 @@ | |||
meta { | |||
name: Create Post with ID | |||
type: http | |||
seq: 8 | |||
} | |||
put { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: json | |||
auth: none | |||
} | |||
body:json { | |||
{ | |||
"title": "Emplaced Post", | |||
"content": "Created post at ID", | |||
"id": "5fac64d6-d261-42bb-a67b-bc7e1955a7e2" | |||
} | |||
} |
@@ -1,18 +0,0 @@ | |||
meta { | |||
name: Create Post | |||
type: http | |||
seq: 2 | |||
} | |||
post { | |||
url: http://localhost:6969/api/posts | |||
body: json | |||
auth: none | |||
} | |||
body:json { | |||
{ | |||
"title": "New Post", | |||
"content": "Hello there" | |||
} | |||
} |
@@ -1,15 +0,0 @@ | |||
meta { | |||
name: Delete Post | |||
type: http | |||
seq: 9 | |||
} | |||
delete { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: none | |||
auth: none | |||
} | |||
headers { | |||
~Accept-Language: tl | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Get Root | |||
type: http | |||
seq: 1 | |||
} | |||
get { | |||
url: http://localhost:6969/api | |||
body: none | |||
auth: none | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Get Single Post | |||
type: http | |||
seq: 4 | |||
} | |||
get { | |||
url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc | |||
body: none | |||
auth: none | |||
} |
@@ -1,25 +0,0 @@ | |||
meta { | |||
name: Modify Post (Delta) | |||
type: http | |||
seq: 6 | |||
} | |||
patch { | |||
url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc | |||
body: json | |||
auth: none | |||
} | |||
headers { | |||
Content-Type: application/json-patch+json | |||
} | |||
body:json { | |||
[ | |||
{ | |||
"op": "replace", | |||
"path": "content", | |||
"value": "I changed the value via delta." | |||
} | |||
] | |||
} |
@@ -1,23 +0,0 @@ | |||
meta { | |||
name: Modify Post (Merge) | |||
type: http | |||
seq: 5 | |||
} | |||
patch { | |||
url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc | |||
body: json | |||
auth: none | |||
} | |||
headers { | |||
Content-Type: application/merge-patch+json | |||
} | |||
body:json { | |||
{ | |||
"title": "Modified Post", | |||
"content": "I changed the content via merge." | |||
} | |||
} |
@@ -1,11 +0,0 @@ | |||
meta { | |||
name: Query Posts | |||
type: http | |||
seq: 3 | |||
} | |||
get { | |||
url: http://localhost:6969/api/posts | |||
body: none | |||
auth: none | |||
} |
@@ -1,19 +0,0 @@ | |||
meta { | |||
name: Replace Post | |||
type: http | |||
seq: 7 | |||
} | |||
put { | |||
url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc | |||
body: json | |||
auth: none | |||
} | |||
body:json { | |||
{ | |||
"title": "Replaced Post", | |||
"content": "The old content is gone.", | |||
"id": "9ba60691-0cd3-4e8a-9f44-e92b19fcacbc" | |||
} | |||
} |
@@ -1,13 +0,0 @@ | |||
{ | |||
"version": "1", | |||
"name": "cms-web-api", | |||
"type": "collection", | |||
"ignore": [ | |||
"node_modules", | |||
".git" | |||
], | |||
"presets": { | |||
"requestType": "http", | |||
"requestUrl": "http://localhost:6969/api/" | |||
} | |||
} |
@@ -1,51 +0,0 @@ | |||
{ | |||
"name": "@modal-sh/yasumi-example-cms-web-api", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=16" | |||
}, | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.11.0", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.3.3", | |||
"vitest": "^1.2.0" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "workspace:*", | |||
"@modal-sh/yasumi-server-http": "workspace:*", | |||
"@modal-sh/yasumi-data-source-file-jsonl": "workspace:*", | |||
"tsx": "^4.7.1" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "tsx src/index.ts", | |||
"dev": "tsx watch src/index.ts", | |||
"test": "vitest" | |||
}, | |||
"private": true, | |||
"description": "CMS example service for Yasumi", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
}, | |||
"homepage": "", | |||
"bugs": { | |||
"url": "" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "restricted" | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
{"id":"9ba60691-0cd3-4e8a-9f44-e92b19fcacbc","title":"Modified Post","content":"I changed the content via merge.","createdAt":1713320757029,"updatedAt":1713352134951,"user":"I changed the content via merge."} | |||
{"id":"1c23a980-eaab-4993-a0d3-58c06226062d","title":"New Post","content":"Hello there","createdAt":1713333787728,"updatedAt":1713333787728} | |||
{"id":"f6ed1dc3-f99f-4f8d-8a14-dbaa2949f795","title":"New Post","content":"Hello there","createdAt":1713352248696,"updatedAt":1713352248696} |
@@ -1,79 +0,0 @@ | |||
import { application, resource, validation as v } from '@modal-sh/yasumi'; | |||
import * as http from '@modal-sh/yasumi-server-http'; | |||
import { randomUUID } from 'crypto'; | |||
import { JsonLinesDataSource } from '@modal-sh/yasumi-data-source-file-jsonl'; | |||
import { constants } from 'http2'; | |||
import TAGALOG from './languages/tl'; | |||
const UuidIdConfig = { | |||
// TODO bind to data source | |||
generationStrategy: () => Promise.resolve(randomUUID()), | |||
schema: v.string(), | |||
serialize: (v: unknown) => v?.toString() ?? '', | |||
deserialize: (v: string) => v, // TODO resolve bytes/non-formatted UUIDs | |||
}; | |||
const User = resource( | |||
v.object({ | |||
email: v.string([v.email()]), | |||
password: v.string(), | |||
createdAt: v.date(), | |||
updatedAt: v.date(), | |||
}) | |||
) | |||
.name('User') | |||
.route('users') | |||
.id('id', UuidIdConfig) | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete(); | |||
const Post = resource( | |||
v.object({ | |||
title: v.string(), | |||
content: v.string(), | |||
}) | |||
) | |||
.name('Post') | |||
.route('posts') | |||
.id('id', UuidIdConfig) | |||
.createdAt('createdAt') | |||
.updatedAt('updatedAt') | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete(); | |||
const app = application({ | |||
name: 'cms' | |||
}) | |||
.language(TAGALOG) | |||
.resource(User) | |||
.resource(Post); | |||
const backend = app.createBackend({ | |||
dataSource: new JsonLinesDataSource(), | |||
}) | |||
.use(http.httpExtender) | |||
.throwsErrorOnDeletingNotFound(); | |||
const server = backend.createServer('http', { | |||
basePath: '/api', | |||
}) | |||
.defaultErrorHandler((_req, res) => () => { | |||
throw new http.ErrorPlainResponse('urlNotFound', { | |||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||
res, | |||
}); | |||
// throw new http.ErrorPlainResponse('notImplemented', { | |||
// statusCode: constants.HTTP_STATUS_NOT_IMPLEMENTED, | |||
// res, | |||
// }); | |||
}); | |||
server.listen(6969); |
@@ -1,282 +0,0 @@ | |||
import { Language } from '@modal-sh/yasumi'; | |||
export default { | |||
name: 'tl' as const, | |||
statusMessages: { | |||
unableToSerializeResponse: 'Hindi Maitala ang Tugon', | |||
unableToEncodeResponse: 'Hindi Ma-Encode ang Tugon', | |||
unableToBindResourceDataSource: 'Hindi Ma-bind ang $RESOURCE na Pinagmulan ng Datos', | |||
unableToInitializeResourceDataSource: 'Hindi Ma-initialize ang $RESOURCE na Pinagmulan ng Datos', | |||
unableToFetchResourceCollection: 'Hindi Ma-fetch ang $RESOURCE Collection', | |||
unableToFetchResource: 'Hindi Ma-fetch ang $RESOURCE', | |||
unableToDeleteResource: 'Hindi Ma-delete ang $RESOURCE', | |||
languageNotAcceptable: 'Hindi Tinatanggap ang Wika', | |||
characterSetNotAcceptable: 'Hindi Tinatanggap ang Set ng mga Titik', | |||
unableToDeserializeResource: 'Hindi Ma-deserialize ang $RESOURCE', | |||
unableToDecodeResource: 'Hindi Ma-decode ang $RESOURCE', | |||
mediaTypeNotAcceptable: 'Hindi Tinatanggap ang Uri ng Media', | |||
methodNotAllowed: 'Bawal ang Paraang Ginamit', | |||
urlNotFound: 'Hindi Nahanap ang URL', | |||
badRequest: 'Maling Paghiling', | |||
ok: 'OK', | |||
provideOptions: 'Magbigay ng mga Pagpipilian', | |||
resourceCollectionQueried: 'Hinanapan ang $RESOURCE Collection', | |||
resourceCollectionFetched: 'Nakuha ang $RESOURCE Collection', | |||
resourceFetched: 'Nakuha ang $RESOURCE', | |||
resourceNotFound: 'Hindi Nahanap ang $RESOURCE', | |||
deleteNonExistingResource: 'I-delete ang Nawawalang $RESOURCE', | |||
resourceDeleted: 'Na-delete ang $RESOURCE', | |||
unableToDeserializeRequest: 'Hindi Ma-deserialize ang Hiling', | |||
patchNonExistingResource: 'I-patch ang Nawawalang $RESOURCE', | |||
unableToPatchResource: 'Hindi Ma-patch ang $RESOURCE', | |||
invalidResourcePatch: 'Maling Patak ng $RESOURCE', | |||
invalidResourcePatchType: 'Maling Uri ng Patak ng $RESOURCE', | |||
invalidResource: 'Maling $RESOURCE', | |||
resourcePatched: 'Na-patch ang $RESOURCE', | |||
resourceCreated: 'Na-gawa ang $RESOURCE', | |||
resourceReplaced: 'Naipalit ang $RESOURCE', | |||
unableToGenerateIdFromResourceDataSource: 'Hindi Makagawa ng ID Mula sa Pinagmulang Data Source ng $RESOURCE', | |||
unableToAssignIdFromResourceDataSource: 'Hindi Makapaglagay ng ID Mula sa Pinagmulang Data Source ng $RESOURCE', | |||
unableToEmplaceResource: 'Hindi Ma-emplace ang $RESOURCE', | |||
resourceIdNotGiven: 'Hindi Ibinigay ang ID ng $RESOURCE', | |||
unableToCreateResource: 'Hindi Makagawa ng $RESOURCE', | |||
notImplemented: 'Hindi Pa Na-implement', | |||
internalServerError: 'Internal Server Error', | |||
}, | |||
bodies: { | |||
badRequest: [ | |||
'Isang maling hiling ang naipadala.', | |||
[ | |||
'Kumpirmahin kung mayroon ang hiling na katawan ng lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung mayroon lang ang hiling na katawan ng mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
'Kumpirmahin kung ang hiling ay naaangkop para sa endpoint na ito.', | |||
], | |||
], | |||
languageNotAcceptable: [ | |||
'Hindi kayang i-proseso ng server ang isang tugon na angkop para sa kinakailangang wika ng kliyente.', | |||
[ | |||
'Pumili mula sa mga available na wika sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
characterSetNotAcceptable: [ | |||
'Hindi kayang i-proseso ng server ang isang tugon na angkop para sa kinakailangang set ng mga titik ng kliyente.', | |||
[ | |||
'Pumili mula sa mga available na set ng mga titik sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
mediaTypeNotAcceptable: [ | |||
'Hindi kayang i-proseso ng server ang isang tugon na angkop para sa kinakailangang uri ng media ng kliyente.', | |||
[ | |||
'Pumili mula sa mga available na uri ng media sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
deleteNonExistingResource: [ | |||
'Sinubukan ng kliyente na i-delete ang isang pinagmulang hindi umiiral na resource.', | |||
[ | |||
'Siguruhing umiiral pa rin ang pinagmulan.', | |||
'Siguruhing ang tamang paraan ng pagsasaad ay ibinigay.', | |||
], | |||
], | |||
internalServerError: [ | |||
'May hindi kilalang error na nangyari sa loob ng serbisyo.', | |||
[ | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator kung nananatili ang serbisyo sa isang degradadong o hindi gumagana na kalagayan.', | |||
], | |||
], | |||
invalidResource: [ | |||
'Ang hiling ay may maling estruktura o kulang sa ilang mga attribute.', | |||
[ | |||
'Kumpirmahin kung ang katawan ng hiling ay may lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lamang mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
], | |||
], | |||
invalidResourcePatch: [ | |||
'Ang hiling ay may maling patch data.', | |||
[ | |||
'Kumpirmahin kung ang tamang uri ng patch ay tinukoy sa datos ng hiling.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lamang mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
], | |||
], | |||
invalidResourcePatchType: [ | |||
'Ang hiling ay may mali o hindi suportadong uri ng patch data.', | |||
[ | |||
'Kumpirmahin kung ang tamang uri ng patch ay tinukoy sa datos ng hiling.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lamang mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
], | |||
], | |||
methodNotAllowed: [ | |||
'Isang hiling na may maling o hindi suportadong paraan ang ginawa.', | |||
[ | |||
'Kumpirmahin kung ang paraang ginamit sa hiling ay naaangkop para sa endpoint na ito.', | |||
'Kumpirmahin kung ang kliyente ay awtorisado na magawa ang paraang ito sa endpoint na ito.', | |||
] | |||
], | |||
notImplemented: [ | |||
'Wala pang implementasyon ang serbisyo para sa inaksyunang endpoint.', | |||
[ | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator kung nananatili ang serbisyo sa isang degradadong o hindi gumagana na kalagayan.', | |||
], | |||
], | |||
patchNonExistingResource: [ | |||
'Subukan ng kliyente na i-patch ang isang pinagmulang hindi umiiral na resource.', | |||
[ | |||
'Siguruhing umiiral pa rin ang pinagmulan.', | |||
'Siguruhing ang tamang paraan ng pagsasaad ay ibinigay.', | |||
], | |||
], | |||
resourceIdNotGiven: [ | |||
'Hindi ibinigay ang ID ng pinagmulang resource para sa inaksyunang endpoint.', | |||
[ | |||
'Kumpirmahin kung ibinigay at wasto ang ID ng pinagmulang sa URL.', | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
], | |||
], | |||
unableToAssignIdFromResourceDataSource: [ | |||
'Hindi ma-assign ang ID mula sa kaugnay na pinagmulang pinagmulan ng datos ng resource.', | |||
[ | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToBindResourceDataSource: [ | |||
'Hindi ma-bind ang pinagmulan ng datos mula sa resource.', | |||
[ | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToCreateResource: [ | |||
'Nagkaroon ng error sa paggawa ng resource.', | |||
[ | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lamang mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToDecodeResource: [ | |||
'Hindi ma-decode ang byte array ng resource para sa ibinigay na set ng mga titik.', | |||
[ | |||
'Pumili mula sa mga available na set ng mga titik sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
unableToDeleteResource: [ | |||
'Nagkaroon ng error sa pag-delete ng resource.', | |||
[ | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToDeserializeRequest: [ | |||
'Hindi ma-deserialize ang decoded na byte array ng hiling para sa ibinigay na uri ng media.', | |||
[ | |||
'Pumili mula sa mga available na uri ng media sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
unableToDeserializeResource: [ | |||
'Hindi ma-deserialize ang decoded na resource para sa ibinigay na uri ng media.', | |||
[ | |||
'Pumili mula sa mga available na uri ng media sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
unableToEmplaceResource: [ | |||
'Nagkaroon ng error sa pag-e-emplace ng resource.', | |||
[ | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lamang mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToEncodeResponse: [ | |||
'Hindi ma-encode ang datos ng tugon para sa ibinigay na set ng mga titik.', | |||
[ | |||
'Pumili mula sa mga available na set ng mga titik sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
unableToFetchResource: [ | |||
'Nagkaroon ng error sa pag-fetch ng resource.', | |||
[ | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToFetchResourceCollection: [ | |||
'Nagkaroon ng error sa pag-fetch ng koleksyon ng resource.', | |||
[ | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToGenerateIdFromResourceDataSource: [ | |||
'Ang kaugnay na pinagmulang pinagmulan ng datos para sa resource ay hindi makapag-produce ng ID.', | |||
[ | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToInitializeResourceDataSource: [ | |||
'Ang kaugnay na pinagmulang pinagmulan ng datos para sa resource ay hindi ma-connect para sa paggamit.', | |||
[ | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToPatchResource: [ | |||
'Nagkaroon ng error sa pag-patch ng resource.', | |||
[ | |||
'Kumpirmahin kung ang paraang hiling ay naaangkop para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lahat ng kinakailangang mga attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay may lamang mga tamang attribute para sa endpoint na ito.', | |||
'Kumpirmahin kung ang katawan ng hiling ay tugma sa schema para sa pinagmulang pinagmulan ng endpoint na ito.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
unableToSerializeResponse: [ | |||
'Hindi ma-serialize ang datos ng tugon para sa ibinigay na uri ng media.', | |||
[ | |||
'Pumili mula sa mga available na uri ng media sa serbisyong ito.', | |||
'Makipag-ugnayan sa administrator upang magbigay ng lokal na pagsasalin para sa mga kinakailangang pangangailangan ng kliyente.', | |||
], | |||
], | |||
urlNotFound: [ | |||
'Hindi nahanap ang isang endpoint sa ibinigay na URL.', | |||
[ | |||
'Kumpirmahin kung ang URL ng hiling ay tama.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
resourceNotFound: [ | |||
'Hindi nahanap ang resource sa ibinigay na URL.', | |||
[ | |||
'Kumpirmahin kung ang URL ng hiling ay tama.', | |||
'Subukang muli ang hiling mamaya.', | |||
'Makipag-ugnayan sa administrator tungkol sa mga nawawalang kongfigurasyon o kawalan ng kagamitan.', | |||
], | |||
], | |||
}, | |||
} satisfies Language; |
@@ -1,50 +0,0 @@ | |||
import { resource, application, validation as v } from '@modal-sh/yasumi'; | |||
import * as http from '@modal-sh/yasumi-server-http'; | |||
import { DuckDbDataSource, AutoincrementIdConfig } from '@modal-sh/yasumi-data-source-duckdb'; | |||
import { constants } from 'http2'; | |||
const Post = resource( | |||
v.object({ | |||
title: v.string(), | |||
content: v.string(), | |||
}) | |||
) | |||
.name('Post') | |||
.route('posts') | |||
.id('id', AutoincrementIdConfig) | |||
.createdAt('createdAt') | |||
.updatedAt('updatedAt') | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete(); | |||
const app = application({ | |||
name: 'duckdb-service' | |||
}) | |||
.resource(Post); | |||
const backend = app.createBackend({ | |||
dataSource: new DuckDbDataSource('test.db'), | |||
}) | |||
.use(http.httpExtender) | |||
.showTotalItemCountOnGetCollection() | |||
.throwsErrorOnDeletingNotFound(); | |||
const server = backend.createServer('http', { | |||
basePath: '/api', | |||
}) | |||
.defaultErrorHandler((_req, res) => () => { | |||
throw new http.ErrorPlainResponse('urlNotFound', { | |||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||
res, | |||
}); | |||
// throw new http.ErrorPlainResponse('notImplemented', { | |||
// statusCode: constants.HTTP_STATUS_NOT_IMPLEMENTED, | |||
// res, | |||
// }); | |||
}); | |||
server.listen(6969); |
@@ -105,4 +105,3 @@ dist | |||
.tern-port | |||
.npmrc | |||
*.jsonl |
@@ -1,5 +1,5 @@ | |||
{ | |||
"name": "@modal-sh/yasumi-example-duckdb", | |||
"name": "http-resource-server", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
@@ -18,24 +18,18 @@ | |||
"typescript": "^5.3.3", | |||
"vitest": "^1.2.0" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "workspace:*", | |||
"@modal-sh/yasumi-server-http": "workspace:*", | |||
"@modal-sh/yasumi-data-source-duckdb": "workspace:*", | |||
"tsx": "^4.7.1" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "tsx src/index.ts", | |||
"dev": "tsx watch src/index.ts", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
}, | |||
"private": true, | |||
"description": "DuckDB-powered example service.", | |||
"description": "Basic HTTP resource server example for Yasumi.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
@@ -47,5 +41,10 @@ | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "restricted" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "workspace:*", | |||
"@modal-sh/yasumi-recipe-resource": "workspace:*", | |||
"@modal-sh/yasumi-extender-http": "workspace:*" | |||
} | |||
} |
@@ -0,0 +1,6 @@ | |||
export default function add(a: number, b: number): number { | |||
if (process.env.NODE_ENV !== 'production') { | |||
console.log('This code would not appear on production builds'); | |||
} | |||
return a + b; | |||
} |
@@ -0,0 +1,34 @@ | |||
import { | |||
app, | |||
composeRecipes, | |||
} from '@modal-sh/yasumi'; | |||
import { | |||
DataSource | |||
} from '@modal-sh/yasumi/backend'; | |||
import {server} from '@modal-sh/yasumi-extender-http/backend'; | |||
import {addResourceRecipe} from '@modal-sh/yasumi-recipe-resource'; | |||
export const setupApp = (dataSource: DataSource) => { | |||
const { | |||
app: theApp, | |||
operations, | |||
backend: theBackend, | |||
} = composeRecipes([ | |||
addResourceRecipe({ endpointName: 'users', dataSource, endpointResourceIdAttr: 'id', }), | |||
addResourceRecipe({ endpointName: 'posts', dataSource, endpointResourceIdAttr: 'id', }) | |||
])({ | |||
app: app({ | |||
name: 'default' as const, | |||
}), | |||
}); | |||
const theServer = typeof theBackend !== 'undefined' ? server({ | |||
backend: theBackend, | |||
}) : undefined; | |||
return { | |||
app: theApp, | |||
operations, | |||
server: theServer, | |||
}; | |||
}; |
@@ -0,0 +1,140 @@ | |||
import { | |||
describe, | |||
beforeAll, | |||
afterAll, | |||
it, | |||
expect, | |||
Mock, | |||
} from 'vitest'; | |||
import { | |||
statusCodes | |||
} from '@modal-sh/yasumi'; | |||
import { | |||
DataSource, | |||
Server, | |||
} from '@modal-sh/yasumi/backend'; | |||
import {Client} from '@modal-sh/yasumi/client'; | |||
import {client} from '@modal-sh/yasumi-extender-http/client'; | |||
import {ResourceItemFetchedResponse, ResourceCreatedResponse} from '@modal-sh/yasumi-recipe-resource'; | |||
import {createDummyDataSource, NEW_ID} from './fixtures/data-source'; | |||
import {setupApp} from '../src/setup'; | |||
const connectionParams = { | |||
port: 3001, | |||
}; | |||
describe('default', () => { | |||
let theClient: Client; | |||
let theServer: Server; | |||
let dataSource: Record<keyof DataSource, Mock>; | |||
beforeAll(() => { | |||
dataSource = createDummyDataSource(); | |||
}); | |||
afterAll(() => { | |||
dataSource.getById.mockReset(); | |||
}); | |||
beforeAll(async () => { | |||
const { | |||
app: theApp, | |||
server: createdServer, | |||
} = setupApp(dataSource); | |||
theServer = createdServer; | |||
await theServer.serve(connectionParams); | |||
theClient = client({ | |||
app: theApp, | |||
}); | |||
await theClient.connect(connectionParams); | |||
}); | |||
afterAll(async () => { | |||
await theClient.disconnect(); | |||
await theServer.close(); | |||
}); | |||
describe('fetch', () => { | |||
it('works', async () => { | |||
const theEndpoint = theClient.app.endpoints.get('users'); | |||
const theOperation = theClient.app.operations.get('fetch'); | |||
// TODO create wrapper for fetch's Response here | |||
// | |||
// should we create a helper object to process client-side received response from server's sent response? | |||
// | |||
// the motivation is to remove the manual deserialization from the client (provide serialization on the response | |||
// object so as the client is not limited to .text(), .json(), .arrayBuffer() etc) | |||
const responseRaw = await theClient | |||
.at(theEndpoint) | |||
.makeRequest( | |||
theOperation | |||
.search({ | |||
foo: 'bar', | |||
}) | |||
); | |||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | |||
const body = await response['deserialize'](); | |||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); | |||
expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); | |||
expect(body).toEqual([]); | |||
}); | |||
it('works for items', async () => { | |||
const theEndpoint = theClient.app.endpoints.get('users'); | |||
const theOperation = theClient.app.operations.get('fetch'); | |||
// TODO create wrapper for fetch's Response here | |||
// | |||
// should we create a helper object to process client-side received response from server's sent response? | |||
// | |||
// the motivation is to remove the manual deserialization from the client (provide serialization on the response | |||
// object so as the client is not limited to .text(), .json(), .arrayBuffer() etc) | |||
const responseRaw = await theClient | |||
.at(theEndpoint, { resourceId: 3 }) | |||
// TODO how to inject extra data (e.g. headers, body) in the operation (e.g. auth)? | |||
.makeRequest( | |||
theOperation | |||
.search({ | |||
foo: 'bar', | |||
}) // allow multiple calls of .search() to add to search params | |||
); | |||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | |||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); | |||
expect(response).toHaveProperty('statusMessage', 'Resource Fetched'); | |||
}); | |||
}); | |||
describe('create', () => { | |||
it('works', async () => { | |||
const theEndpoint = theClient.app.endpoints.get('users'); | |||
const theOperation = theClient.app.operations.get('create'); | |||
// TODO create wrapper for fetch's Response here | |||
// | |||
// should we create a helper object to process client-side received response from server's sent response? | |||
// | |||
// the motivation is to remove the manual deserialization from the client (provide serialization on the response | |||
// object so as the client is not limited to .text(), .json(), .arrayBuffer() etc) | |||
const responseRaw = await theClient | |||
.at(theEndpoint) | |||
.makeRequest( | |||
theOperation | |||
.setBody({}) | |||
); | |||
const response = ResourceCreatedResponse.fromFetchResponse(responseRaw); | |||
const body = await response['deserialize'](); | |||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_CREATED); | |||
expect(response).toHaveProperty('statusMessage', 'Resource Created'); | |||
expect(body).toEqual({ id: NEW_ID }); | |||
}); | |||
}); | |||
}); |
@@ -0,0 +1,18 @@ | |||
import {vi} from 'vitest'; | |||
export const NEW_ID = 1; | |||
export const TOTAL_COUNT = 1; | |||
export const createDummyDataSource = () => ({ | |||
create: vi.fn(async (data) => data), | |||
getById: vi.fn(async () => ({})), | |||
delete: vi.fn(), | |||
emplace: vi.fn(async () => [{}, { isCreated: false }]), | |||
getMultiple: vi.fn(async () => []), | |||
getSingle: vi.fn(async () => ({})), | |||
getTotalCount: vi.fn(async () => TOTAL_COUNT), | |||
newId: vi.fn(async () => NEW_ID), | |||
patch: vi.fn(async (id, data) => ({ ...data, id })), | |||
initialize: vi.fn(async () => {}), | |||
}); |
@@ -105,5 +105,3 @@ dist | |||
.tern-port | |||
.npmrc | |||
*.db | |||
*.db.wal |
@@ -0,0 +1,79 @@ | |||
{ | |||
"name": "@modal-sh/yasumi-extender-http", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=16" | |||
}, | |||
"license": "MIT", | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.11.0", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.3.3", | |||
"vitest": "^1.2.0" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
}, | |||
"private": false, | |||
"description": "HTTP extender for Yasumi.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
}, | |||
"homepage": "", | |||
"bugs": { | |||
"url": "" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "public" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "workspace:*" | |||
}, | |||
"exports": { | |||
"./backend": { | |||
"development": { | |||
"require": "./dist/cjs/development/backend.js", | |||
"import": "./dist/esm/development/backend.js" | |||
}, | |||
"require": "./dist/cjs/production/backend.js", | |||
"import": "./dist/esm/production/backend.js", | |||
"types": "./dist/types/backend/index.d.ts" | |||
}, | |||
"./client": { | |||
"development": { | |||
"require": "./dist/cjs/development/client.js", | |||
"import": "./dist/esm/development/client.js" | |||
}, | |||
"require": "./dist/cjs/production/client.js", | |||
"import": "./dist/esm/production/client.js", | |||
"types": "./dist/types/client/index.d.ts" | |||
} | |||
}, | |||
"typesVersions": { | |||
"*": { | |||
"backend": [ | |||
"./dist/types/backend/index.d.ts" | |||
], | |||
"client": [ | |||
"./dist/types/client/index.d.ts" | |||
] | |||
} | |||
} | |||
} |
@@ -0,0 +1,7 @@ | |||
{ | |||
"target": "es2018", | |||
"entrypoints": { | |||
"./backend": "src/backend/index.ts", | |||
"./client": "src/client/index.ts" | |||
} | |||
} |
@@ -0,0 +1,295 @@ | |||
import http from 'http'; | |||
import { | |||
ErrorResponse, | |||
parseToEndpointQueue, | |||
ServiceParams, | |||
statusCodes, | |||
getContentNegotiationParams, | |||
FALLBACK_LANGUAGE, | |||
FALLBACK_CHARSET, | |||
FALLBACK_MEDIA_TYPE, | |||
parseAcceptString, | |||
} from '@modal-sh/yasumi'; | |||
import { | |||
Backend as BaseBackend, | |||
Server, | |||
ServerRequestContext, | |||
ServerResponseContext, | |||
ServerParams, | |||
} from '@modal-sh/yasumi/backend'; | |||
declare module '@modal-sh/yasumi/backend' { | |||
interface ServerRequestContext extends http.IncomingMessage {} | |||
interface ServerResponseContext extends http.ServerResponse {} | |||
} | |||
const getBody = ( | |||
req: http.IncomingMessage, | |||
) => new Promise<Buffer>((resolve, reject) => { | |||
let body = Buffer.from(''); | |||
req.on('data', (chunk) => { | |||
body = Buffer.concat([body, chunk]); | |||
}); | |||
req.on('end', async () => { | |||
resolve(body); | |||
}); | |||
req.on('error', (err) => { | |||
reject(err); | |||
}); | |||
}); | |||
class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
readonly backend: Backend; | |||
private serverInternal?: http.Server; | |||
constructor(params: ServerParams<Backend>) { | |||
this.backend = params.backend; | |||
} | |||
private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => { | |||
const { method: methodRaw, url, headers: requestHeaders } = req; | |||
const { | |||
language, | |||
mediaType, | |||
charset, | |||
} = getContentNegotiationParams(this.backend.app, requestHeaders); | |||
const fallbackLanguage = language ?? FALLBACK_LANGUAGE; | |||
// TODO use these for errors | |||
const fallbackMediaType = mediaType ?? FALLBACK_MEDIA_TYPE; | |||
const fallbackCharset = charset ?? FALLBACK_CHARSET; | |||
const errorHeaders = { | |||
'content-language': fallbackLanguage.name, | |||
} as Record<string, string>; | |||
if (typeof methodRaw === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
if (typeof url === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
const method = methodRaw.toUpperCase(); | |||
console.log(method, url); | |||
console.log('Accept:', requestHeaders['accept']); | |||
console.log('Accept-Charset:', requestHeaders['accept-charset']); | |||
console.log('Accept-Language:', requestHeaders['accept-language']); | |||
console.log('Content-Language:', language?.name); | |||
console.log('Content-Type:', `${mediaType?.name};charset=${charset?.name}`); | |||
const endpoints = parseToEndpointQueue(url, this.backend.app.endpoints); | |||
const [endpoint, endpointParams] = endpoints.at(-1) ?? []; | |||
if (typeof endpoint === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_NOT_FOUND, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
const appOperations = Array.from(this.backend.app.operations.values()) | |||
const foundAppOperation = appOperations.find((op) => { | |||
const doesMethodMatch = op.method === method; | |||
if (typeof op.headers !== 'undefined') { | |||
const doesHeadersMatch = Array.from(op.headers.entries()).reduce( | |||
(currentHeadersMatch, [headerKey, opHeaderValue]) => ( | |||
// TODO honor content-type matching | |||
currentHeadersMatch && requestHeaders[headerKey.toLowerCase()] === opHeaderValue | |||
), | |||
true, | |||
); | |||
return ( | |||
doesMethodMatch | |||
&& doesHeadersMatch | |||
); | |||
} | |||
return doesMethodMatch; | |||
}); | |||
if (typeof foundAppOperation === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
...errorHeaders, | |||
'Allow': appOperations.map((op) => op.method).join(',') | |||
}); | |||
res.end(); | |||
return; | |||
} | |||
if (!endpoint.operations.has(foundAppOperation.name)) { | |||
const endpointOperations = Array.from(endpoint?.operations ?? []); | |||
res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
...errorHeaders, | |||
'Allow': endpointOperations | |||
.map((a) => appOperations.find((aa) => aa.name === a)?.method) | |||
.join(',') | |||
}); | |||
res.end(); | |||
return; | |||
} | |||
const implementation = this.backend.implementations.get(foundAppOperation.name); | |||
if (typeof implementation === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
const [, search] = url.split('?'); | |||
// TODO add flag on implementation context if CQRS should be enabled | |||
try { | |||
// TODO deserialize body, else throw 415 | |||
const requestBodySpecs = parseAcceptString(requestHeaders['content-type']); | |||
let body: unknown = undefined; | |||
// TODO check body | |||
if (typeof requestBodySpecs !== 'undefined') { | |||
const { | |||
mediaType: requestBodyMediaTypeString, | |||
params: requestBodyParams, | |||
} = requestBodySpecs; | |||
const requestBodyCharsetString = requestBodyParams?.charset?.toLowerCase(); | |||
const requestBodyMediaType = this.backend.app.mediaTypes.get(requestBodyMediaTypeString); | |||
if (typeof requestBodyMediaType === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
let decoder = (buf: Buffer, _params?: {}) => buf.toString('binary'); | |||
if (typeof requestBodyCharsetString !== 'undefined') { | |||
const requestBodyCharset = this.backend.app.charsets.get(requestBodyCharsetString); | |||
if (typeof requestBodyCharset !== 'undefined') { | |||
decoder = requestBodyCharset.decode; | |||
} | |||
} | |||
const bodyBuffer = await getBody(req); | |||
const bodyDecoded = decoder(bodyBuffer, requestBodyParams); | |||
console.log(); | |||
console.log(bodyDecoded); | |||
body = requestBodyMediaType.deserialize(bodyDecoded, requestBodyParams); | |||
} | |||
const responseHeaders = {} as Record<string, string>; | |||
const isCqrs = false; | |||
let hasSentResponse = false; | |||
console.log(); | |||
const responseSpec = await implementation({ | |||
endpoint, | |||
body, | |||
params: endpointParams ?? {}, | |||
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | |||
dataSource: this.backend.dataSource, | |||
language: language ?? fallbackLanguage, | |||
setLocation: (location: string) => { | |||
// TODO set base path | |||
responseHeaders['Location'] = `${location}`; | |||
if (!isCqrs) { | |||
return; | |||
} | |||
res.writeHead(statusCodes.HTTP_STATUS_SEE_OTHER, { | |||
Location: location, | |||
}); | |||
res.end(); | |||
hasSentResponse = true; | |||
}, | |||
}); | |||
if (hasSentResponse) { | |||
return; | |||
} | |||
if (typeof responseSpec === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
// TODO serialize using content-negotiation params | |||
const bodyToSerialize = responseSpec.body; | |||
if (bodyToSerialize instanceof Buffer) { | |||
// TODO make easy and consistent way to set headers without having to mind about casing | |||
responseHeaders['Content-Type'] = responseHeaders['Content-Type'] ?? 'application/octet-stream'; | |||
res.statusMessage = responseSpec.statusMessage; | |||
res.writeHead(responseSpec.statusCode, responseHeaders) | |||
res.end(bodyToSerialize); | |||
return; | |||
} | |||
let encoded = Buffer.from(''); | |||
// TODO throw not acceptable when cannot serialize | |||
if (typeof bodyToSerialize === 'object') { | |||
let serialized = ''; | |||
if (typeof mediaType === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_NOT_ACCEPTABLE, errorHeaders); | |||
res.end(); | |||
return; | |||
} | |||
serialized = mediaType.serialize(bodyToSerialize); | |||
console.log(responseSpec.statusCode, responseSpec.statusMessage); | |||
Object.entries(responseHeaders).forEach(([key, value]) => { | |||
console.log(`${key}:`, value); | |||
}); | |||
console.log(); | |||
console.log(serialized); | |||
responseHeaders['Content-Type'] = mediaType.name; | |||
if (typeof charset !== 'undefined' && typeof responseHeaders['Content-Type'] === 'string') { | |||
encoded = charset.encode(serialized); | |||
responseHeaders['Content-Type'] += `;charset=${charset.name}`; | |||
} | |||
} | |||
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | |||
res.writeHead(responseSpec.statusCode, responseHeaders); | |||
res.end(encoded); | |||
} catch (errorResponseSpecRaw) { | |||
const responseSpec = errorResponseSpecRaw as ErrorResponse; | |||
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | |||
res.writeHead(responseSpec.statusCode, errorHeaders); | |||
res.end(); | |||
} | |||
}; | |||
serve(params: ServiceParams) { | |||
return new Promise<void>((resolve) => { | |||
this.serverInternal = new http.Server(this.requestListener); | |||
this.serverInternal.listen(params.port ?? 80, params.host ?? '0.0.0.0', undefined, resolve); | |||
}); | |||
} | |||
close() { | |||
return new Promise<void>((resolve, reject) => { | |||
if (typeof this.serverInternal === 'undefined') { | |||
resolve(); | |||
return; | |||
} | |||
this.serverInternal.close((err) => { | |||
if (err) { | |||
reject(err); | |||
return; | |||
} | |||
resolve(); | |||
}); | |||
}); | |||
} | |||
} | |||
export const server = <Backend extends BaseBackend>(params: ServerParams<Backend>): Server<Backend> => { | |||
return new ServerInstance(params); | |||
}; |
@@ -0,0 +1 @@ | |||
export * from './core'; |
@@ -0,0 +1,156 @@ | |||
import { | |||
App as BaseApp, | |||
AVAILABLE_EXTENSION_METHODS, | |||
Endpoint, EndpointQueue, | |||
GetEndpointParams, | |||
Operation, serializeEndpointQueue, | |||
ServiceParams, | |||
FALLBACK_LANGUAGE, | |||
FALLBACK_MEDIA_TYPE, | |||
FALLBACK_CHARSET, | |||
Language, | |||
MediaType, | |||
Charset, | |||
} from '@modal-sh/yasumi'; | |||
import {Client, ClientParams, ClientConnection} from '@modal-sh/yasumi/client'; | |||
declare module '@modal-sh/yasumi/client' { | |||
interface ClientConnection { | |||
host: string; | |||
port: number; | |||
basePath: string; | |||
} | |||
} | |||
const DEFAULT_HOST = '0.0.0.0' as const; | |||
const DEFAULT_PORT = 80 as const; | |||
const DEFAULT_METHOD = 'GET' as const; | |||
const EXTENSION_METHOD_EFFECTIVE_METHOD = 'POST' as const; | |||
class ClientInstance<App extends BaseApp> implements Client<App> { | |||
readonly app: App; | |||
private readonly fetchFn: typeof fetch; | |||
private connection?: ServiceParams; | |||
private endpointQueue = [] as EndpointQueue; | |||
private requestLanguage?: Language; | |||
private requestMediaType?: MediaType; | |||
private requestCharset?: Charset; | |||
private responseLanguage?: Language; | |||
private responseMediaType?: MediaType; | |||
private responseCharset?: Charset; | |||
constructor(params: ClientParams<App>) { | |||
this.app = params.app; | |||
this.fetchFn = params.fetch ?? fetch; | |||
this.requestLanguage = params.app.languages.get(FALLBACK_LANGUAGE.name.toLowerCase()); | |||
this.requestMediaType = params.app.mediaTypes.get(FALLBACK_MEDIA_TYPE.name.toLowerCase()); | |||
this.requestCharset = params.app.charsets.get(FALLBACK_CHARSET.name.toLowerCase()); | |||
this.responseLanguage = params.app.languages.get(FALLBACK_LANGUAGE.name.toLowerCase()); | |||
this.responseMediaType = params.app.mediaTypes.get(FALLBACK_MEDIA_TYPE.name.toLowerCase()); | |||
this.responseCharset = params.app.charsets.get(FALLBACK_CHARSET.name.toLowerCase()); | |||
} | |||
async connect(params: ServiceParams): Promise<ClientConnection> { | |||
// we should be explicit with connection params in order not to give a false impression to the user they are "connected" | |||
// to the target server | |||
const connection = { | |||
host: params.host ?? DEFAULT_HOST, | |||
port: params.port ?? DEFAULT_PORT, | |||
basePath: params.basePath ?? '', | |||
}; | |||
this.connection = connection; | |||
return connection; | |||
} | |||
async disconnect() { | |||
// noop | |||
} | |||
at<TheEndpoint extends Endpoint = Endpoint>(endpoint: TheEndpoint, params?: Record<GetEndpointParams<TheEndpoint>, unknown>) { | |||
if (Array.isArray(this.endpointQueue)) { | |||
this.endpointQueue?.push([endpoint, params]); | |||
} | |||
return this; | |||
} | |||
makeRequest(operation: Operation) { | |||
const baseUrlFragments = [ | |||
this.connection?.host ?? DEFAULT_HOST | |||
]; | |||
const thePort = (this.connection?.port ?? DEFAULT_PORT); | |||
if (thePort !== DEFAULT_PORT) { | |||
baseUrlFragments.push(thePort.toString()); | |||
} | |||
// TODO how to set to https? | |||
const scheme = 'http'; | |||
// todo need a way to decode url back to endpoint queue | |||
const urlString = serializeEndpointQueue(this.endpointQueue); | |||
this.endpointQueue = []; | |||
const url = new URL( | |||
this.connection?.basePath ? `${this.connection.basePath}${urlString}` : urlString, | |||
`${scheme}://${baseUrlFragments.join(':')}` | |||
); | |||
if (typeof operation.searchParams !== 'undefined') { | |||
url.search = operation.searchParams.toString(); | |||
} | |||
const rawEffectiveMethod = (operation.method ?? DEFAULT_METHOD).toUpperCase(); | |||
const finalEffectiveMethod = (AVAILABLE_EXTENSION_METHODS as unknown as string[]).includes(rawEffectiveMethod) | |||
? EXTENSION_METHOD_EFFECTIVE_METHOD | |||
: rawEffectiveMethod; | |||
const effectiveResponseMediaType = this.responseMediaType ?? FALLBACK_MEDIA_TYPE; | |||
const effectiveResponseCharset = this.responseCharset ?? FALLBACK_CHARSET; | |||
const effectiveResponseLanguage = this.responseLanguage ?? FALLBACK_LANGUAGE; | |||
if (typeof operation.body !== 'undefined') { | |||
const effectiveRequestMediaType = this.requestMediaType ?? FALLBACK_MEDIA_TYPE; | |||
const bodySerialized = effectiveRequestMediaType.serialize(operation.body); | |||
const effectiveRequestCharset = this.requestCharset ?? FALLBACK_CHARSET; | |||
const bodyEncoded = effectiveRequestCharset.encode(bodySerialized); | |||
const effectiveRequestLanguage = this.requestLanguage ?? FALLBACK_LANGUAGE; | |||
return this.fetchFn( | |||
url, | |||
{ | |||
method: finalEffectiveMethod, | |||
body: bodyEncoded, | |||
headers: { | |||
// TODO add wildcard for accept headers to accept anything from the server | |||
'Accept': effectiveResponseMediaType.name, | |||
'Accept-Charset': effectiveResponseCharset.name, | |||
'Accept-Language': effectiveResponseLanguage.name, | |||
'Content-Language': effectiveRequestLanguage.name, | |||
'Content-Type': `${effectiveRequestMediaType.name};charset=${effectiveRequestCharset.name}`, | |||
}, | |||
}, | |||
); | |||
} | |||
return this.fetchFn( | |||
url, | |||
{ | |||
method: finalEffectiveMethod, | |||
headers: { | |||
'Accept': effectiveResponseMediaType.name, | |||
'Accept-Charset': effectiveResponseCharset.name, | |||
'Accept-Language': effectiveResponseLanguage.name, | |||
}, | |||
}, | |||
); | |||
} | |||
} | |||
export const client = <App extends BaseApp>(params: ClientParams<App>): Client<App> => { | |||
return new ClientInstance(params); | |||
}; |
@@ -0,0 +1 @@ | |||
export * from './core'; |
@@ -0,0 +1,107 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
# Diagnostic reports (https://nodejs.org/api/report.html) | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
# Runtime data | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
# Directory for instrumented libs generated by jscoverage/JSCover | |||
lib-cov | |||
# Coverage directory used by tools like istanbul | |||
coverage | |||
*.lcov | |||
# nyc test coverage | |||
.nyc_output | |||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | |||
.grunt | |||
# Bower dependency directory (https://bower.io/) | |||
bower_components | |||
# node-waf configuration | |||
.lock-wscript | |||
# Compiled binary addons (https://nodejs.org/api/addons.html) | |||
build/Release | |||
# Dependency directories | |||
node_modules/ | |||
jspm_packages/ | |||
# TypeScript v1 declaration files | |||
typings/ | |||
# TypeScript cache | |||
*.tsbuildinfo | |||
# Optional npm cache directory | |||
.npm | |||
# Optional eslint cache | |||
.eslintcache | |||
# Microbundle cache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
# Optional REPL history | |||
.node_repl_history | |||
# Output of 'npm pack' | |||
*.tgz | |||
# Yarn Integrity file | |||
.yarn-integrity | |||
# dotenv environment variables file | |||
.env | |||
.env.production | |||
.env.development | |||
# parcel-bundler cache (https://parceljs.org/) | |||
.cache | |||
# Next.js build output | |||
.next | |||
# Nuxt.js build / generate output | |||
.nuxt | |||
dist | |||
# Gatsby files | |||
.cache/ | |||
# Comment in the public line in if your project uses Gatsby and *not* Next.js | |||
# https://nextjs.org/blog/next-9-1#public-directory-support | |||
# public | |||
# vuepress build output | |||
.vuepress/dist | |||
# Serverless directories | |||
.serverless/ | |||
# FuseBox cache | |||
.fusebox/ | |||
# DynamoDB Local files | |||
.dynamodb/ | |||
# TernJS port file | |||
.tern-port | |||
.npmrc |
@@ -0,0 +1,7 @@ | |||
MIT License Copyright (c) 2024 TheoryOfNekomata <allan.crisostomo@outlook.com> | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@@ -1,5 +1,5 @@ | |||
{ | |||
"name": "@modal-sh/yasumi-server-http", | |||
"name": "@modal-sh/yasumi-recipe-resource", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
@@ -13,17 +13,12 @@ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/negotiator": "^0.6.3", | |||
"@types/node": "^20.11.0", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.3.3", | |||
"vitest": "^1.2.0" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "workspace:*", | |||
"negotiator": "^0.6.3" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
@@ -35,7 +30,7 @@ | |||
"test": "vitest" | |||
}, | |||
"private": false, | |||
"description": "HTTP server for Yasumi backend.", | |||
"description": "Resource recipe for Yasumi.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
@@ -48,6 +43,9 @@ | |||
"publishConfig": { | |||
"access": "public" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "workspace:*" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", |
@@ -0,0 +1,77 @@ | |||
import {Recipe, endpoint, validation as v} from '@modal-sh/yasumi'; | |||
import {backend, DataSource} from '@modal-sh/yasumi/backend'; | |||
import * as fetchOperation from './implementation/fetch'; | |||
import * as createOperation from './implementation/create'; | |||
import * as emplaceOperation from './implementation/emplace'; | |||
import * as patchDeltaOperation from './implementation/patch-delta'; | |||
import * as patchMergeOperation from './implementation/patch-merge'; | |||
import * as queryOperation from './implementation/query'; | |||
import * as deleteOperation from './implementation/delete'; | |||
interface AddResourceRecipeParams { | |||
endpointName: string; | |||
dataSource?: DataSource; | |||
endpointResourceIdAttr: string; | |||
// TODO specify the available operations to implement | |||
} | |||
export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a) => { | |||
const operations = { | |||
fetch: fetchOperation.operation, | |||
create: createOperation.operation, | |||
emplace: emplaceOperation.operation, | |||
patchMerge: patchMergeOperation.operation, | |||
patchDelta: patchDeltaOperation.operation, | |||
query: queryOperation.operation, | |||
delete: deleteOperation.operation, | |||
}; | |||
const theEndpoint = endpoint({ | |||
name: params.endpointName, | |||
schema: v.object({ | |||
username: v.string(), | |||
}), | |||
}) | |||
.param('resourceId') | |||
.id(params.endpointResourceIdAttr) | |||
.can(fetchOperation.name) | |||
.can(createOperation.name) | |||
.can(emplaceOperation.name) | |||
.can(patchMergeOperation.name) | |||
.can(patchDeltaOperation.name) | |||
.can(queryOperation.name) | |||
.can(deleteOperation.name); | |||
const enhancedApp = a.app | |||
.operation(operations.fetch) | |||
.operation(operations.create) | |||
.operation(operations.emplace) | |||
.operation(operations.patchMerge) | |||
.operation(operations.patchDelta) | |||
.operation(operations.query) | |||
.operation(operations.delete) | |||
.endpoint(theEndpoint); | |||
const theBackend = a.backend ?? backend({ | |||
app: enhancedApp, | |||
dataSource: params.dataSource, | |||
}); | |||
theBackend | |||
.implementOperation(fetchOperation.name, fetchOperation.implementation) | |||
.implementOperation(createOperation.name, createOperation.implementation) | |||
.implementOperation(deleteOperation.name, deleteOperation.implementation) | |||
.implementOperation(queryOperation.name, queryOperation.implementation) | |||
.implementOperation(patchDeltaOperation.name, patchDeltaOperation.implementation) | |||
.implementOperation(patchMergeOperation.name, patchMergeOperation.implementation) | |||
.implementOperation(emplaceOperation.name, emplaceOperation.implementation); | |||
return { | |||
operations, | |||
app: enhancedApp, | |||
backend: theBackend, | |||
endpoints: { | |||
[params.endpointName]: theEndpoint, | |||
}, | |||
}; | |||
}; |
@@ -0,0 +1,89 @@ | |||
import {DataSource, ImplementationFunction} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation, validation as v} from '@modal-sh/yasumi'; | |||
import { | |||
DataSourceMethodNotImplementedResponseError, IncompleteEndpointMetadataResponseError, | |||
ResourceCreatedResponse, | |||
UnableToAssignIdFromDataSourceResponseError, UnableToCreateResourceResponseError, | |||
} from '../response'; | |||
export const name = 'create' as const; | |||
export const method = 'POST' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
const { setLocation, language, endpoint, dataSource, body } = ctx; | |||
const effectiveDataSource: DataSource = endpoint.dataSource ?? dataSource ?? {} as DataSource; | |||
const { newId, create, getTotalCount } = effectiveDataSource; | |||
const idAttr = endpoint.metadata.get('idAttr'); // todo use metadata | |||
if (typeof idAttr !== 'string') { | |||
throw new IncompleteEndpointMetadataResponseError( | |||
// TODO get the status message | |||
); | |||
} | |||
if (typeof newId === 'undefined') { | |||
throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); | |||
} | |||
if (typeof create === 'undefined') { | |||
throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); | |||
} | |||
let resourceNewId; | |||
let params: v.Output<typeof endpoint.schema>; | |||
try { | |||
resourceNewId = await newId(); | |||
params = { ...body as Record<string, unknown> }; | |||
params[idAttr] = resourceNewId; | |||
} catch (cause) { | |||
throw new UnableToAssignIdFromDataSourceResponseError( | |||
language.statusMessages.unableToAssignIdFromResourceDataSource, | |||
{ | |||
cause, | |||
} | |||
); | |||
} | |||
setLocation(`/${endpoint.name}/${resourceNewId}`); | |||
let newObject; | |||
let totalItemCount: number | undefined; | |||
// TODO put this in ImplementationContext argument | |||
const showTotalItemCountOnCreateItem = false; | |||
try { | |||
if ( | |||
showTotalItemCountOnCreateItem | |||
&& typeof getTotalCount === 'function' | |||
) { | |||
totalItemCount = await getTotalCount(); | |||
totalItemCount += 1; | |||
} | |||
newObject = await create(params); | |||
} catch (cause) { | |||
throw new UnableToCreateResourceResponseError( | |||
language.statusMessages.unableToCreateResource, | |||
{ | |||
cause, | |||
} | |||
); | |||
} | |||
const headers: Record<string, string> = {}; | |||
// no need to set location here | |||
if (typeof totalItemCount !== 'undefined') { | |||
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); | |||
} | |||
return new ResourceCreatedResponse({ | |||
statusMessage: language.statusMessages.resourceCreated, | |||
body: newObject, | |||
headers, | |||
}); | |||
}; |
@@ -0,0 +1,15 @@ | |||
import {ImplementationFunction} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation} from '@modal-sh/yasumi'; | |||
export const name = 'delete' as const; | |||
export const method = 'DELETE' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
}; |
@@ -0,0 +1,15 @@ | |||
import {ImplementationFunction} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation} from '@modal-sh/yasumi'; | |||
export const name = 'emplace' as const; | |||
export const method = 'PUT' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
}; |
@@ -0,0 +1,58 @@ | |||
import {ImplementationFunction, DataSource} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation, fromUrlSearchParams} from '@modal-sh/yasumi'; | |||
import { | |||
DataSourceMethodNotImplementedResponseError, | |||
ResourceNotFoundResponseError, | |||
ResourceCollectionFetchedResponse, | |||
ResourceItemFetchedResponse, | |||
} from '../response'; | |||
export const name = 'fetch' as const; | |||
export const method = 'GET' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
const { | |||
params, | |||
endpoint, | |||
dataSource, | |||
language, | |||
query, | |||
} = ctx; | |||
// need to genericise the response here so that we don't depend on the HTTP responses. | |||
const { resourceId } = params; | |||
const effectiveDataSource: DataSource = endpoint.dataSource ?? dataSource ?? {} as DataSource; | |||
const { getById, getMultiple } = effectiveDataSource; | |||
if (typeof resourceId === 'undefined') { | |||
if (typeof getMultiple === 'undefined') { | |||
throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); | |||
} | |||
const dataSourceQuery = typeof query !== 'undefined' ? fromUrlSearchParams(query) : undefined; | |||
const items = await getMultiple(dataSourceQuery); | |||
return new ResourceCollectionFetchedResponse({ | |||
statusMessage: language.statusMessages.resourceCollectionFetched, | |||
body: items, | |||
}); | |||
} | |||
if (typeof getById === 'undefined') { | |||
throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); | |||
} | |||
const item = await getById(resourceId); | |||
if (!item) { | |||
throw new ResourceNotFoundResponseError(language.statusMessages.resourceNotFound); | |||
} | |||
return new ResourceItemFetchedResponse({ | |||
statusMessage: language.statusMessages.resourceFetched, | |||
body: item, | |||
}); | |||
}; |
@@ -0,0 +1,20 @@ | |||
import {ImplementationFunction} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation} from '@modal-sh/yasumi'; | |||
export const name = 'patchDelta' as const; | |||
export const method = 'PATCH' as const; | |||
export const contentType = 'application/json-patch+json' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
headers: { | |||
'Content-Type': contentType, | |||
}, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
}; |
@@ -0,0 +1,20 @@ | |||
import {ImplementationFunction} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation} from '@modal-sh/yasumi'; | |||
export const name = 'patchMerge' as const; | |||
export const method = 'PATCH' as const; | |||
export const contentType = 'application/merge-patch+json' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
headers: { | |||
'Content-Type': contentType, | |||
}, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
}; |
@@ -0,0 +1,15 @@ | |||
import {ImplementationFunction} from '@modal-sh/yasumi/backend'; | |||
import {operation as defineOperation} from '@modal-sh/yasumi'; | |||
export const name = 'query' as const; | |||
export const method = 'QUERY' as const; | |||
export const operation = defineOperation({ | |||
name, | |||
method, | |||
}); | |||
export const implementation: ImplementationFunction = async (ctx) => { | |||
}; |
@@ -0,0 +1,17 @@ | |||
import {HttpResponse, statusCodes} from '@modal-sh/yasumi'; | |||
export class ResourceItemFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} | |||
export class ResourceCollectionFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} | |||
export class DataSourceMethodNotImplementedResponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED) {} | |||
export class ResourceNotFoundResponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_FOUND) {} | |||
export class ResourceCreatedResponse extends HttpResponse(statusCodes.HTTP_STATUS_CREATED) {} | |||
export class UnableToAssignIdFromDataSourceResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} | |||
export class UnableToCreateResourceResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} | |||
export class IncompleteEndpointMetadataResponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED) {} |
@@ -0,0 +1,8 @@ | |||
import { describe, it, expect } from 'vitest'; | |||
import add from '../src'; | |||
describe('blah', () => { | |||
it('works', () => { | |||
expect(add(1, 1)).toEqual(2); | |||
}); | |||
}); |
@@ -1,3 +0,0 @@ | |||
{ | |||
"target": "es2018" | |||
} |