Compare commits

...

17 Commits

Author SHA1 Message Date
  TheoryOfNekomata 517571a6d8 Update tests 5 months ago
  TheoryOfNekomata 51348cf438 Implement create operation 5 months ago
  TheoryOfNekomata 09c65a1dfd Update content negotiation logic 5 months ago
  TheoryOfNekomata 5db84c2c81 Update content negotiation processing 5 months ago
  TheoryOfNekomata de6cdd3d7e Update tests 5 months ago
  TheoryOfNekomata 2865bb7d3a Update package definitions 5 months ago
  TheoryOfNekomata 321be25d00 Update implemention 5 months ago
  TheoryOfNekomata 528a3a47c0 Update operations 5 months ago
  TheoryOfNekomata 37034d23ae Update internals 5 months ago
  TheoryOfNekomata feb105d292 Implement resource query 5 months ago
  TheoryOfNekomata 615eb4ce5b Update structure 5 months ago
  TheoryOfNekomata 392b842351 Implement recipe system 5 months ago
  TheoryOfNekomata 9786d79b7a Test backend implementations 5 months ago
  TheoryOfNekomata 9c08e00fdf Update status codes 5 months ago
  TheoryOfNekomata 0d3f10b08e Add HTTP-related exports 5 months ago
  TheoryOfNekomata f5b6700301 Implement new API 5 months ago
  TheoryOfNekomata b526819cc5 Set up new architecture 5 months ago
100 changed files with 2241 additions and 3982 deletions
Unified View
  1. +0
    -0
      packages/core/.gitignore
  2. +0
    -37
      packages/core/docs/01-faqs.md
  3. +0
    -26
      packages/core/docs/bruno/Create Resource.bru
  4. +0
    -11
      packages/core/docs/bruno/Delete Resource.bru
  5. +0
    -23
      packages/core/docs/bruno/Emplace Resource.bru
  6. +0
    -11
      packages/core/docs/bruno/Get Resource Collection Metadata.bru
  7. +0
    -11
      packages/core/docs/bruno/Get Resource Collection.bru
  8. +0
    -11
      packages/core/docs/bruno/Get Resource Metadata.bru
  9. +0
    -11
      packages/core/docs/bruno/Get Resource.bru
  10. +0
    -22
      packages/core/docs/bruno/Patch Resource.bru
  11. +0
    -13
      packages/core/docs/bruno/bruno.json
  12. +6
    -5
      packages/core/package.json
  13. +0
    -1514
      packages/core/pnpm-lock.yaml
  14. +46
    -68
      packages/core/src/backend/common.ts
  15. +0
    -57
      packages/core/src/backend/core.ts
  16. +8
    -10
      packages/core/src/backend/data-source.ts
  17. +1
    -1
      packages/core/src/backend/index.ts
  18. +16
    -0
      packages/core/src/backend/server.ts
  19. +20
    -44
      packages/core/src/client/index.ts
  20. +109
    -88
      packages/core/src/common/app.ts
  21. +17
    -0
      packages/core/src/common/charset.ts
  22. +26
    -0
      packages/core/src/common/common.ts
  23. +48
    -0
      packages/core/src/common/content-negotiation.ts
  24. +0
    -187
      packages/core/src/common/delta/core.ts
  25. +0
    -7
      packages/core/src/common/delta/error.ts
  26. +0
    -2
      packages/core/src/common/delta/index.ts
  27. +0
    -77
      packages/core/src/common/delta/object.ts
  28. +0
    -23
      packages/core/src/common/delta/utils.ts
  29. +195
    -0
      packages/core/src/common/endpoint.ts
  30. +10
    -14
      packages/core/src/common/index.ts
  31. +44
    -28
      packages/core/src/common/language.ts
  32. +60
    -10
      packages/core/src/common/media-type.ts
  33. +85
    -0
      packages/core/src/common/operation.ts
  34. +0
    -4
      packages/core/src/common/queries/errors.ts
  35. +1
    -2
      packages/core/src/common/queries/index.ts
  36. +0
    -1
      packages/core/src/common/queries/media-types/index.ts
  37. +14
    -27
      packages/core/src/common/queries/parsing.ts
  38. +21
    -0
      packages/core/src/common/recipe.ts
  39. +0
    -205
      packages/core/src/common/resource.ts
  40. +99
    -0
      packages/core/src/common/response.ts
  41. +5
    -0
      packages/core/src/common/service.ts
  42. +205
    -0
      packages/core/src/common/status-codes.ts
  43. +0
    -1
      packages/core/src/common/validation.ts
  44. +0
    -271
      packages/core/test/features/query.test.ts
  45. +0
    -480
      packages/core/test/utils.ts
  46. +2
    -7
      packages/core/tsconfig.json
  47. +1
    -1
      packages/data-sources/file-jsonl/package.json
  48. +0
    -11
      packages/examples/cms-web-api/bruno/Check Allowed Post Operations.bru
  49. +0
    -11
      packages/examples/cms-web-api/bruno/Check Allowed Posts Operations.bru
  50. +0
    -19
      packages/examples/cms-web-api/bruno/Create Post with ID.bru
  51. +0
    -18
      packages/examples/cms-web-api/bruno/Create Post.bru
  52. +0
    -15
      packages/examples/cms-web-api/bruno/Delete Post.bru
  53. +0
    -11
      packages/examples/cms-web-api/bruno/Get Root.bru
  54. +0
    -11
      packages/examples/cms-web-api/bruno/Get Single Post.bru
  55. +0
    -25
      packages/examples/cms-web-api/bruno/Modify Post (Delta).bru
  56. +0
    -23
      packages/examples/cms-web-api/bruno/Modify Post (Merge).bru
  57. +0
    -11
      packages/examples/cms-web-api/bruno/Query Posts.bru
  58. +0
    -19
      packages/examples/cms-web-api/bruno/Replace Post.bru
  59. +0
    -13
      packages/examples/cms-web-api/bruno/bruno.json
  60. +0
    -51
      packages/examples/cms-web-api/package.json
  61. +3
    -0
      packages/examples/cms-web-api/posts.jsonl
  62. +0
    -79
      packages/examples/cms-web-api/src/index.ts
  63. +0
    -282
      packages/examples/cms-web-api/src/languages/tl.ts
  64. +0
    -50
      packages/examples/duckdb/src/index.ts
  65. BIN
      packages/examples/duckdb/test.db
  66. BIN
      packages/examples/duckdb/test.db.wal
  67. +0
    -1
      packages/examples/http-resource-server/.gitignore
  68. +9
    -10
      packages/examples/http-resource-server/package.json
  69. +0
    -0
      packages/examples/http-resource-server/pridepack.json
  70. +6
    -0
      packages/examples/http-resource-server/src/index.ts
  71. +34
    -0
      packages/examples/http-resource-server/src/setup.ts
  72. +140
    -0
      packages/examples/http-resource-server/test/default.test.ts
  73. +18
    -0
      packages/examples/http-resource-server/test/fixtures/data-source.ts
  74. +0
    -0
      packages/examples/http-resource-server/tsconfig.json
  75. +0
    -2
      packages/extenders/http/.gitignore
  76. +0
    -0
      packages/extenders/http/LICENSE
  77. +79
    -0
      packages/extenders/http/package.json
  78. +7
    -0
      packages/extenders/http/pridepack.json
  79. +295
    -0
      packages/extenders/http/src/backend/core.ts
  80. +1
    -0
      packages/extenders/http/src/backend/index.ts
  81. +156
    -0
      packages/extenders/http/src/client/core.ts
  82. +1
    -0
      packages/extenders/http/src/client/index.ts
  83. +0
    -0
      packages/extenders/http/tsconfig.json
  84. +107
    -0
      packages/recipes/resource/.gitignore
  85. +7
    -0
      packages/recipes/resource/LICENSE
  86. +5
    -7
      packages/recipes/resource/package.json
  87. +0
    -0
      packages/recipes/resource/pridepack.json
  88. +77
    -0
      packages/recipes/resource/src/core.ts
  89. +89
    -0
      packages/recipes/resource/src/implementation/create.ts
  90. +15
    -0
      packages/recipes/resource/src/implementation/delete.ts
  91. +15
    -0
      packages/recipes/resource/src/implementation/emplace.ts
  92. +58
    -0
      packages/recipes/resource/src/implementation/fetch.ts
  93. +20
    -0
      packages/recipes/resource/src/implementation/patch-delta.ts
  94. +20
    -0
      packages/recipes/resource/src/implementation/patch-merge.ts
  95. +15
    -0
      packages/recipes/resource/src/implementation/query.ts
  96. +0
    -0
      packages/recipes/resource/src/index.ts
  97. +17
    -0
      packages/recipes/resource/src/response.ts
  98. +8
    -0
      packages/recipes/resource/test/index.test.ts
  99. +0
    -0
      packages/recipes/resource/tsconfig.json
  100. +0
    -3
      packages/servers/http/pridepack.json

packages/servers/http/.gitignore → packages/core/.gitignore View File


+ 0
- 37
packages/core/docs/01-faqs.md View File

@@ -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.

+ 0
- 26
packages/core/docs/bruno/Create Resource.bru View File

@@ -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!"
}
}

+ 0
- 11
packages/core/docs/bruno/Delete Resource.bru View File

@@ -1,11 +0,0 @@
meta {
name: Delete Resource
type: http
seq: 8
}

delete {
url: http://localhost:3000/api/users/2
body: none
auth: none
}

+ 0
- 23
packages/core/docs/bruno/Emplace Resource.bru View File

@@ -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
}
}

+ 0
- 11
packages/core/docs/bruno/Get Resource Collection Metadata.bru View File

@@ -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
}

+ 0
- 11
packages/core/docs/bruno/Get Resource Collection.bru View File

@@ -1,11 +0,0 @@
meta {
name: Get Resource Collection
type: http
seq: 3
}

get {
url: http://localhost:3000/api/users
body: none
auth: none
}

+ 0
- 11
packages/core/docs/bruno/Get Resource Metadata.bru View File

@@ -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
}

+ 0
- 11
packages/core/docs/bruno/Get Resource.bru View File

@@ -1,11 +0,0 @@
meta {
name: Get Resource
type: http
seq: 4
}

get {
url: http://localhost:3000/api/users/1
body: none
auth: none
}

+ 0
- 22
packages/core/docs/bruno/Patch Resource.bru View File

@@ -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!"
}
}

+ 0
- 13
packages/core/docs/bruno/bruno.json View File

@@ -1,13 +0,0 @@
{
"version": "1",
"name": "Yasumi",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"presets": {
"requestType": "http",
"requestUrl": "http://localhost:3000"
}
}

+ 6
- 5
packages/core/package.json View File

@@ -13,11 +13,12 @@
"pridepack" "pridepack"
], ],
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.30",
"@types/negotiator": "^0.6.3",
"@types/node": "^20.11.0",
"pridepack": "2.6.0", "pridepack": "2.6.0",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.4.3",
"vitest": "^1.4.0"
"typescript": "^5.3.3",
"vitest": "^1.2.0"
}, },
"scripts": { "scripts": {
"prepublishOnly": "pridepack clean && pridepack build", "prepublishOnly": "pridepack clean && pridepack build",
@@ -30,7 +31,7 @@
"test": "vitest" "test": "vitest"
}, },
"private": false, "private": false,
"description": "HATEOAS-first backend framework",
"description": "Core module for Yasumi.",
"repository": { "repository": {
"url": "", "url": "",
"type": "git" "type": "git"
@@ -44,7 +45,7 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"tsx": "^4.7.1",
"negotiator": "^0.6.3",
"valibot": "^0.30.0" "valibot": "^0.30.0"
}, },
"types": "./dist/types/common/index.d.ts", "types": "./dist/types/common/index.d.ts",


+ 0
- 1514
packages/core/pnpm-lock.yaml
File diff suppressed because it is too large
View File


+ 46
- 68
packages/core/src/backend/common.ts View File

@@ -1,87 +1,65 @@
import {BaseSchema} from 'valibot';
import { import {
ApplicationState,
BaseResourceType,
ContentNegotiation,
App as BaseApp,
AppOperations,
BaseAppState,
Endpoint,
Language, Language,
LanguageStatusMessageMap,
Resource,
Response,
} from '../common'; } from '../common';
import {DataSource} from './data-source'; 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() {
}
} }

+ 0
- 57
packages/core/src/backend/core.ts View File

@@ -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;
};

+ 8
- 10
packages/core/src/backend/data-source.ts View File

@@ -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; export type DataSourceQuery = QueryAndGrouping;


@@ -13,17 +10,18 @@ export interface DataSource<
ItemData extends object = object, ItemData extends object = object,
ID extends unknown = unknown, ID extends unknown = unknown,
Query extends DataSourceQuery = DataSourceQuery, Query extends DataSourceQuery = DataSourceQuery,
Emplace extends EmplaceDetails = EmplaceDetails,
DeleteResult = unknown,
> { > {
initialize(): Promise<unknown>; initialize(): Promise<unknown>;
getTotalCount?(query?: Query): Promise<TotalCount>;
getTotalCount?(query?: Query): Promise<number>;
getMultiple(query?: Query): Promise<ItemData[]>; getMultiple(query?: Query): Promise<ItemData[]>;
getById(id: ID): Promise<ItemData | null>; getById(id: ID): Promise<ItemData | null>;
getSingle?(query?: Query): Promise<ItemData | null>; getSingle?(query?: Query): Promise<ItemData | null>;
create(data: ItemData): Promise<ItemData>; create(data: ItemData): Promise<ItemData>;
delete(id: ID): Promise<DeleteResult>; 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>; patch(id: ID, data: Partial<ItemData>): Promise<ItemData | null>;
prepareResource(resource: Resource): void;
newId(): Promise<ID>; newId(): Promise<ID>;
} }




+ 1
- 1
packages/core/src/backend/index.ts View File

@@ -1,3 +1,3 @@
export * from './core';
export * from './common'; export * from './common';
export * from './data-source'; export * from './data-source';
export * from './server';

+ 16
- 0
packages/core/src/backend/server.ts View File

@@ -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>;
}

+ 20
- 44
packages/core/src/client/index.ts View File

@@ -1,53 +1,29 @@
import { import {
ApplicationState,
Charset,
FALLBACK_CHARSET,
FALLBACK_LANGUAGE,
FALLBACK_MEDIA_TYPE,
ServiceParams,
App as BaseApp,
Endpoint,
GetEndpointParams,
Operation,
Language, Language,
MediaType, MediaType,
Charset,
} from '../common'; } 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;
};

+ 109
- 88
packages/core/src/common/app.ts View File

@@ -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_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 {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);
}; };

+ 17
- 0
packages/core/src/common/charset.ts View File

@@ -1,3 +1,6 @@
import {isTextMediaType} from './media-type';
import Negotiator from 'negotiator';

export interface Charset<Name extends string = string> { export interface Charset<Name extends string = string> {
name: Name; name: Name;
encode: (str: string) => Buffer; encode: (str: string) => Buffer;
@@ -9,3 +12,17 @@ export const FALLBACK_CHARSET = {
decode: (buf: Buffer) => buf.toString('utf-8'), decode: (buf: Buffer) => buf.toString('utf-8'),
name: 'utf-8' as const, name: 'utf-8' as const,
} satisfies Charset; } 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);
};

+ 26
- 0
packages/core/src/common/common.ts View File

@@ -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;
}
}

+ 48
- 0
packages/core/src/common/content-negotiation.ts View File

@@ -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,
};
};

+ 0
- 187
packages/core/src/common/delta/core.ts View File

@@ -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),
);
};

+ 0
- 7
packages/core/src/common/delta/error.ts View File

@@ -1,7 +0,0 @@
export class InvalidSchemaInPathError extends Error {}

export class InvalidPathValueError extends Error {}

export class InvalidOperationError extends Error {}

export class PathValueTestFailedError extends Error {}

+ 0
- 2
packages/core/src/common/delta/index.ts View File

@@ -1,2 +0,0 @@
export * from './error';
export * from './core';

+ 0
- 77
packages/core/src/common/delta/object.ts View File

@@ -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;
};

+ 0
- 23
packages/core/src/common/delta/utils.ts View File

@@ -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));
};

+ 195
- 0
packages/core/src/common/endpoint.ts View File

@@ -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;

+ 10
- 14
packages/core/src/common/index.ts View File

@@ -1,18 +1,14 @@
import {Language} from './language';
import {Charset} from './charset';
import {MediaType} from './media-type';

export * from './app'; export * from './app';
export * from './charset'; 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 './language';
export * from './media-type';
export * from './operation';
export * from './queries'; 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';

+ 44
- 28
packages/core/src/common/language.ts View File

@@ -1,3 +1,5 @@
import Negotiator from 'negotiator';

export type MessageBody = string | string[] | (string | string[])[]; export type MessageBody = string | string[] | (string | string[])[];


export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [ export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [
@@ -13,7 +15,7 @@ export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [
'badRequest', 'badRequest',
'deleteNonExistingResource', 'deleteNonExistingResource',
'unableToCreateResource', 'unableToCreateResource',
'unableToBindResourceDataSource',
'dataSourceMethodNotImplemented',
'unableToGenerateIdFromResourceDataSource', 'unableToGenerateIdFromResourceDataSource',
'unableToAssignIdFromResourceDataSource', 'unableToAssignIdFromResourceDataSource',
'unableToEmplaceResource', 'unableToEmplaceResource',
@@ -65,41 +67,41 @@ export const FALLBACK_LANGUAGE = {
statusMessages: { statusMessages: {
unableToSerializeResponse: 'Unable To Serialize Response', unableToSerializeResponse: 'Unable To Serialize Response',
unableToEncodeResponse: 'Unable To Encode 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', languageNotAcceptable: 'Language Not Acceptable',
characterSetNotAcceptable: 'Character Set 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', mediaTypeNotAcceptable: 'Media Type Not Acceptable',
methodNotAllowed: 'Method Not Allowed', methodNotAllowed: 'Method Not Allowed',
urlNotFound: 'URL Not Found', urlNotFound: 'URL Not Found',
badRequest: 'Bad Request', badRequest: 'Bad Request',
ok: 'OK', ok: 'OK',
provideOptions: 'Provide Options', 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', 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', notImplemented: 'Not Implemented',
internalServerError: 'Internal Server Error', internalServerError: 'Internal Server Error',
}, },
@@ -209,7 +211,7 @@ export const FALLBACK_LANGUAGE = {
'Contact the administrator regarding missing configuration or unavailability of dependencies.', 'Contact the administrator regarding missing configuration or unavailability of dependencies.',
], ],
], ],
unableToBindResourceDataSource: [
dataSourceMethodNotImplemented: [
'The resource could not be associated from the data source.', 'The resource could not be associated from the data source.',
[ [
'Try the request again at a later time.', 'Try the request again at a later time.',
@@ -340,3 +342,17 @@ export const FALLBACK_LANGUAGE = {
], ],
}, },
} satisfies 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);
};

+ 60
- 10
packages/core/src/common/media-type.ts View File

@@ -1,6 +1,8 @@
import Negotiator from 'negotiator';

export interface MediaType< export interface MediaType<
Name extends string = string, Name extends string = string,
T extends object = object,
T extends object | null = object | null,
SerializeOpts extends {} = {}, SerializeOpts extends {} = {},
DeserializeOpts extends {} = {} DeserializeOpts extends {} = {}
> { > {
@@ -26,12 +28,60 @@ export const getAcceptPostString = (mediaTypes: Map<string, MediaType>) => Array
.filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType)) .filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType))
.join(','); .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,
};
};

+ 85
- 0
packages/core/src/common/operation.ts View File

@@ -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);
};

+ 0
- 4
packages/core/src/common/queries/errors.ts View File

@@ -1,4 +0,0 @@

export class DeserializeError extends Error {}

export class SerializeError extends Error {}

+ 1
- 2
packages/core/src/common/queries/index.ts View File

@@ -1,3 +1,2 @@
export * from './common'; export * from './common';
export * from './errors';
export * as queryMediaTypes from './media-types';
export * from './parsing';

+ 0
- 1
packages/core/src/common/queries/media-types/index.ts View File

@@ -1 +0,0 @@
export * as applicationXWwwFormUrlencoded from './application/x-www-form-urlencoded';

packages/core/src/common/queries/media-types/application/x-www-form-urlencoded.ts → packages/core/src/common/queries/parsing.ts View File

@@ -1,14 +1,14 @@
import { import {
QueryMediaType,
QueryAndGrouping, QueryAndGrouping,
QueryAnyExpression, QueryAnyExpression,
QueryOrGrouping, 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 { interface ProcessEntryBase {
type: string; 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" // we can also make this function act as a "sanitizer"
} }


interface SerializeOptions {}

interface DeserializeOptions { interface DeserializeOptions {
processEntries?: Record<string, ProcessEntry>; processEntries?: Record<string, ProcessEntry>;
} }
@@ -137,17 +135,11 @@ const doesGroupHaveExpression = (ex2: QueryAnyExpression, key: string) => {
return false; 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]) => { (queries, [key, value]) => {
const defaultOr = { const defaultOr = {
type: 'or',
type: 'or' as const,
expressions: [], expressions: [],
} as QueryOrGrouping; } as QueryOrGrouping;
const existingOr = queries.expressions.find((ex) => ( const existingOr = queries.expressions.find((ex) => (
@@ -162,7 +154,7 @@ export const deserialize: QueryMediaType<
expressions: [ expressions: [
...queries.expressions, ...queries.expressions,
{ {
type: 'or',
type: 'or' as const,
expressions: [ expressions: [
newExpression, newExpression,
], ],
@@ -187,11 +179,11 @@ export const deserialize: QueryMediaType<
}; };
}, },
{ {
type: 'and',
type: 'and' as const,
expressions: [], expressions: [],
} as QueryAndGrouping } as QueryAndGrouping
) )
};
);


class SerializeInvalidExpressionError extends SerializeError {} class SerializeInvalidExpressionError extends SerializeError {}


@@ -234,15 +226,10 @@ const serializeExpression = (ex2: QueryAnyExpression) => {
throw new SerializeInvalidExpressionError(`Unknown type for rhs: ${ex2.lhs} ${ex2.operator} <rhs>`); 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( new URLSearchParams(
q.expressions.flatMap((ex) => ( q.expressions.flatMap((ex) => (
ex.expressions.map((ex2) => serializeExpression(ex2)) ex.expressions.map((ex2) => serializeExpression(ex2))
))
)) as [string, string][]
) )
.toString()
); );

+ 21
- 0
packages/core/src/common/recipe.ts View File

@@ -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
)
);

+ 0
- 205
packages/core/src/common/resource.ts View File

@@ -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(',');
}

+ 99
- 0
packages/core/src/common/response.ts View File

@@ -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>;
};

+ 5
- 0
packages/core/src/common/service.ts View File

@@ -0,0 +1,5 @@
export interface ServiceParams {
host?: string;
port?: number;
basePath?: string;
}

+ 205
- 0
packages/core/src/common/status-codes.ts View File

@@ -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;

+ 0
- 1
packages/core/src/common/validation.ts View File

@@ -1 +0,0 @@
export * from 'valibot';

+ 0
- 271
packages/core/test/features/query.test.ts View File

@@ -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',
},
],
},
],
});
});
});

+ 0
- 480
packages/core/test/utils.ts View File

@@ -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.',
],
],
},
};

+ 2
- 7
packages/core/tsconfig.json View File

@@ -3,10 +3,7 @@
"include": ["src", "types"], "include": ["src", "types"],
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
"lib": [
"ESNext",
"dom"
],
"lib": ["ESNext"],
"importHelpers": true, "importHelpers": true,
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
@@ -21,8 +18,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"target": "es2018", "target": "es2018",
"useDefineForClassFields": false, "useDefineForClassFields": false,
"declarationMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"declarationMap": true
} }
} }

+ 1
- 1
packages/data-sources/file-jsonl/package.json View File

@@ -33,7 +33,7 @@
"@modal-sh/yasumi": "workspace:*" "@modal-sh/yasumi": "workspace:*"
}, },
"private": false, "private": false,
"description": "JSON lines file data source for yasumi.",
"description": "JSON lines file data source for Yasumi.",
"repository": { "repository": {
"url": "", "url": "",
"type": "git" "type": "git"


+ 0
- 11
packages/examples/cms-web-api/bruno/Check Allowed Post Operations.bru View File

@@ -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
}

+ 0
- 11
packages/examples/cms-web-api/bruno/Check Allowed Posts Operations.bru View File

@@ -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
}

+ 0
- 19
packages/examples/cms-web-api/bruno/Create Post with ID.bru View File

@@ -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"
}
}

+ 0
- 18
packages/examples/cms-web-api/bruno/Create Post.bru View File

@@ -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"
}
}

+ 0
- 15
packages/examples/cms-web-api/bruno/Delete Post.bru View File

@@ -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
}

+ 0
- 11
packages/examples/cms-web-api/bruno/Get Root.bru View File

@@ -1,11 +0,0 @@
meta {
name: Get Root
type: http
seq: 1
}

get {
url: http://localhost:6969/api
body: none
auth: none
}

+ 0
- 11
packages/examples/cms-web-api/bruno/Get Single Post.bru View File

@@ -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
}

+ 0
- 25
packages/examples/cms-web-api/bruno/Modify Post (Delta).bru View File

@@ -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."
}
]
}

+ 0
- 23
packages/examples/cms-web-api/bruno/Modify Post (Merge).bru View File

@@ -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."
}
}

+ 0
- 11
packages/examples/cms-web-api/bruno/Query Posts.bru View File

@@ -1,11 +0,0 @@
meta {
name: Query Posts
type: http
seq: 3
}

get {
url: http://localhost:6969/api/posts
body: none
auth: none
}

+ 0
- 19
packages/examples/cms-web-api/bruno/Replace Post.bru View File

@@ -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"
}
}

+ 0
- 13
packages/examples/cms-web-api/bruno/bruno.json View File

@@ -1,13 +0,0 @@
{
"version": "1",
"name": "cms-web-api",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"presets": {
"requestType": "http",
"requestUrl": "http://localhost:6969/api/"
}
}

+ 0
- 51
packages/examples/cms-web-api/package.json View File

@@ -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"
}
}

+ 3
- 0
packages/examples/cms-web-api/posts.jsonl View File

@@ -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}

+ 0
- 79
packages/examples/cms-web-api/src/index.ts View File

@@ -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);

+ 0
- 282
packages/examples/cms-web-api/src/languages/tl.ts View File

@@ -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;

+ 0
- 50
packages/examples/duckdb/src/index.ts View File

@@ -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);

BIN
packages/examples/duckdb/test.db View File


BIN
packages/examples/duckdb/test.db.wal View File


packages/examples/cms-web-api/.gitignore → packages/examples/http-resource-server/.gitignore View File

@@ -105,4 +105,3 @@ dist
.tern-port .tern-port


.npmrc .npmrc
*.jsonl

packages/examples/duckdb/package.json → packages/examples/http-resource-server/package.json View File

@@ -1,5 +1,5 @@
{ {
"name": "@modal-sh/yasumi-example-duckdb",
"name": "http-resource-server",
"version": "0.0.0", "version": "0.0.0",
"files": [ "files": [
"dist", "dist",
@@ -18,24 +18,18 @@
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.2.0" "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": { "scripts": {
"prepublishOnly": "pridepack clean && pridepack build", "prepublishOnly": "pridepack clean && pridepack build",
"build": "pridepack build", "build": "pridepack build",
"type-check": "pridepack check", "type-check": "pridepack check",
"clean": "pridepack clean", "clean": "pridepack clean",
"watch": "pridepack watch", "watch": "pridepack watch",
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"start": "pridepack start",
"dev": "pridepack dev",
"test": "vitest" "test": "vitest"
}, },
"private": true, "private": true,
"description": "DuckDB-powered example service.",
"description": "Basic HTTP resource server example for Yasumi.",
"repository": { "repository": {
"url": "", "url": "",
"type": "git" "type": "git"
@@ -47,5 +41,10 @@
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", "author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": { "publishConfig": {
"access": "restricted" "access": "restricted"
},
"dependencies": {
"@modal-sh/yasumi": "workspace:*",
"@modal-sh/yasumi-recipe-resource": "workspace:*",
"@modal-sh/yasumi-extender-http": "workspace:*"
} }
} }

packages/examples/cms-web-api/pridepack.json → packages/examples/http-resource-server/pridepack.json View File


+ 6
- 0
packages/examples/http-resource-server/src/index.ts View File

@@ -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;
}

+ 34
- 0
packages/examples/http-resource-server/src/setup.ts View File

@@ -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,
};
};

+ 140
- 0
packages/examples/http-resource-server/test/default.test.ts View File

@@ -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 });
});
});
});

+ 18
- 0
packages/examples/http-resource-server/test/fixtures/data-source.ts View File

@@ -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 () => {}),
});

packages/examples/cms-web-api/tsconfig.json → packages/examples/http-resource-server/tsconfig.json View File


packages/examples/duckdb/.gitignore → packages/extenders/http/.gitignore View File

@@ -105,5 +105,3 @@ dist
.tern-port .tern-port


.npmrc .npmrc
*.db
*.db.wal

packages/servers/http/LICENSE → packages/extenders/http/LICENSE View File


+ 79
- 0
packages/extenders/http/package.json View File

@@ -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"
]
}
}
}

+ 7
- 0
packages/extenders/http/pridepack.json View File

@@ -0,0 +1,7 @@
{
"target": "es2018",
"entrypoints": {
"./backend": "src/backend/index.ts",
"./client": "src/client/index.ts"
}
}

+ 295
- 0
packages/extenders/http/src/backend/core.ts View File

@@ -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);
};

+ 1
- 0
packages/extenders/http/src/backend/index.ts View File

@@ -0,0 +1 @@
export * from './core';

+ 156
- 0
packages/extenders/http/src/client/core.ts View File

@@ -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);
};

+ 1
- 0
packages/extenders/http/src/client/index.ts View File

@@ -0,0 +1 @@
export * from './core';

packages/examples/duckdb/tsconfig.json → packages/extenders/http/tsconfig.json View File


+ 107
- 0
packages/recipes/resource/.gitignore View File

@@ -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

+ 7
- 0
packages/recipes/resource/LICENSE View File

@@ -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.

packages/servers/http/package.json → packages/recipes/resource/package.json View File

@@ -1,5 +1,5 @@
{ {
"name": "@modal-sh/yasumi-server-http",
"name": "@modal-sh/yasumi-recipe-resource",
"version": "0.0.0", "version": "0.0.0",
"files": [ "files": [
"dist", "dist",
@@ -13,17 +13,12 @@
"pridepack" "pridepack"
], ],
"devDependencies": { "devDependencies": {
"@types/negotiator": "^0.6.3",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"pridepack": "2.6.0", "pridepack": "2.6.0",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.2.0" "vitest": "^1.2.0"
}, },
"dependencies": {
"@modal-sh/yasumi": "workspace:*",
"negotiator": "^0.6.3"
},
"scripts": { "scripts": {
"prepublishOnly": "pridepack clean && pridepack build", "prepublishOnly": "pridepack clean && pridepack build",
"build": "pridepack build", "build": "pridepack build",
@@ -35,7 +30,7 @@
"test": "vitest" "test": "vitest"
}, },
"private": false, "private": false,
"description": "HTTP server for Yasumi backend.",
"description": "Resource recipe for Yasumi.",
"repository": { "repository": {
"url": "", "url": "",
"type": "git" "type": "git"
@@ -48,6 +43,9 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"dependencies": {
"@modal-sh/yasumi": "workspace:*"
},
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",
"main": "./dist/cjs/production/index.js", "main": "./dist/cjs/production/index.js",
"module": "./dist/esm/production/index.js", "module": "./dist/esm/production/index.js",

packages/examples/duckdb/pridepack.json → packages/recipes/resource/pridepack.json View File


+ 77
- 0
packages/recipes/resource/src/core.ts View File

@@ -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,
},
};
};

+ 89
- 0
packages/recipes/resource/src/implementation/create.ts View File

@@ -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,
});
};

+ 15
- 0
packages/recipes/resource/src/implementation/delete.ts View File

@@ -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) => {

};

+ 15
- 0
packages/recipes/resource/src/implementation/emplace.ts View File

@@ -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) => {

};

+ 58
- 0
packages/recipes/resource/src/implementation/fetch.ts View File

@@ -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,
});
};

+ 20
- 0
packages/recipes/resource/src/implementation/patch-delta.ts View File

@@ -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) => {

};

+ 20
- 0
packages/recipes/resource/src/implementation/patch-merge.ts View File

@@ -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) => {

};

+ 15
- 0
packages/recipes/resource/src/implementation/query.ts View File

@@ -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) => {

};

packages/servers/http/src/index.ts → packages/recipes/resource/src/index.ts View File


+ 17
- 0
packages/recipes/resource/src/response.ts View File

@@ -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) {}

+ 8
- 0
packages/recipes/resource/test/index.test.ts View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import add from '../src';

describe('blah', () => {
it('works', () => {
expect(add(1, 1)).toEqual(2);
});
});

packages/servers/http/tsconfig.json → packages/recipes/resource/tsconfig.json View File


+ 0
- 3
packages/servers/http/pridepack.json View File

@@ -1,3 +0,0 @@
{
"target": "es2018"
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save