Browse Source

Set up new architecture

Make architecture more flexible.
refactor/new-arch
TheoryOfNekomata 7 months ago
parent
commit
b526819cc5
100 changed files with 360 additions and 9614 deletions
  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. +5
    -48
      packages/core/package.json
  13. +0
    -1514
      packages/core/pnpm-lock.yaml
  14. +1
    -6
      packages/core/pridepack.json
  15. +0
    -87
      packages/core/src/backend/common.ts
  16. +0
    -57
      packages/core/src/backend/core.ts
  17. +0
    -39
      packages/core/src/backend/data-source.ts
  18. +21
    -3
      packages/core/src/backend/index.ts
  19. +34
    -0
      packages/core/src/backend/server.ts
  20. +20
    -45
      packages/core/src/client/index.ts
  21. +211
    -88
      packages/core/src/common/app.ts
  22. +0
    -11
      packages/core/src/common/charset.ts
  23. +0
    -187
      packages/core/src/common/delta/core.ts
  24. +0
    -7
      packages/core/src/common/delta/error.ts
  25. +0
    -2
      packages/core/src/common/delta/index.ts
  26. +0
    -77
      packages/core/src/common/delta/object.ts
  27. +0
    -23
      packages/core/src/common/delta/utils.ts
  28. +4
    -17
      packages/core/src/common/index.ts
  29. +0
    -342
      packages/core/src/common/language.ts
  30. +0
    -37
      packages/core/src/common/media-type.ts
  31. +0
    -53
      packages/core/src/common/queries/common.ts
  32. +0
    -4
      packages/core/src/common/queries/errors.ts
  33. +0
    -3
      packages/core/src/common/queries/index.ts
  34. +0
    -248
      packages/core/src/common/queries/media-types/application/x-www-form-urlencoded.ts
  35. +0
    -1
      packages/core/src/common/queries/media-types/index.ts
  36. +0
    -205
      packages/core/src/common/resource.ts
  37. +0
    -1
      packages/core/src/common/validation.ts
  38. +62
    -0
      packages/core/src/index.ts
  39. +0
    -271
      packages/core/test/features/query.test.ts
  40. +0
    -0
      packages/core/test/index.test.ts
  41. +0
    -480
      packages/core/test/utils.ts
  42. +2
    -7
      packages/core/tsconfig.json
  43. +0
    -7
      packages/data-sources/duckdb/LICENSE
  44. +0
    -67
      packages/data-sources/duckdb/package.json
  45. +0
    -3
      packages/data-sources/duckdb/pridepack.json
  46. +0
    -268
      packages/data-sources/duckdb/src/index.ts
  47. +0
    -23
      packages/data-sources/duckdb/tsconfig.json
  48. +0
    -107
      packages/data-sources/file-jsonl/.gitignore
  49. +0
    -7
      packages/data-sources/file-jsonl/LICENSE
  50. +0
    -66
      packages/data-sources/file-jsonl/package.json
  51. +0
    -3
      packages/data-sources/file-jsonl/pridepack.json
  52. +0
    -255
      packages/data-sources/file-jsonl/src/index.ts
  53. +0
    -236
      packages/data-sources/file-jsonl/test/index.test.ts
  54. +0
    -23
      packages/data-sources/file-jsonl/tsconfig.json
  55. +0
    -108
      packages/examples/cms-web-api/.gitignore
  56. +0
    -11
      packages/examples/cms-web-api/bruno/Check Allowed Post Operations.bru
  57. +0
    -11
      packages/examples/cms-web-api/bruno/Check Allowed Posts Operations.bru
  58. +0
    -19
      packages/examples/cms-web-api/bruno/Create Post with ID.bru
  59. +0
    -18
      packages/examples/cms-web-api/bruno/Create Post.bru
  60. +0
    -15
      packages/examples/cms-web-api/bruno/Delete Post.bru
  61. +0
    -11
      packages/examples/cms-web-api/bruno/Get Root.bru
  62. +0
    -11
      packages/examples/cms-web-api/bruno/Get Single Post.bru
  63. +0
    -25
      packages/examples/cms-web-api/bruno/Modify Post (Delta).bru
  64. +0
    -23
      packages/examples/cms-web-api/bruno/Modify Post (Merge).bru
  65. +0
    -11
      packages/examples/cms-web-api/bruno/Query Posts.bru
  66. +0
    -19
      packages/examples/cms-web-api/bruno/Replace Post.bru
  67. +0
    -13
      packages/examples/cms-web-api/bruno/bruno.json
  68. +0
    -51
      packages/examples/cms-web-api/package.json
  69. +0
    -3
      packages/examples/cms-web-api/pridepack.json
  70. +0
    -79
      packages/examples/cms-web-api/src/index.ts
  71. +0
    -282
      packages/examples/cms-web-api/src/languages/tl.ts
  72. +0
    -23
      packages/examples/cms-web-api/tsconfig.json
  73. +0
    -109
      packages/examples/duckdb/.gitignore
  74. +0
    -51
      packages/examples/duckdb/package.json
  75. +0
    -3
      packages/examples/duckdb/pridepack.json
  76. +0
    -50
      packages/examples/duckdb/src/index.ts
  77. +0
    -23
      packages/examples/duckdb/tsconfig.json
  78. +0
    -107
      packages/servers/http/.gitignore
  79. +0
    -7
      packages/servers/http/LICENSE
  80. +0
    -68
      packages/servers/http/package.json
  81. +0
    -3
      packages/servers/http/pridepack.json
  82. +0
    -829
      packages/servers/http/src/core.ts
  83. +0
    -29
      packages/servers/http/src/decorators/backend/content-negotiation.ts
  84. +0
    -18
      packages/servers/http/src/decorators/backend/index.ts
  85. +0
    -25
      packages/servers/http/src/decorators/backend/resource.ts
  86. +0
    -24
      packages/servers/http/src/decorators/method/index.ts
  87. +0
    -13
      packages/servers/http/src/decorators/url/base-path.ts
  88. +0
    -13
      packages/servers/http/src/decorators/url/host.ts
  89. +0
    -26
      packages/servers/http/src/decorators/url/index.ts
  90. +0
    -13
      packages/servers/http/src/decorators/url/scheme.ts
  91. +0
    -78
      packages/servers/http/src/handlers/default.ts
  92. +0
    -449
      packages/servers/http/src/handlers/resource.ts
  93. +0
    -2
      packages/servers/http/src/index.ts
  94. +0
    -45
      packages/servers/http/src/response.ts
  95. +0
    -48
      packages/servers/http/src/utils.ts
  96. +0
    -103
      packages/servers/http/test/features/decorators.test.ts
  97. +0
    -604
      packages/servers/http/test/handlers/default.test.ts
  98. +0
    -534
      packages/servers/http/test/handlers/error-handling.test.ts
  99. +0
    -480
      packages/servers/http/test/utils.ts
  100. +0
    -23
      packages/servers/http/tsconfig.json

packages/data-sources/duckdb/.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"
}
}

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

@@ -1,5 +1,5 @@
{
"name": "@modal-sh/yasumi",
"name": "core",
"version": "0.0.0",
"files": [
"dist",
@@ -13,11 +13,11 @@
"pridepack"
],
"devDependencies": {
"@types/node": "^20.11.30",
"@types/node": "^20.11.0",
"pridepack": "2.6.0",
"tslib": "^2.6.2",
"typescript": "^5.4.3",
"vitest": "^1.4.0"
"typescript": "^5.3.3",
"vitest": "^1.2.0"
},
"scripts": {
"prepublishOnly": "pridepack clean && pridepack build",
@@ -30,7 +30,7 @@
"test": "vitest"
},
"private": false,
"description": "HATEOAS-first backend framework",
"description": "Core module for Yasumi.",
"repository": {
"url": "",
"type": "git"
@@ -44,49 +44,6 @@
"access": "public"
},
"dependencies": {
"tsx": "^4.7.1",
"valibot": "^0.30.0"
},
"types": "./dist/types/common/index.d.ts",
"main": "./dist/cjs/production/index.js",
"module": "./dist/esm/production/index.js",
"exports": {
".": {
"development": {
"require": "./dist/cjs/development/index.js",
"import": "./dist/esm/development/index.js"
},
"require": "./dist/cjs/production/index.js",
"import": "./dist/esm/production/index.js",
"types": "./dist/types/common/index.d.ts"
},
"./backend": {
"development": {
"require": "./dist/cjs/development/backend.js",
"import": "./dist/esm/development/backend.js"
},
"require": "./dist/cjs/production/backend.js",
"import": "./dist/esm/production/backend.js",
"types": "./dist/types/backend/index.d.ts"
},
"./client": {
"development": {
"require": "./dist/cjs/development/client.js",
"import": "./dist/esm/development/client.js"
},
"require": "./dist/cjs/production/client.js",
"import": "./dist/esm/production/client.js",
"types": "./dist/types/client/index.d.ts"
}
},
"typesVersions": {
"*": {
"backend": [
"./dist/types/backend/index.d.ts"
],
"client": [
"./dist/types/client/index.d.ts"
]
}
}
}

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


+ 1
- 6
packages/core/pridepack.json View File

@@ -1,8 +1,3 @@
{
"target": "es2018",
"entrypoints": {
".": "src/common/index.ts",
"./backend": "src/backend/index.ts",
"./client": "src/client/index.ts"
}
"target": "es2018"
}

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

@@ -1,87 +0,0 @@
import {BaseSchema} from 'valibot';
import {
ApplicationState,
BaseResourceType,
ContentNegotiation,
Language,
LanguageStatusMessageMap,
Resource,
} from '../common';
import {DataSource} from './data-source';

export interface Server {
requestDecorator(requestDecorator: RequestDecorator): this;
}

export interface BackendState {
app: ApplicationState;
dataSource: DataSource;
cn: ContentNegotiation;
showTotalItemCountOnGetCollection: boolean;
throwsErrorOnDeletingNotFound: boolean;
checksSerializersOnDelete: boolean;
showTotalItemCountOnCreateItem: boolean;
}

export interface RequestContext {}

export interface Middleware {}

export class MiddlewareError extends Error {}

export interface MiddlewareResponseErrorParams extends Omit<Response, 'statusMessage'> {
cause?: unknown;
}

export abstract class MiddlewareResponseError extends MiddlewareError implements Response {
readonly statusMessage: Response['statusMessage'];
readonly statusCode: Response['statusCode'];
readonly headers: Response['headers'];

constructor(statusMessage: keyof Language['statusMessages'], params: MiddlewareResponseErrorParams) {
super(statusMessage, { cause: params.cause });
this.statusCode = params.statusCode;
this.headers = params.headers;
this.statusMessage = statusMessage;
}
}

export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>;

export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator;

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

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 getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => {
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]);
return allowedMethods.join(',');
}

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

+ 0
- 39
packages/core/src/backend/data-source.ts View File

@@ -1,39 +0,0 @@
import * as v from 'valibot';
import {Resource, QueryAndGrouping} from '../common';

type IsCreated = boolean;

type TotalCount = number;

type DeleteResult = unknown;

export type DataSourceQuery = QueryAndGrouping;

export interface DataSource<
ItemData extends object = object,
ID extends unknown = unknown,
Query extends DataSourceQuery = DataSourceQuery,
> {
initialize(): Promise<unknown>;
getTotalCount?(query?: Query): Promise<TotalCount>;
getMultiple(query?: Query): Promise<ItemData[]>;
getById(id: ID): Promise<ItemData | null>;
getSingle?(query?: Query): Promise<ItemData | null>;
create(data: ItemData): Promise<ItemData>;
delete(id: ID): Promise<DeleteResult>;
emplace(id: ID, data: ItemData): Promise<[ItemData, IsCreated]>;
patch(id: ID, data: Partial<ItemData>): Promise<ItemData | null>;
prepareResource(resource: Resource): void;
newId(): Promise<ID>;
}

export interface ResourceIdConfig<IdSchema extends v.BaseSchema> {
generationStrategy: GenerationStrategy;
serialize: (id: unknown) => string;
deserialize: (id: string) => v.Output<IdSchema>;
schema: IdSchema;
}

export interface GenerationStrategy {
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>;
}

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

@@ -1,3 +1,21 @@
export * from './core';
export * from './common';
export * from './data-source';
import { App as BaseApp } from '../common/app';

interface BackendParams<App extends BaseApp> {
app: App;
}

export interface Backend<App extends BaseApp = BaseApp> {
app: App;
}

class BackendInstance<App extends BaseApp> implements Backend<App> {
readonly app: App;

constructor(params: BackendParams<App>) {
this.app = params.app;
}
}

export const backend = <App extends BaseApp>(params: BackendParams<App>): Backend<App> => {
return new BackendInstance(params);
};

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

@@ -0,0 +1,34 @@
import { Backend as BaseBackend } from './index';
import http from 'http';

interface ServerParams<Backend extends BaseBackend = BaseBackend> {
backend: Backend;
}

export interface Server<Backend extends BaseBackend = BaseBackend> {
backend: Backend;
host(params: ServiceParams): this;
}

class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
readonly backend: Backend;
private readonly serverInternal;

constructor(params: ServerParams<Backend>) {
this.backend = params.backend;
this.serverInternal = new http.Server(this.requestListener);
}

private readonly requestListener = (req, res) => {

};

host(params: ServiceParams) {
this.serverInternal.listen(params.port, params.host);
return this;
}
}

export const server = <Backend extends BaseBackend>(params: ServerParams<Backend>): Server<Backend> => {
return new ServerInstance(params);
};

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

@@ -1,53 +1,28 @@
import {
ApplicationState,
Charset,
FALLBACK_CHARSET,
FALLBACK_LANGUAGE,
FALLBACK_MEDIA_TYPE,
Language,
MediaType,
} from '../common';
import { App as BaseApp } from '../common/app';

export interface ClientState {
app: ApplicationState;
mediaType: MediaType;
charset: Charset;
language: Language;
interface ClientParams<App extends BaseApp> {
app: App;
}

export interface ClientBuilder {
language(languageCode: ClientState['language']['name']): this;
charset(charset: ClientState['charset']['name']): this;
mediaType(mediaType: ClientState['mediaType']['name']): this;
interface Client<App extends BaseApp> {
app: App;
connect(params: ServiceParams): this;
}

export interface CreateClientParams {
app: ApplicationState;
}
class ClientInstance<App extends BaseApp> implements Client<App> {
readonly app: App;
private connection: ServiceParams;

constructor(params: ClientParams<App>) {
this.app = params.app;
}

export const createClient = (params: CreateClientParams) => {
const clientState: ClientState = {
app: params.app,
mediaType: FALLBACK_MEDIA_TYPE,
charset: FALLBACK_CHARSET,
language: FALLBACK_LANGUAGE
};
connect(params: ServiceParams) {
this.connection = params;
return this;
}
}

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;
export const client = <App extends BaseApp>(params: ClientParams<App>): Client<App> => {
return new ClientInstance(params);
};

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

@@ -1,100 +1,223 @@
import {BaseResourceType, Resource} from './resource';
import {FALLBACK_LANGUAGE, Language} from './language';
import {FALLBACK_MEDIA_TYPE, MediaType, PATCH_CONTENT_TYPES} from './media-type';
import {Charset, FALLBACK_CHARSET} from './charset';
import * as v from 'valibot';
import {Backend, createBackend, CreateBackendParams} from '../backend';
import {ClientBuilder, createClient, CreateClientParams} from '../client';

type ApplicationMap<T extends { name: string }> = Map<T['name'], T>;
interface BaseEndpointState {
operations: unknown;
}

type OpValueType = undefined | boolean;

export interface ApplicationState {
name: string;
resources: Set<Resource<any>>;
languages: ApplicationMap<Language>;
mediaTypes: ApplicationMap<MediaType>;
charsets: ApplicationMap<Charset>;
export interface Endpoint<Schema extends {} = {}, State extends BaseEndpointState = BaseEndpointState> {
schema: Schema;
can<OpName extends string = string, OpValue extends OpValueType = OpValueType>(
op: OpName,
value?: OpValue
): Endpoint<
Schema,
{
operations: State['operations'] extends string[] ? readonly [...State['operations'], OpName] : [OpName],
}
>;
}

export interface ApplicationParams {
name: string;
interface EndpointParams<Schema extends v.BaseSchema = v.BaseSchema> {
schema: Schema;
}

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
class EndpointInstance<
Params extends EndpointParams,
State extends BaseEndpointState
> implements Endpoint<Params['schema'], State> {
readonly operations: Set<string>;
readonly schema: Params['schema'];

constructor(params: Params) {
this.schema = params.schema;
this.operations = new Set<string>();
}

can<OpName extends string = string, OpValue extends OpValueType = OpValueType>(
op: OpName,
value?: OpValue
): Endpoint<
Params['schema'],
{
operations: State['operations'] extends string[] ? readonly [...State['operations'], OpName] : [OpName],
}
> {
if (value) {
this.operations.add(op);
} else {
this.operations.delete(op);
}

return this;
}
}

export const endpoint = <Params extends EndpointParams>(params: Params): Endpoint<v.Output<Params['schema']>> => {
return new EndpointInstance(params);
};

export interface BaseAppState {
endpoints: unknown;
operations: unknown;
}

type EndpointOperations<T extends Endpoint> = T extends Endpoint<any, infer R> ? (
R extends { operations: Record<number, any> } ? R['operations'][number] : []
) : [];

type AppOperations<T extends App> = (
T extends App<any, infer R>
? R extends BaseAppState
? keyof R['operations']
: never
: never
);



export interface App<Name extends string = string, State extends BaseAppState = {
endpoints: [];
operations: Record<never, []>;
}> {
name: Name;
operation<
OperationName extends string,
OperationParams extends BaseOperationParams<OperationName>,
NewOperation extends Operation<OperationParams>
>(newOperation: NewOperation): App<
Name,
{
endpoints: State['endpoints'],
operations: keyof State['operations'] extends never ? {
[Key in NewOperation['name']]: (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
} : {
[Key in NewOperation['name'] | keyof State['operations']]: (
State['operations'] extends Record<Key, any>
? (
State['operations'][Key] extends readonly string[] ? State['operations'][Key] : (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
)
: (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
);
}
}
>;
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<
Name,
{
endpoints: State['endpoints'] extends Array<unknown> ? [...State['endpoints'], NewEndpoint] : [NewEndpoint],
operations: State['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],
]),
};

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
});
},
};
interface BaseOperationParams<Name extends string = string, Args extends readonly string[] = readonly string[]> {
name: Name;
args?: Args;
}

interface Operation<Params extends BaseOperationParams = BaseOperationParams> {
name: Params['name'];
args: Params['args'];
}

class OperationInstance<Params extends BaseOperationParams = BaseOperationParams> implements Operation<Params> {
readonly name: Params['name'];
readonly args: Params['args'];

constructor(params: Params) {
this.name = params.name;
this.args = params.args;
}
}

export const operation = <Params extends BaseOperationParams = BaseOperationParams>(
params: Params
): Operation<Params> => {
return new OperationInstance(params);
};

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: Set<Endpoint>;
readonly operations: Set<Operation>;

constructor(params: Params) {
this.name = params.name;
this.endpoints = new Set<Endpoint>();
this.operations = new Set<Operation>();
}

operation<NewOperation extends Operation>(newOperation: NewOperation): App<
Params['name'],
{
endpoints: State['endpoints'],
operations: keyof State['operations'] extends never ? {
[Key in NewOperation['name']]: (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
} : {
[Key in NewOperation['name'] | keyof State['operations']]: (
State['operations'] extends Record<Key, any>
? (
State['operations'][Key] extends readonly string[] ? State['operations'][Key] : (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
)
: (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
);
}
}
> {
this.operations.add(newOperation);
return this as App<
Params['name'],
{
endpoints: State['endpoints'],
operations: {
[Key in NewOperation['name'] | keyof State['operations']]: (
State['operations'] extends Record<Key, any>
? (
State['operations'][Key] extends readonly string[] ? State['operations'][Key] : (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
)
: (
Exclude<NewOperation['args'], undefined> extends readonly string[] ? Exclude<NewOperation['args'], undefined> : never[]
)
);
}
}
>;
}

endpoint<NewEndpoint extends Endpoint = Endpoint>(newEndpoint: NewEndpoint): App<
Params['name'],
{
endpoints: State['endpoints'] extends Array<unknown> ? [...State['endpoints'], NewEndpoint] : [NewEndpoint],
operations: State['operations']
}
> {
this.endpoints.add(newEndpoint);
return this;
}
}

export const app = <Params extends AppParams>(params: Params): App<Params['name']> => {
return new AppInstance(params);
};

export * as validation from 'valibot';

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

@@ -1,11 +0,0 @@
export interface Charset<Name extends string = string> {
name: Name;
encode: (str: string) => Buffer;
decode: (buf: Buffer) => string;
}

export const FALLBACK_CHARSET = {
encode: (str: string) => Buffer.from(str, 'utf-8'),
decode: (buf: Buffer) => buf.toString('utf-8'),
name: 'utf-8' as const,
} satisfies 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));
};

+ 4
- 17
packages/core/src/common/index.ts View File

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

export * from './app';
export * from './charset';
export * from './delta';
export * from './media-type';
export * from './resource';
export * from './language';
export * from './queries';
export * as validation from './validation';

export interface ContentNegotiation {
language: Language;
mediaType: MediaType;
charset: Charset;
interface ServiceParams {
host?: string;
port?: number;
basePath?: string;
}

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

@@ -1,342 +0,0 @@
export type MessageBody = string | string[] | (string | string[])[];

export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [
'unableToInitializeResourceDataSource',
'unableToFetchResourceCollection',
'unableToFetchResource',
'resourceIdNotGiven',
'languageNotAcceptable',
'characterSetNotAcceptable',
'mediaTypeNotAcceptable',
'methodNotAllowed',
'urlNotFound',
'badRequest',
'deleteNonExistingResource',
'unableToCreateResource',
'unableToBindResourceDataSource',
'unableToGenerateIdFromResourceDataSource',
'unableToAssignIdFromResourceDataSource',
'unableToEmplaceResource',
'unableToSerializeResponse',
'unableToEncodeResponse',
'unableToDeleteResource',
'unableToDeserializeResource',
'unableToDecodeResource',
'unableToDeserializeRequest',
'patchNonExistingResource',
'unableToPatchResource',
'invalidResourcePatch',
'invalidResourcePatchType',
'invalidResource',
'notImplemented',
'internalServerError',
'resourceNotFound',
] as const;

export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [
...LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS,
'ok',
'resourceCollectionFetched',
'resourceCollectionQueried',
'resourceFetched',
'resourceDeleted',
'resourcePatched',
'resourceCreated',
'resourceReplaced',
'provideOptions',
] as const;

export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number];

export interface LanguageStatusMessageMap extends Record<LanguageDefaultStatusMessageKey, string> {}

export type LanguageDefaultErrorStatusMessageKey = typeof LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS[number];

export interface LanguageBodyMap extends Record<LanguageDefaultErrorStatusMessageKey, MessageBody> {}

export interface Language<Name extends string = string> {
name: Name,
statusMessages: LanguageStatusMessageMap,
bodies: LanguageBodyMap
}

export const FALLBACK_LANGUAGE = {
name: 'en' as const,
statusMessages: {
unableToSerializeResponse: 'Unable To Serialize Response',
unableToEncodeResponse: 'Unable To Encode Response',
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source',
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source',
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection',
unableToFetchResource: 'Unable To Fetch $RESOURCE',
unableToDeleteResource: 'Unable To Delete $RESOURCE',
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',
resourceCollectionQueried: '$RESOURCE Collection Queried',
resourceFetched: '$RESOURCE Fetched',
resourceNotFound: '$RESOURCE Not Found',
deleteNonExistingResource: 'Delete Non-Existing $RESOURCE',
resourceDeleted: '$RESOURCE Deleted',
unableToDeserializeRequest: 'Unable To Deserialize Request',
patchNonExistingResource: 'Patch Non-Existing $RESOURCE',
unableToPatchResource: 'Unable To Patch $RESOURCE',
invalidResourcePatch: 'Invalid $RESOURCE Patch',
invalidResourcePatchType: 'Invalid $RESOURCE Patch Type',
invalidResource: 'Invalid $RESOURCE',
resourcePatched: '$RESOURCE Patched',
resourceCreated: '$RESOURCE Created',
resourceReplaced: '$RESOURCE Replaced',
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source',
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source',
unableToEmplaceResource: 'Unable To Emplace $RESOURCE',
resourceIdNotGiven: '$RESOURCE ID Not Given',
unableToCreateResource: 'Unable To Create $RESOURCE',
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.',
],
],
},
} satisfies Language;

+ 0
- 37
packages/core/src/common/media-type.ts View File

@@ -1,37 +0,0 @@
export interface MediaType<
Name extends string = string,
T extends object = object,
SerializeOpts extends {} = {},
DeserializeOpts extends {} = {}
> {
name: Name;
serialize: (object: T, args?: SerializeOpts) => string;
deserialize: (s: string, args?: DeserializeOpts) => T;
}

export const FALLBACK_MEDIA_TYPE = {
serialize: (obj: unknown) => JSON.stringify(obj),
deserialize: (str: string) => JSON.parse(str),
name: 'application/json' as const,
} satisfies MediaType;

export const PATCH_CONTENT_TYPES = [
'application/merge-patch+json',
'application/json-patch+json',
] as const;

export type PatchContentType = typeof PATCH_CONTENT_TYPES[number];

export const getAcceptPostString = (mediaTypes: Map<string, MediaType>) => Array.from(mediaTypes.keys())
.filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType))
.join(',');

export const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')
|| [
'application/json',
'application/xml',
'application/x-www-form-urlencoded',
...PATCH_CONTENT_TYPES,
].includes(mediaType)
);

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

@@ -1,53 +0,0 @@
import {MediaType} from '../media-type';

const OPERATORS = [
'=',
'!=',
'>=',
'<=',
'>',
'<',
'LIKE',
'ILIKE',
'REGEXP',
] as const;

type QueryOperator = typeof OPERATORS[number];

type QueryExpressionValue = string | number | boolean;

interface QueryOperatorExpression {
lhs: string;
operator: QueryOperator;
rhs: QueryExpressionValue | RegExp;
}

interface QueryFunctionExpression {
name: string;
args: QueryExpressionValue[];
}

export type QueryAnyExpression = QueryOperatorExpression | QueryFunctionExpression;

export interface QueryOrGrouping {
type: 'or';
expressions: QueryAnyExpression[];
}

export interface QueryAndGrouping {
type: 'and';
expressions: QueryOrGrouping[];
}

export type Query = QueryAndGrouping;

export interface QueryMediaType<
Name extends string = string,
SerializeOptions extends {} = {},
DeserializeOptions extends {} = {}
> extends MediaType<
Name,
QueryAndGrouping,
SerializeOptions,
DeserializeOptions
> {}

+ 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 {}

+ 0
- 3
packages/core/src/common/queries/index.ts View File

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

+ 0
- 248
packages/core/src/common/queries/media-types/application/x-www-form-urlencoded.ts View File

@@ -1,248 +0,0 @@
import {
QueryMediaType,
QueryAndGrouping,
QueryAnyExpression,
QueryOrGrouping,
} from '../../common';

import {
DeserializeError,
SerializeError,
} from '../../errors';

interface ProcessEntryBase {
type: string;
}

const AVAILABLE_MATCH_TYPES = [
'startsWith',
'endsWith',
'includes',
'regexp',
] as const;

type MatchType = typeof AVAILABLE_MATCH_TYPES[number];

interface ProcessEntryString extends ProcessEntryBase {
type: 'string';
matchType?: MatchType;
caseInsensitiveMatch?: boolean;
}

interface ProcessEntryNumber extends ProcessEntryBase {
type: 'number';
decimal?: boolean;
}

interface ProcessEntryBoolean extends ProcessEntryBase {
type: 'boolean';
truthyStrings?: string[];
}

type ProcessEntry = ProcessEntryString | ProcessEntryNumber | ProcessEntryBoolean;

export const name = 'application/x-www-form-urlencoded' as const;

class DeserializeInvalidFormatError extends DeserializeError {}

const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<string, ProcessEntry>) => {
const defaultCoerceValues = {
type: 'string'
} as ProcessEntry;
const coerceValues = processEntriesMap?.[lhs] ?? defaultCoerceValues;

if (coerceValues?.type === 'number') {
return {
lhs,
operator: '=',
rhs: Number(rhs)
} as QueryAnyExpression;
}

if (coerceValues?.type === 'boolean') {
const truthyStrings = [
...(coerceValues.truthyStrings ?? []).map((s) => s.trim().toLowerCase()),
'true',
];

return {
lhs,
operator: '=',
rhs: truthyStrings.includes(rhs)
} as QueryAnyExpression;
}

if (coerceValues?.type === 'string') {
switch (coerceValues?.matchType) {
case 'startsWith': {
return {
lhs,
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE',
rhs: `%${rhs}`,
} as QueryAnyExpression;
}
case 'endsWith': {
return {
lhs,
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE',
rhs: `${rhs}%`,
} as QueryAnyExpression;
}
case 'includes': {
return {
lhs,
operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE',
rhs: `%${rhs}%`,
} as QueryAnyExpression;
}
case 'regexp': {
return {
lhs,
operator: 'REGEXP',
rhs: new RegExp(rhs, coerceValues.caseInsensitiveMatch ? 'i' : ''),
} as QueryAnyExpression;
}
default:
break;
}

return {
lhs,
operator: '=',
rhs,
} as QueryAnyExpression;
}

const unknownCoerceValues = coerceValues as unknown as Record<string, string>;
throw new DeserializeInvalidFormatError(`Invalid coercion type: ${unknownCoerceValues.type}`);
// this will be sent to the data source, e.g., the SQL query
// we can also make this function act as a "sanitizer"
}

interface SerializeOptions {}

interface DeserializeOptions {
processEntries?: Record<string, ProcessEntry>;
}

const doesGroupHaveExpression = (ex2: QueryAnyExpression, key: string) => {
if ('operator' in ex2) {
return ex2.lhs === key;
}

if ('name' in ex2) {
return ex2.name === key;
}

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(
(queries, [key, value]) => {
const defaultOr = {
type: 'or',
expressions: [],
} as QueryOrGrouping;
const existingOr = queries.expressions.find((ex) => (
ex.expressions.some((ex2) => doesGroupHaveExpression(ex2, key))
)) ?? defaultOr;
const existingLhs = existingOr.expressions.find((ex) => doesGroupHaveExpression(ex, key));
const newExpression = normalizeRhs(key, value, options.processEntries);

if (typeof existingLhs === 'undefined') {
return {
...queries,
expressions: [
...queries.expressions,
{
type: 'or',
expressions: [
newExpression,
],
},
],
};
}

return {
...queries,
expressions: queries.expressions.map((ex) => (
ex.expressions.some((ex2) => !doesGroupHaveExpression(ex2, key))
? ex
: {
...existingOr,
expressions: [
...(existingOr.expressions ?? []),
newExpression,
],
}
)),
};
},
{
type: 'and',
expressions: [],
} as QueryAndGrouping
)
};

class SerializeInvalidExpressionError extends SerializeError {}

const serializeExpression = (ex2: QueryAnyExpression) => {
if ('name' in ex2) {
return [ex2.name, `(${ex2.args.map((s) => s.toString()).join(',')})`];
}

if (ex2.rhs instanceof RegExp) {
if (ex2.operator !== 'REGEXP') {
throw new SerializeInvalidExpressionError(`Invalid rhs given for operator: ${ex2.lhs} ${ex2.operator} <rhs>`);
}

return [ex2.lhs, ex2.rhs.toString()];
}

switch (typeof ex2.rhs) {
case 'string': {
switch (ex2.operator) {
case 'ILIKE':
case 'LIKE':
return [ex2.lhs, ex2.rhs.replace(/^%+/, '').replace(/%+$/, '')];
case '=':
return [ex2.lhs, ex2.rhs];
default:
break;
}
throw new SerializeInvalidExpressionError(`Invalid operator given for lhs: ${ex2.lhs} <op> ${ex2.rhs}`);
}
case 'number': {
return [ex2.lhs, ex2.rhs.toString()];
}
case 'boolean': {
return [ex2.lhs, ex2.rhs ? 'true' : 'false'];
}
default:
break;
}

throw new SerializeInvalidExpressionError(`Unknown type for rhs: ${ex2.lhs} ${ex2.operator} <rhs>`);
};

export const serialize: QueryMediaType<
typeof name,
SerializeOptions,
DeserializeOptions
>['serialize'] = (q: QueryAndGrouping) => (
new URLSearchParams(
q.expressions.flatMap((ex) => (
ex.expressions.map((ex2) => serializeExpression(ex2))
))
)
.toString()
);

+ 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';

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

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

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

+ 62
- 0
packages/core/src/index.ts View File

@@ -0,0 +1,62 @@
import {app, endpoint, operation, validation as v} from './common/app';

const theEndpoint = endpoint({
schema: v.object({
username: v.string(),
}),
})
.can('patch')
.can('query');

const canPatch = operation({
name: 'patch' as const,
args: [
'merge',
'delta',
] as const,
// TODO define resource-specific stuff, like defining URL params, etc.
});

const canFetch = operation({
name: 'fetch' as const,
args: [
'item',
'collection',
] as const,
});

const canQuery = operation({
name: 'query' as const,
});

const canCreate = operation({
name: 'create' as const,
});

const canEmplace = operation({
name: 'emplace' as const,
});

const canDelete = operation({
name: 'delete' as const,
});

export const theApp = app({
name: 'foo' as const,
})
.operation(canQuery)
.operation(canPatch)
.operation(canFetch)
.operation(canCreate)
.operation(canEmplace)
.operation(canDelete)
.endpoint(theEndpoint);
//
// const bootstrap = async (theApp: App) => {
// if (typeof window === 'undefined') {
// const { backend } = await import('./backend');
// const theBackend = backend({
// app: theApp
// });
// }
// };

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

packages/data-sources/duckdb/test/index.test.ts → packages/core/test/index.test.ts View File


+ 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"],
"compilerOptions": {
"module": "ESNext",
"lib": [
"ESNext",
"dom"
],
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
@@ -21,8 +18,6 @@
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"declarationMap": true
}
}

+ 0
- 7
packages/data-sources/duckdb/LICENSE View File

@@ -1,7 +0,0 @@
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.

+ 0
- 67
packages/data-sources/duckdb/package.json View File

@@ -1,67 +0,0 @@
{
"name": "@modal-sh/yasumi-data-source-duckdb",
"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"
},
"dependencies": {
"@modal-sh/yasumi": "workspace:*",
"duckdb-async": "^0.10.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": "DuckDB adapter for Yasumi.",
"repository": {
"url": "",
"type": "git"
},
"homepage": "",
"bugs": {
"url": ""
},
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": {
"access": "public"
},
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/production/index.js",
"module": "./dist/esm/production/index.js",
"exports": {
".": {
"development": {
"require": "./dist/cjs/development/index.js",
"import": "./dist/esm/development/index.js"
},
"require": "./dist/cjs/production/index.js",
"import": "./dist/esm/production/index.js",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {}
}
}

+ 0
- 3
packages/data-sources/duckdb/pridepack.json View File

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

+ 0
- 268
packages/data-sources/duckdb/src/index.ts View File

@@ -1,268 +0,0 @@
import { Resource, validation as v, BaseResourceType } from '@modal-sh/yasumi';
import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend';
import { Database } from 'duckdb-async';
import assert from 'assert';

type ID = number;

interface DuckDbDataSourceBase<
ID,
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
Data extends object = v.Output<Schema>,
> extends DataSource<Data, ID> {
resource?: Resource<BaseResourceType & {
schema: Schema,
name: CurrentName,
routeName: CurrentRouteName,
}>;

db?: Database;
}

export const AutoincrementIdConfig = {
// TODO add options: https://duckdb.org/docs/sql/statements/create_sequence
generationStrategy: async (dataSourceRaw: DataSource) => {
const dataSource = dataSourceRaw as DuckDbDataSourceBase<ID>;
assert(typeof dataSource.db !== 'undefined');
assert(typeof dataSource.resource !== 'undefined');
const idAttr = dataSource.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const con = await dataSource.db.connect();
const stmt = await con.prepare(`
SELECT nextval('${dataSource.resource.state.routeName}_sequence') as ${idAttr};
`);
const [v] = await stmt.all();
return v[idAttr];
},
schema: v.number(),
serialize: (v: unknown) => v?.toString() ?? '',
deserialize: (v: string) => Number(v),
}

export class DuckDbDataSource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
Data extends object = v.Output<Schema>,
> implements DuckDbDataSourceBase<ID, Schema, CurrentName, CurrentRouteName, Data> {
resource?: Resource<BaseResourceType & {
schema: Schema,
name: CurrentName,
routeName: CurrentRouteName,
}>;

db?: Database;

constructor(private readonly path: string) {
// noop
}

async initialize() {
assert(typeof this.path !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const idSchema = idConfig.schema as v.BaseSchema;

this.db = await Database.create(this.path);
const clause = `CREATE TABLE IF NOT EXISTS ${this.resource.state.routeName}`;
const resourceSchema = this.resource.schema as unknown as v.ObjectSchema<any>;
const tableSchema = Object.entries(resourceSchema.entries)
.map(([columnName, columnDefRaw]) => {
const columnDef = columnDefRaw as unknown as v.BaseSchema;
return [columnName, columnDef.type].join(' ');
})
.join(',');
let sequenceSql = '';
let defaultValue = '';
let idType = 'STRING';
if (idSchema.type === 'number') {
// TODO support more sequence statements: https://duckdb.org/docs/sql/statements/create_sequence
sequenceSql = `CREATE SEQUENCE IF NOT EXISTS ${this.resource.state.routeName}_sequence START 1;`;
defaultValue = `DEFAULT nextval('${this.resource.state.routeName}_sequence')`;
idType = 'INTEGER';
}
const sql = `${sequenceSql}${clause} (${idAttr} ${idType} ${defaultValue},${tableSchema});`;
const con = await this.db.connect();
const stmt = await con.prepare(sql);
await stmt.run();
}

prepareResource<Schema extends v.BaseSchema>(resource: Resource<BaseResourceType & {
schema: Schema,
name: CurrentName,
routeName: CurrentRouteName,
}>) {
resource.dataSource = resource.dataSource ?? this;
const originalResourceId = resource.id;
resource.id = <NewIdAttr extends BaseResourceType['idAttr'], NewIdSchema extends BaseResourceType['idSchema']>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => {
originalResourceId(newIdAttr, params);
return resource as Resource<BaseResourceType & {
name: CurrentName,
routeName: CurrentRouteName,
schema: Schema,
idAttr: NewIdAttr,
idSchema: NewIdSchema,
}>;
};
this.resource = resource as any;
}

async getMultiple(query) {
// TODO translate query to SQL statements
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const con = await this.db.connect();
const stmt = await con.prepare(`
SELECT * FROM ${this.resource.state.routeName};
`);
const data = await stmt.all();
return data as Data[];
}

async newId() {
assert(typeof this.resource !== 'undefined');
const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any>;
assert(typeof idConfig !== 'undefined');

const theNewId = await idConfig.generationStrategy(this);
return theNewId as ID;
}

async create(data: Data) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

let theId: any;
const { [idAttr]: dataId } = data as Record<string, unknown>;
if (typeof dataId !== 'undefined') {
theId = idConfig.deserialize((data as Record<string, string>)[idAttr]);
} else {
const newId = await this.newId();
theId = idConfig.deserialize(newId.toString());
}
const effectiveData = {
...data,
} as Record<string, unknown>;
effectiveData[idAttr] = theId;

const clause = `INSERT INTO ${this.resource.state.routeName}`;
const keys = Object.keys(effectiveData).join(',');
const values = Object.values(effectiveData).map((d) => JSON.stringify(d).replace(/"/g, "'")).join(',');
const sql = `${clause} (${keys}) VALUES (${values});`;
const con = await this.db.connect();
const stmt = await con.prepare(sql);
await stmt.run();
const newData = {
...effectiveData
};
return newData as Data;
}

async getTotalCount(query) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const con = await this.db.connect();
const stmt = await con.prepare(`
SELECT COUNT(*) as c FROM ${this.resource.state.routeName};
`);
const [data] = await stmt.all();
return data['c'] as unknown as number;
}

async getById(id: ID) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const con = await this.db.connect();
const stmt = await con.prepare(`
SELECT * FROM ${this.resource.state.routeName} WHERE ${idAttr} = ${JSON.stringify(id).replace(/"/g, "'")};
`);
const [data = null] = await stmt.all();
return data as Data | null;
}

async getSingle(query) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const con = await this.db.connect();
const stmt = await con.prepare(`
SELECT * FROM ${this.resource.state.routeName} LIMIT 1;
`);
const [data = null] = await stmt.all();
return data as Data | null;
}

async delete(id: ID) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const con = await this.db.connect();
const stmt = await con.prepare(`
DELETE FROM ${this.resource.state.routeName} WHERE ${idAttr} = ${JSON.stringify(id).replace(/"/g, "'")};
`);
await stmt.run();
}

async emplace(id: ID, data: Data) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const clause = `INSERT OR REPLACE INTO ${this.resource.state.routeName}`;
const keys = Object.keys(data).join(',');
const values = Object.values(data).map((d) => JSON.stringify(d).replace(/"/g, "'")).join(',');
const sql = `${clause} (${idAttr},${keys}) VALUES (${id},${values});`;
const con = await this.db.connect();
const stmt = await con.prepare(sql);
const [newData] = await stmt.all();
// TODO check if created flag
return [newData, false] as [Data, boolean];
}

async patch(id: ID, data: Partial<Data>) {
assert(typeof this.db !== 'undefined');
assert(typeof this.resource !== 'undefined');

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string');

const clause = `UPDATE ${this.resource.state.routeName}`;
const setParams = Object.entries(data).map(([key, value]) => (
`${key} = ${JSON.stringify(value).replace(/"/g, "'")}`
)).join(',');
const sql = `${clause} SET ${setParams} WHERE ${idAttr} = ${JSON.stringify(id).replace(/"/g, "'")}`
const con = await this.db.connect();
const stmt = await con.prepare(sql);
const [newData] = await stmt.all();
return newData as Data;
}
}

+ 0
- 23
packages/data-sources/duckdb/tsconfig.json View File

@@ -1,23 +0,0 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true
}
}

+ 0
- 107
packages/data-sources/file-jsonl/.gitignore View File

@@ -1,107 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.production
.env.development

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

.npmrc

+ 0
- 7
packages/data-sources/file-jsonl/LICENSE View File

@@ -1,7 +0,0 @@
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.

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

@@ -1,66 +0,0 @@
{
"name": "@modal-sh/yasumi-data-source-file-jsonl",
"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"
},
"dependencies": {
"@modal-sh/yasumi": "workspace:*"
},
"private": false,
"description": "JSON lines file data source for yasumi.",
"repository": {
"url": "",
"type": "git"
},
"homepage": "",
"bugs": {
"url": ""
},
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": {
"access": "public"
},
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/production/index.js",
"module": "./dist/esm/production/index.js",
"exports": {
".": {
"development": {
"require": "./dist/cjs/development/index.js",
"import": "./dist/esm/development/index.js"
},
"require": "./dist/cjs/production/index.js",
"import": "./dist/esm/production/index.js",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {}
}
}

+ 0
- 3
packages/data-sources/file-jsonl/pridepack.json View File

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

+ 0
- 255
packages/data-sources/file-jsonl/src/index.ts View File

@@ -1,255 +0,0 @@
import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { Resource, validation as v, BaseResourceType } from '@modal-sh/yasumi';
import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend';
import assert from 'assert';

export class ResourceNotPreparedError extends Error {}

export class ResourceIdNotDesignatedError extends Error {}

export class JsonLinesDataSource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
Data extends object = v.Output<Schema>,
> implements DataSource<Data> {
private path?: string;

private resource?: Resource<BaseResourceType & {
schema: Schema,
name: CurrentName,
routeName: CurrentRouteName,
}>;

data: Data[] = [];

constructor(private readonly baseDir = '') {
// noop
}

prepareResource<Schema extends v.BaseSchema>(resource: Resource<BaseResourceType & {
schema: Schema,
name: CurrentName,
routeName: CurrentRouteName,
}>) {
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`);
resource.dataSource = resource.dataSource ?? this;
const originalResourceId = resource.id;
resource.id = <NewIdAttr extends BaseResourceType['idAttr'], NewIdSchema extends BaseResourceType['idSchema']>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => {
originalResourceId(newIdAttr, params);
return resource as Resource<BaseResourceType & {
name: CurrentName,
routeName: CurrentRouteName,
schema: Schema,
idAttr: NewIdAttr,
idSchema: NewIdSchema,
}>;
};
this.resource = resource as any;
}

async initialize() {
assert(typeof this.path === 'string', new ResourceNotPreparedError());

try {
const fileContents = await readFile(this.path, 'utf-8');
const lines = fileContents.split('\n');
this.data = lines.filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
} catch (err) {
await writeFile(this.path, '');
}
}

async getTotalCount() {
return this.data.length;
}

async getMultiple() {
return [...this.data];
}

async newId() {
const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig<any>;
assert(typeof idConfig !== 'undefined', new ResourceNotPreparedError());
const theNewId = await idConfig.generationStrategy(this);
return theNewId as string;
}

async getById(idSerialized: string) {
assert(typeof this.resource !== 'undefined', new ResourceNotPreparedError());
assert(typeof this.path === 'string', new ResourceNotPreparedError());

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError());

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError());

const id = idConfig.deserialize(idSerialized);
const foundData = this.data.find((s) => (s as any)[idAttr] === id);

if (foundData) {
return {
...foundData
};
}

return null;
}

async create(data: Data) {
assert(typeof this.resource !== 'undefined', new ResourceNotPreparedError());
assert(typeof this.path === 'string', new ResourceNotPreparedError());

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError());

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError());

let theId: any;
const { [idAttr]: dataId, ...etcData } = data as Record<string, unknown>;
if (typeof dataId !== 'undefined') {
theId = idConfig.deserialize((data as Record<string, string>)[idAttr]);
} else {
const newId = await this.newId();
theId = idConfig.deserialize(newId);
}
const newData = {
[idAttr]: theId,
...etcData
} as Record<string, unknown>;

const now = Date.now(); // TODO how to serialize dates
const createdAt = this.resource.state.shared.get('createdAtAttr');
if (typeof createdAt === 'string') {
newData[createdAt] = now;
}

const updatedAt = this.resource.state.shared.get('updatedAtAttr');
if (typeof updatedAt === 'string') {
newData[updatedAt] = now;
}

const newCollection = [
...this.data,
newData
];

await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n'));

return newData as Data;
}

async delete(idSerialized: string) {
assert(typeof this.resource !== 'undefined', new ResourceNotPreparedError());
assert(typeof this.path === 'string', new ResourceNotPreparedError());

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError());

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError());

const oldDataLength = this.data.length;

const id = idConfig.deserialize(idSerialized);
const newData = this.data.filter((s) => !((s as any)[idAttr] === id));

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));

return oldDataLength !== newData.length;
}

async emplace(idSerialized: string, dataWithId: Data) {
assert(typeof this.resource !== 'undefined', new ResourceNotPreparedError());
assert(typeof this.path === 'string', new ResourceNotPreparedError());

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError());

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError());

const existing = await this.getById(idSerialized);
const id = idConfig.deserialize(idSerialized);
const { [idAttr]: idFromResource, ...data } = (dataWithId as any);
const dataToEmplace = {
[idAttr]: id,
...data,
} as Record<string, unknown>;

if (existing) {
const createdAt = this.resource.state.shared.get('createdAtAttr');
if (typeof createdAt === 'string') {
dataToEmplace[createdAt] = (existing as Record<string, unknown>)[createdAt];
}

const now = Date.now(); // TODO how to serialize dates
const updatedAt = this.resource.state.shared.get('updatedAtAttr');
if (typeof updatedAt === 'string') {
dataToEmplace[updatedAt] = now;
}

const newData = this.data.map((d) => {
if ((d as any)[idAttr] === id) {
return dataToEmplace;
}

return d;
});

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));

return [dataToEmplace, false] as [Data, boolean];
}

const newData = await this.create(dataToEmplace as Data);
return [newData, true] as [Data, boolean];
}

async patch(idSerialized: string, data: Partial<Data>) {
assert(typeof this.resource !== 'undefined', new ResourceNotPreparedError());
assert(typeof this.path === 'string', new ResourceNotPreparedError());

const idAttr = this.resource.state.shared.get('idAttr');
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError());

const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError());

const existing = await this.getById(idSerialized);
if (!existing) {
return null;
}

const newItem = {
...existing,
...data,
} as Record<string, unknown>;

const createdAt = this.resource.state.shared.get('createdAtAttr');
if (typeof createdAt === 'string') {
newItem[createdAt] = (existing as Record<string, unknown>)[createdAt];
}

const now = Date.now(); // TODO how to serialize dates
const updatedAt = this.resource.state.shared.get('updatedAtAttr');
if (typeof updatedAt === 'string') {
newItem[updatedAt] = now;
}

const id = idConfig.deserialize(idSerialized);
const newData = this.data.map((d) => {
if ((d as any)[idAttr] === id) {
return newItem;
}

return d;
});

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));
return newItem as Data;
}
}

+ 0
- 236
packages/data-sources/file-jsonl/test/index.test.ts View File

@@ -1,236 +0,0 @@
import {describe, it, expect, vi, Mock, beforeAll, beforeEach} from 'vitest';
import { readFile, writeFile } from 'fs/promises';
import { JsonLinesDataSource } from '../src';
import { resource, validation as v, BaseResourceType } from '@modal-sh/yasumi';
import {DataSource} from '@modal-sh/yasumi/dist/types/backend';

vi.mock('fs/promises');

const toJsonl = (dummyItems: unknown[]) => dummyItems.map((i) => JSON.stringify(i)).join('\n');

const ID_ATTR = 'id' as const;

describe('prepareResource', () => {
beforeAll(() => {
const mockWriteFile = writeFile as Mock;
mockWriteFile.mockImplementation(() => { /* noop */ })
});

it('works', () => {
const schema = v.object({});
const r = resource(schema);
const ds = new JsonLinesDataSource<typeof schema>();
expect(() => ds.prepareResource(r)).not.toThrow();
});
});

describe('methods', () => {
const dummyItems = [
{
id: 1,
name: 'foo',
},
{
id: 2,
name: 'bar',
},
{
id: 3,
name: 'baz',
},
];
const schema = v.object({
name: v.string(),
});
let ds: DataSource<v.Output<typeof schema>>;
let mockGenerationStrategy: Mock;
beforeEach(() => {
mockGenerationStrategy = vi.fn();
const r = resource(schema)
.id(ID_ATTR, {
generationStrategy: mockGenerationStrategy,
schema: v.any(),
serialize: (id) => id.toString(),
deserialize: (id) => Number(id?.toString() ?? 0),
});
ds = new JsonLinesDataSource<typeof schema>();
ds.prepareResource(r);
});

beforeEach(() => {
const mockReadFile = readFile as Mock;
mockReadFile.mockReturnValueOnce(toJsonl(dummyItems));
});

let mockWriteFile: Mock;
beforeEach(() => {
mockWriteFile = writeFile as Mock;
mockWriteFile.mockImplementationOnce(() => { /* noop */ });
});

describe('initialize', () => {
it('works', async () => {
try {
await ds.initialize();
} catch {
expect.fail('Could not initialize data source.');
}
});
});

describe('operations', () => {
beforeEach(async () => {
await ds.initialize();
});

describe('getTotalCount', () => {
it('works', async () => {
if (typeof ds.getTotalCount !== 'function') {
return;
}
const totalCount = await ds.getTotalCount();
expect(totalCount).toBe(dummyItems.length);
});
});

describe('getMultiple', () => {
it('works', async () => {
const items = await ds.getMultiple();
expect(items).toEqual(dummyItems);
});
});

describe('getById', () => {
it('works', async () => {
const id = 2;
const item = await ds.getById(id.toString()); // ID is always a string because it originates from URLs
const expected = dummyItems.find((i) => i[ID_ATTR] === id);
expect(item).toEqual(expected);
});
});

describe('getSingle', () => {
it('works', async () => {
if (typeof ds.getSingle !== 'function') {
// skip if data source doesn't offer this
return;
}
const item = await ds.getSingle();
const expected = dummyItems[0];
expect(item).toEqual(expected);
});
});

describe('create', () => {
it('works', async () => {
const data = {
// notice we don't have IDs here, as it is expected to be generated by newId()
name: 'foo'
};
const newItem = await ds.create(data);

expect(mockWriteFile).toBeCalledWith(
expect.any(String),
toJsonl([
...dummyItems,
{
id: 0,
...data,
}
])
);
expect(newItem).toEqual({ id: 0, ...data});
});
});

describe('delete', () => {
it('works', async () => {
await ds.delete('1');
expect(mockWriteFile).toBeCalledWith(
expect.any(String),
toJsonl(dummyItems.filter((d) => d[ID_ATTR] !== 1)),
);
});
});

describe('emplace', () => {
it('replaces existing data', async () => {
const data = {
[ID_ATTR]: 2,
name: 'foo',
};
const { id, ...etcData } = data;
const newItem = await ds.emplace(
id.toString(),
etcData,
);

expect(mockWriteFile).toBeCalledWith(
expect.any(String),
toJsonl(dummyItems.map((d) =>
d[ID_ATTR] === data[ID_ATTR]
// ID will be defined first, since we are just writing to file, we need strict ordering
? { [ID_ATTR]: id, ...etcData }
: d
)),
);
expect(newItem).toEqual([data, false]);
});

it('creates new data', async () => {
const data = {
[ID_ATTR]: 4,
name: 'quux',
};
const { [ID_ATTR]: id, ...etcData } = data;
const newItem = await ds.emplace(
id.toString(),
etcData,
);

expect(mockWriteFile).toBeCalledWith(
expect.any(String),
toJsonl([
...dummyItems,
data
]),
);
expect(newItem).toEqual([data, true]);
});
});

describe('patch', () => {
it('works', async () => {
const data = {
[ID_ATTR]: 2,
name: 'foo',
};
const { id, ...etcData } = data;
const newItem = await ds.emplace(
id.toString(),
etcData,
);

expect(mockWriteFile).toBeCalledWith(
expect.any(String),
toJsonl(dummyItems.map((d) =>
d[ID_ATTR] === data[ID_ATTR]
// ID will be defined first, since we are just writing to file, we need strict ordering
? { [ID_ATTR]: id, ...etcData }
: d
)),
);
expect(newItem).toBeDefined();
});
});

describe('newId', () => {
it('works', async () => {
const v = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
mockGenerationStrategy.mockResolvedValueOnce(v);
const id = await ds.newId();
expect(id).toBe(v);
});
});
});
});

+ 0
- 23
packages/data-sources/file-jsonl/tsconfig.json View File

@@ -1,23 +0,0 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true
}
}

+ 0
- 108
packages/examples/cms-web-api/.gitignore View File

@@ -1,108 +0,0 @@
# 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
*.jsonl

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

+ 0
- 3
packages/examples/cms-web-api/pridepack.json View File

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

+ 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
- 23
packages/examples/cms-web-api/tsconfig.json View File

@@ -1,23 +0,0 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true
}
}

+ 0
- 109
packages/examples/duckdb/.gitignore View File

@@ -1,109 +0,0 @@
# 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
*.db
*.db.wal

+ 0
- 51
packages/examples/duckdb/package.json View File

@@ -1,51 +0,0 @@
{
"name": "@modal-sh/yasumi-example-duckdb",
"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-duckdb": "workspace:*",
"tsx": "^4.7.1"
},
"scripts": {
"prepublishOnly": "pridepack clean && pridepack build",
"build": "pridepack build",
"type-check": "pridepack check",
"clean": "pridepack clean",
"watch": "pridepack watch",
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"test": "vitest"
},
"private": true,
"description": "DuckDB-powered example service.",
"repository": {
"url": "",
"type": "git"
},
"homepage": "",
"bugs": {
"url": ""
},
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": {
"access": "restricted"
}
}

+ 0
- 3
packages/examples/duckdb/pridepack.json View File

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

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

+ 0
- 23
packages/examples/duckdb/tsconfig.json View File

@@ -1,23 +0,0 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true
}
}

+ 0
- 107
packages/servers/http/.gitignore View File

@@ -1,107 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.production
.env.development

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

.npmrc

+ 0
- 7
packages/servers/http/LICENSE View File

@@ -1,7 +0,0 @@
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.

+ 0
- 68
packages/servers/http/package.json View File

@@ -1,68 +0,0 @@
{
"name": "@modal-sh/yasumi-server-http",
"version": "0.0.0",
"files": [
"dist",
"src"
],
"engines": {
"node": ">=16"
},
"license": "MIT",
"keywords": [
"pridepack"
],
"devDependencies": {
"@types/negotiator": "^0.6.3",
"@types/node": "^20.11.0",
"pridepack": "2.6.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
},
"dependencies": {
"@modal-sh/yasumi": "workspace:*",
"negotiator": "^0.6.3"
},
"scripts": {
"prepublishOnly": "pridepack clean && pridepack build",
"build": "pridepack build",
"type-check": "pridepack check",
"clean": "pridepack clean",
"watch": "pridepack watch",
"start": "pridepack start",
"dev": "pridepack dev",
"test": "vitest"
},
"private": false,
"description": "HTTP server for Yasumi backend.",
"repository": {
"url": "",
"type": "git"
},
"homepage": "",
"bugs": {
"url": ""
},
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": {
"access": "public"
},
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/production/index.js",
"module": "./dist/esm/production/index.js",
"exports": {
".": {
"development": {
"require": "./dist/cjs/development/index.js",
"import": "./dist/esm/development/index.js"
},
"require": "./dist/cjs/production/index.js",
"import": "./dist/esm/production/index.js",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {}
}
}

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

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

+ 0
- 829
packages/servers/http/src/core.ts View File

@@ -1,829 +0,0 @@
import http, { createServer as httpCreateServer } from 'http';
import { createServer as httpCreateSecureServer } from 'https';
import {constants,} from 'http2';
import EventEmitter from 'events';
import {
AllowedMiddlewareSpecification,
Backend,
BackendState,
Middleware,
RequestContext,
RequestDecorator,
Response,
Server,
DataSource,
} from '@modal-sh/yasumi/backend';
import {
BaseResourceType,
CanPatchSpec,
DELTA_SCHEMA,
getAcceptPatchString,
getAcceptPostString,
LanguageDefaultErrorStatusMessageKey,
PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES,
PatchContentType, queryMediaTypes,
Resource,
validation as v,
isTextMediaType,
} from '@modal-sh/yasumi';
import {
handleGetRoot, handleOptions,
} from './handlers/default';
import {
handleCreateItem,
handleDeleteItem,
handleEmplaceItem,
handleGetCollection,
handleGetItem,
handlePatchItem,
handleQueryCollection,
} from './handlers/resource';
import {getBody} from './utils';
import {decorateRequestWithBackend} from './decorators/backend';
import {decorateRequestWithMethod} from './decorators/method';
import {decorateRequestWithUrl} from './decorators/url';
import {ErrorPlainResponse, PlainResponse} from './response';

type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource'];

interface ResourceWithDataSource extends Omit<RequiredResource, 'dataSource'> {
dataSource: DataSource;
}

interface ResourceRequestContext extends Omit<RequestContext, 'resource'> {
resource: ResourceWithDataSource;
}

export interface HttpServer extends Server {
readonly listening: boolean;
on(event: string, cb: (...args: unknown[]) => unknown): this;
close(callback?: (err?: Error) => void): this;
listen(...args: Parameters<http.Server['listen']>): this;
defaultErrorHandler(errorHandler: ErrorHandler): this;
}

declare module '@modal-sh/yasumi/backend' {
interface RequestContext extends http.IncomingMessage {
body?: unknown;
}

interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext, Res extends NodeJS.EventEmitter = NodeJS.EventEmitter> {
(req: Req, res: Res): undefined | Response | Promise<undefined | Response>;
}

interface Backend {
createServer<T extends Server = HttpServer>(type: 'http', options?: CreateServerParams): T;
}
}

const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => {
return resource.schema;
};

const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>, mainResourceId?: string) => {
if (typeof mainResourceId === 'undefined') {
return resource.schema;
}

const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema;
const idAttr = resource.state.shared.get('idAttr') as string;
const idConfig = resource.state.shared.get('idConfig') as any;
return (
schema.type === 'object'
? v.merge([
schema as v.ObjectSchema<any>,
v.object({
[idAttr]: v.transform(
v.any(),
input => idConfig!.serialize(input),
v.literal(mainResourceId)
)
})
])
: schema
);
};

const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => {
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema;

if (resource.schema.type !== 'object') {
return resource.schema;
}

const schemaChoices = {
merge: v.partial(
schema as v.ObjectSchema<any>,
(schema as v.ObjectSchema<any>).rest,
(schema as v.ObjectSchema<any>).pipe
),
delta: v.array(DELTA_SCHEMA),
}

const selectedSchemaChoices = Object.entries(schemaChoices)
.filter(([key]) => resource.state.canPatch[key as CanPatchSpec])
.map(([, value]) => value);

return v.union(selectedSchemaChoices);
};
// TODO add a way to define custom middlewares
const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [
{
method: 'QUERY',
middleware: handleQueryCollection,
allowed: (resource) => resource.state.canFetchCollection,
},
{
method: 'GET',
middleware: handleGetCollection,
allowed: (resource) => resource.state.canFetchCollection,
},
{
method: 'POST',
middleware: handleCreateItem,
allowed: (resource) => resource.state.canCreate,
constructBodySchema: constructPostSchema,
},
];

const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [
{
method: 'GET',
middleware: handleGetItem,
allowed: (resource) => resource.state.canFetchItem,
},
{
method: 'PUT',
middleware: handleEmplaceItem,
constructBodySchema: constructPutSchema,
allowed: (resource) => resource.state.canEmplace,
},
{
method: 'PATCH',
middleware: handlePatchItem,
constructBodySchema: constructPatchSchema,
allowed: (resource) => resource.state.canPatch.merge || resource.state.canPatch.delta,
},
{
method: 'DELETE',
middleware: handleDeleteItem,
allowed: (resource) => resource.state.canDelete,
},
];

export interface CreateServerParams {
basePath?: string;
host?: string;
cert?: string;
key?: string;
requestTimeout?: number;
// CQRS
streamResponses?: boolean;
}

class CqrsEventEmitter extends EventEmitter {

}

export type ErrorHandler = (req: RequestContext, res: http.ServerResponse<RequestContext>) => <E extends Error = Error>(err?: E) => never;

interface ServerState {
requestDecorators: Set<RequestDecorator>;
defaultErrorHandler?: ErrorHandler;
}

export const httpExtender = (backendState: BackendState, backend: Backend) => {
const originalCreateServer = backend.createServer;
backend.createServer = (type: 'http', serverParamsRaw = {}) => {
const theServerRaw = originalCreateServer(type, serverParamsRaw);
if (type !== 'http') {
return theServerRaw;
}

const serverParams = serverParamsRaw as CreateServerParams;
const state: ServerState = {
requestDecorators: new Set<RequestDecorator>(),
defaultErrorHandler: undefined,
};

const theServer = {
...theServerRaw,
get listening() { return server.listening },
listen(...args: Parameters<HttpServer['listen']>) {
server.listen(...args);
return this;
},
close(callback?: (err?: Error) => void) {
server.close(callback);
return this;
},
on(...args: Parameters<HttpServer['on']>) {
server.on(args[0], args[1]);
return this;
},
requestDecorator(requestDecorator: RequestDecorator) {
state.requestDecorators.add(requestDecorator);
return this;
},
defaultErrorHandler(errorHandler: ErrorHandler) {
state.defaultErrorHandler = errorHandler;
return this;
}
} as HttpServer;

const isHttps = 'key' in serverParams && 'cert' in serverParams;
const theRes = new CqrsEventEmitter();

http.METHODS.push('QUERY');
const server = isHttps
? httpCreateSecureServer({
key: serverParams.key,
cert: serverParams.cert,
requestTimeout: serverParams.requestTimeout,
// TODO add custom methods
})
: httpCreateServer({
requestTimeout: serverParams.requestTimeout,
});

const handleMiddlewares = async (currentHandlerState: Awaited<ReturnType<Middleware>>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => {
const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware;
const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method;

if (effectiveMethod !== middlewareMethod) {
return currentHandlerState;
}

if (typeof currentHandlerState !== 'undefined') {
return currentHandlerState;
}

if (effectiveMethod === 'QUERY') {
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream';
const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';');
const mediaType = fragments[0];
const charsetParam = (
fragments
.map((s) => s.trim())
.find((f) => f.startsWith('charset='))

?? (
isTextMediaType(mediaType)
? 'charset=utf-8'
: 'charset=binary'
)
);
const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim());
const charset = (
(
(charsetRaw.startsWith('"') && charsetRaw.endsWith('"'))
|| (charsetRaw.startsWith("'") && charsetRaw.endsWith("'"))
)
? charsetRaw.slice(1, -1).trim()
: charsetRaw.trim()
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary');

const theBodyBuffer = await getBody(req);
const encodingPair = req.backend.app.charsets.get(charset);
if (typeof encodingPair === 'undefined') {
throw new ErrorPlainResponse('unableToDecodeResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
});
}

const deserializerPair = Object.values(queryMediaTypes)
.find((a) => a.name === mediaType);
if (typeof deserializerPair === 'undefined') {
throw new ErrorPlainResponse(
'unableToDeserializeRequest',
{
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
},
);
}

const theBodyStr = encodingPair.decode(theBodyBuffer);
req.body = deserializerPair.deserialize(theBodyStr);
} else if (typeof constructBodySchema === 'function') {
const bodySchema = constructBodySchema(req.resource, req.resourceId);
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream';
const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';');
const mediaType = fragments[0];
const charsetParam = (
fragments
.map((s) => s.trim())
.find((f) => f.startsWith('charset='))

?? (
isTextMediaType(mediaType)
? 'charset=utf-8'
: 'charset=binary'
)
);
const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim());
const charset = (
(
(charsetRaw.startsWith('"') && charsetRaw.endsWith('"'))
|| (charsetRaw.startsWith("'") && charsetRaw.endsWith("'"))
)
? charsetRaw.slice(1, -1).trim()
: charsetRaw.trim()
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary');

if (effectiveMethod === 'POST' && PATCH_CONTENT_TYPES.includes(mediaType as PatchContentType)) {
throw new ErrorPlainResponse('invalidResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
headers: {
'Accept-Post': getAcceptPostString(req.backend.app.mediaTypes),
},
});
}

if (effectiveMethod === 'PATCH') {
const isPatchEnabled = req.resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[mediaType as PatchContentType]];
if (!isPatchEnabled) {
throw new ErrorPlainResponse('invalidResourcePatchType', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
headers: {
'Accept-Patch': getAcceptPatchString(req.resource.state.canPatch),
},
});
}
}

const theBodyBuffer = await getBody(req);
const encodingPair = req.backend.app.charsets.get(charset);
if (typeof encodingPair === 'undefined') {
throw new ErrorPlainResponse('unableToDecodeResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
});
}
const deserializerPair = req.backend.app.mediaTypes.get(mediaType);
if (typeof deserializerPair === 'undefined') {
throw new ErrorPlainResponse('unableToDeserializeResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
});
}
const theBodyStr = encodingPair.decode(theBodyBuffer);
const theBody = deserializerPair.deserialize(theBodyStr);
try {
// for validation, I wonder why an empty object is returned for PATCH when both methods are enabled
req.body = await v.parseAsync(bodySchema, theBody, {abortEarly: false, abortPipeEarly: false});
req.body = theBody;
} catch (errRaw) {
const err = errRaw as v.ValiError;
// todo use error message key for each method
// TODO better error reporting, localizable messages
// TODO handle error handlers' errors
if (Array.isArray(err.issues)) {
if (req.method === 'PATCH' && req.headers['content-type']?.startsWith('application/json-patch+json')) {
throw new ErrorPlainResponse('invalidResourcePatch', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
body: err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
res: theRes,
});
}

throw new ErrorPlainResponse('invalidResource', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
body: err.issues.map((i) => (
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
res: theRes,
});
}
}
}

const result = await middleware(req, theRes);

// HEAD is just GET without the response body
if (req.method === 'HEAD' && result instanceof PlainResponse) {
const { body: _, ...etcResult } = result;

return new PlainResponse({
...etcResult,
res: theRes,
});
}

return result;
};

const processRequest = (middlewares: AllowedMiddlewareSpecification[]) => async (req: ResourceRequestContext) => {
const { resource } = req;
if (typeof resource === 'undefined') {
throw new ErrorPlainResponse('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res: theRes,
});
}

if (req.method === 'OPTIONS') {
return handleOptions(middlewares)(req, theRes);
}

if (typeof resource.dataSource === 'undefined') {
throw new ErrorPlainResponse('unableToBindResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res: theRes,
});
}

try {
await resource.dataSource.initialize();
} catch (cause) {
throw new ErrorPlainResponse(
'unableToInitializeResourceDataSource',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res: theRes,
}
);
}

const middlewareResponse = await middlewares.reduce<ReturnType<Middleware>>(
async (currentHandlerStatePromise, currentMiddleware) => {
const currentHandlerState = await currentHandlerStatePromise;
return await handleMiddlewares(currentHandlerState, currentMiddleware, req);
},
Promise.resolve<ReturnType<Middleware>>(undefined)
) as Awaited<ReturnType<Middleware>>;

if (typeof middlewareResponse === 'undefined') {
throw new ErrorPlainResponse('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res: theRes,
});
}

return middlewareResponse as Awaited<ReturnType<Middleware>>
};

const defaultRequestDecorators = [
decorateRequestWithMethod,
decorateRequestWithUrl(serverParams),
decorateRequestWithBackend(backendState),
];

const decorateRequest = async (reqRaw: http.IncomingMessage) => {
const effectiveRequestDecorators = [
...defaultRequestDecorators,
...Array.from(state.requestDecorators),
];

return await effectiveRequestDecorators.reduce(
async (resultRequestPromise, decorator) => {
const resultRequest = await resultRequestPromise;
const decoratedRequest = await decorator(resultRequest);
// TODO log decorators
return decoratedRequest;
},
Promise.resolve(reqRaw as RequestContext)
);
};

const handleResourceError = (processRequestErrRaw: Error) => (resourceReq: ResourceRequestContext, res: http.ServerResponse<RequestContext>) => {
const finalErr = processRequestErrRaw as ErrorPlainResponse;
const headers = finalErr.headers ?? {};
const language = resourceReq.cn.language ?? resourceReq.backend.cn.language;
const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType;
const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset;
let encoded: Buffer | undefined;
let serialized;

const body = finalErr.body ?? language.bodies[(finalErr.statusMessage ?? 'internalServerError') as LanguageDefaultErrorStatusMessageKey];
try {
serialized = mediaType.serialize(body);
} catch (cause) {
handleError(
new ErrorPlainResponse('unableToSerializeResponse', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
cause,
})
)(resourceReq, res);
return;
}

try {
encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined;
} catch (cause) {
handleError(
new ErrorPlainResponse('unableToEncodeResponse', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
cause,
})
)(resourceReq, res);
return;
}

headers['Content-Type'] = [
mediaType.name,
typeof serialized !== 'undefined' ? `charset=${charset.name}` : '',
]
.filter((s) => s.length > 0)
.join('; ');

res.statusMessage = language.statusMessages[
finalErr.statusMessage ?? 'internalServerError'
]?.replace(/\$RESOURCE/g,
resourceReq.resource.state.itemName);
res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
};

const handleError = (err: Error) => (req: RequestContext, res: http.ServerResponse<RequestContext>) => {
if ('resource' in req && typeof req.resource !== 'undefined') {
handleResourceError(err)(req as ResourceRequestContext, res);
return;
}

const finalErr = err as ErrorPlainResponse;
const headers = finalErr.headers ?? {};
const language = req.backend.cn.language;
const mediaType = req.backend.cn.mediaType;
const charset = req.backend.cn.charset;

let encoded: Buffer | undefined;
let serialized;
const body = finalErr.body ?? language.bodies[(finalErr.statusMessage ?? 'internalServerError') as LanguageDefaultErrorStatusMessageKey];
try {
serialized = mediaType.serialize(body);
} catch (cause) {
// TODO logging
res.statusMessage = language.statusMessages['unableToSerializeResponse'];
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
}

try {
encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined;
} catch (cause) {
// TODO logging
res.statusMessage = language.statusMessages['unableToEncodeResponse'];
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
}

headers['Content-Type'] = [
mediaType.name,
typeof serialized !== 'undefined' ? `charset=${charset.name}` : '',
]
.filter((s) => s.length > 0)
.join('; ');

res.statusMessage = typeof finalErr.statusMessage !== 'undefined' ? language.statusMessages[finalErr.statusMessage] : '';
res.writeHead(
finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers,
)
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
};

const handleResourceResponse = (resourceReq: ResourceRequestContext, res: http.ServerResponse<RequestContext>) => (middlewareState: Response) => {
const language = resourceReq.cn.language ?? resourceReq.backend.cn.language;
const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType;
const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset;

const headers: Record<string, string> = {
...(
middlewareState.headers ?? {}
),
'Content-Language': language.name,
};
if (middlewareState instanceof http.ServerResponse) {
// TODO streaming responses
middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers);
return;
}
if (middlewareState instanceof PlainResponse) {
let encoded: Buffer | undefined;
if (typeof middlewareState.body !== 'undefined') {
let serialized;
try {
serialized = mediaType.serialize(middlewareState.body);
} catch (cause) {
const headers: Record<string, string> = {
'Content-Language': language.name,
};
if (resourceReq.method === 'POST') {
headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys())
.filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t))
.join(',');
} else if (resourceReq.method === 'PATCH') {
headers['Accept-Patch'] = Array.from(Object.entries(PATCH_CONTENT_MAP_TYPE))
.filter(([, value]) => Object.keys(resourceReq.resource.state.canPatch).includes(value))
.map(([contentType]) => contentType)
.join(',');
}

handleError(new ErrorPlainResponse('unableToSerializeResponse', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers,
res,
}))(resourceReq, res);
return;
}

try {
encoded = charset.encode(serialized);
} catch (cause) {
handleError(new ErrorPlainResponse('unableToEncodeResponse', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers,
res,
}))(resourceReq, res);
return;
}

headers['Content-Type'] = [
mediaType.name,
`charset=${charset.name}`,
].join('; ');
}

const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined;
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource.state.itemName) ?? '';
res.writeHead(middlewareState.statusCode, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
return;
}

handleError(new ErrorPlainResponse('urlNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res,
}))(resourceReq, res);
};

const handleResponse = (resourceReq: RequestContext, res: http.ServerResponse<RequestContext>) => (middlewareState: Response) => {
if ('resource' in resourceReq && typeof resourceReq.resource !== 'undefined') {
handleResourceResponse(resourceReq as ResourceRequestContext, res)(middlewareState);
return;
}

const language = resourceReq.cn.language ?? resourceReq.backend.cn.language;
const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType;
const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset;

const headers: Record<string, string> = {
...(
middlewareState.headers ?? {}
),
'Content-Language': language.name,
};
if (middlewareState instanceof http.ServerResponse) {
// TODO streaming responses
middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers);
return;
}
if (middlewareState instanceof PlainResponse) {
let encoded: Buffer | undefined;
if (typeof middlewareState.body !== 'undefined') {
let serialized;
try {
serialized = mediaType.serialize(middlewareState.body);
} catch (cause) {
const headers: Record<string, string> = {
'Content-Language': language.name,
};
if (resourceReq.method === 'POST') {
headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys())
.filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t))
.join(',');
}

handleError(new ErrorPlainResponse('unableToSerializeResponse', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers,
res,
}))(resourceReq, res);
return;
}

try {
encoded = charset.encode(serialized);
} catch (cause) {
handleError(new ErrorPlainResponse('unableToEncodeResponse', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
headers,
res,
}))(resourceReq, res);
return;
}

headers['Content-Type'] = [
mediaType.name,
`charset=${charset.name}`,
].join('; ');
}

const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined;
res.statusMessage = statusMessageKey ?? '';
res.writeHead(middlewareState.statusCode, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;
}
res.end();
return;
}

handleError(new ErrorPlainResponse('urlNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res,
}))(resourceReq, res);
};

const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => {
const plainReq = await decorateRequest(reqRaw); // TODO add type safety here

if (plainReq.url === '/' || plainReq.url === '') {
const response = await handleGetRoot(plainReq as ResourceRequestContext, theRes);
if (typeof response === 'undefined') {
handleError(
new ErrorPlainResponse('internalServerError', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
})
)(reqRaw, res);
return;
}
handleResponse(plainReq as ResourceRequestContext, res)(response);
return;
}

if (typeof plainReq.resource !== 'undefined') {
const resourceReq = plainReq as ResourceRequestContext;
// TODO custom middlewares
const effectiveMiddlewares = (
typeof resourceReq.resourceId === 'string'
? defaultItemMiddlewares
: defaultCollectionMiddlewares
);
const middlewares = effectiveMiddlewares.filter((m) => m.allowed(resourceReq.resource));
// TODO listen to res.on('response')
const processRequestFn = processRequest(middlewares);
let middlewareState: Response;
try {
middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this
} catch (processRequestErrRaw) {
// TODO add error handlers
handleError(processRequestErrRaw as Error)(resourceReq, res);
return;
}

handleResponse(resourceReq, res)(middlewareState);
return;
}

try {
state.defaultErrorHandler?.(reqRaw, res)();
} catch (err) {
handleError(err as Error)(reqRaw, res);
return;
}

handleError(
new ErrorPlainResponse('internalServerError', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
})
)(reqRaw, res);
};

server.on('request', handleRequest);

return theServer;
};

return backend;
};

+ 0
- 29
packages/servers/http/src/decorators/backend/content-negotiation.ts View File

@@ -1,29 +0,0 @@
import {ContentNegotiation} from '@modal-sh/yasumi';
import {RequestDecorator} from '@modal-sh/yasumi/backend';
import Negotiator from 'negotiator';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
cn: Partial<ContentNegotiation>;
}
}

export const decorateRequestWithContentNegotiation: RequestDecorator = (req) => {
const negotiator = new Negotiator(req);

const availableLanguages = Array.from(req.backend.app.languages.values() ?? []);
const availableCharsets = Array.from(req.backend.app.charsets.values() ?? []);
const availableMediaTypes = Array.from(req.backend.app.mediaTypes.values() ?? []);

const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? req.backend.cn.language.name;
const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? req.backend.cn.charset.name;
const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? req.backend.cn.mediaType.name;

req.cn = {
language: req.backend.app.languages.get(languageCandidate),
mediaType: req.backend.app.mediaTypes.get(mediaTypeCandidate),
charset: req.backend.app.charsets.get(charsetCandidate),
};

return req;
};

+ 0
- 18
packages/servers/http/src/decorators/backend/index.ts View File

@@ -1,18 +0,0 @@
import {BackendState, ParamRequestDecorator} from '@modal-sh/yasumi/backend';
import {decorateRequestWithContentNegotiation} from './content-negotiation';
import {decorateRequestWithResource} from './resource';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
backend: BackendState;
}
}

export const decorateRequestWithBackend: ParamRequestDecorator<[BackendState]> = (backend) => (req) => {
req.backend = backend;

decorateRequestWithContentNegotiation(req);
decorateRequestWithResource(req);

return req;
};

+ 0
- 25
packages/servers/http/src/decorators/backend/resource.ts View File

@@ -1,25 +0,0 @@
import {Resource} from '@modal-sh/yasumi';
import {RequestDecorator} from '@modal-sh/yasumi/backend';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
resource?: Resource;
resourceId?: string;
}
}

export const decorateRequestWithResource: RequestDecorator = (req) => {
const [, resourceRouteName, resourceId] = req.url?.split('/') ?? [];
const resource = Array.from(req.backend.app.resources)
.find((r) => r.state.routeName === resourceRouteName) as Resource | undefined;

if (typeof resource !== 'undefined') {
req.backend.dataSource.prepareResource(resource);
req.resource = resource;
if (resourceId?.trim().length > 0) {
req.resourceId = resourceId;
}
}

return req;
};

+ 0
- 24
packages/servers/http/src/decorators/method/index.ts View File

@@ -1,24 +0,0 @@
import {RequestDecorator} from '@modal-sh/yasumi/backend';

const METHOD_SPOOF_HEADER_NAME = 'x-original-method' as const;
const METHOD_SPOOF_ORIGINAL_METHOD = 'POST' as const;
const WHITELISTED_METHODS = [
'QUERY'
] as const;

export const decorateRequestWithMethod: RequestDecorator = (req) => {
req.method = req.method?.trim().toUpperCase() ?? '';
if (req.method === METHOD_SPOOF_ORIGINAL_METHOD) {
const spoofedMethod = req.headers[METHOD_SPOOF_HEADER_NAME];
if (Array.isArray(spoofedMethod)) {
return req;
}
const whitelistedMethod = WHITELISTED_METHODS.find((s) => s === spoofedMethod);

if (typeof whitelistedMethod === 'string') {
req.method = whitelistedMethod;
}
}

return req;
};

+ 0
- 13
packages/servers/http/src/decorators/url/base-path.ts View File

@@ -1,13 +0,0 @@
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
basePath: string;
}
}

export const decorateRequestWithBasePath: ParamRequestDecorator<[string]> = (basePath) => (req) => {
req.basePath = basePath;

return req;
}

+ 0
- 13
packages/servers/http/src/decorators/url/host.ts View File

@@ -1,13 +0,0 @@
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
host: string;
}
}

export const decorateRequestWithHost: ParamRequestDecorator<[string]> = (host) => (req) => {
req.host = host;

return req;
};

+ 0
- 26
packages/servers/http/src/decorators/url/index.ts View File

@@ -1,26 +0,0 @@
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';
import {CreateServerParams} from '../../core';
import {decorateRequestWithScheme} from './scheme';
import {decorateRequestWithHost} from './host';
import {decorateRequestWithBasePath} from './base-path';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
rawUrl?: string;
query: URLSearchParams;
}
}

export const decorateRequestWithUrl: ParamRequestDecorator<[CreateServerParams]> = (serverParams) => (req) => {
const isHttps = 'key' in serverParams && 'cert' in serverParams;
decorateRequestWithScheme(isHttps ? 'https' : 'http')(req);
decorateRequestWithHost(serverParams.host ?? '127.0.0.1')(req);
decorateRequestWithBasePath(serverParams.basePath ?? '')(req);
const basePath = new URL(req.basePath, `${req.scheme}://${req.host}`);
const parsedUrl = new URL(`${basePath.pathname}/${req.url ?? ''}`, basePath.origin);
req.rawUrl = req.url;
req.url = req.url?.slice(basePath.pathname.length) ?? '';
req.query = parsedUrl.searchParams;

return req;
};

+ 0
- 13
packages/servers/http/src/decorators/url/scheme.ts View File

@@ -1,13 +0,0 @@
import {ParamRequestDecorator} from '@modal-sh/yasumi/backend';

declare module '@modal-sh/yasumi/backend' {
interface RequestContext {
scheme: string;
}
}

export const decorateRequestWithScheme: ParamRequestDecorator<[string]> = (scheme) => (req) => {
req.scheme = scheme;

return req;
};

+ 0
- 78
packages/servers/http/src/handlers/default.ts View File

@@ -1,78 +0,0 @@
import {constants} from 'http2';
import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '@modal-sh/yasumi/backend';
import {LinkMap} from '../utils';
import {PlainResponse, ErrorPlainResponse} from '../response';
import {getAcceptPatchString, getAcceptPostString} from '@modal-sh/yasumi';

export const handleGetRoot: Middleware = (req, res) => {
const { backend, basePath } = req;

const data = {
name: backend.app.name
};

const registeredResources = Array.from(backend.app.resources);
const availableResources = registeredResources.filter((r) => (
r.state.canFetchCollection
|| r.state.canCreate
));

const headers: Record<string, string> = {};
if (availableResources.length > 0) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
headers['Link'] = new LinkMap(
availableResources.map((r) => ({
url: `${basePath}/${r.state.routeName}`,
params: {
rel: 'related',
name: r.state.routeName,
},
}))
)
.toString();
}

return new PlainResponse({
headers,
statusMessage: 'ok',
statusCode: constants.HTTP_STATUS_OK,
body: data,
res,
});
};

export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => (req, res) => {
if (middlewares.length > 0) {
const allowString = getAllowString(middlewares);
const headers: Record<string, string> = {
'Allow': getAllowString(middlewares),
};

const allowedMethods = allowString.split(',');
if (allowedMethods.includes('POST')) {
headers['Accept-Post'] = getAcceptPostString(req.backend.app.mediaTypes);
}

if (allowedMethods.includes('PATCH')) {
const validPatchTypes = Object.entries(req.resource.state.canPatch)
.filter(([, allowed]) => allowed);

if (validPatchTypes.length > 0) {
headers['Accept-Patch'] = getAcceptPatchString(req.resource.state.canPatch);
}
}

return new PlainResponse({
headers,
statusMessage: 'provideOptions',
statusCode: constants.HTTP_STATUS_NO_CONTENT,
res,
});
}

// TODO add option for custom error handler
throw new ErrorPlainResponse('methodNotAllowed', {
statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED,
res,
});
};

+ 0
- 449
packages/servers/http/src/handlers/resource.ts View File

@@ -1,449 +0,0 @@
import { constants } from 'http2';
import assert from 'assert';
import {Middleware} from '@modal-sh/yasumi/backend';
import {
applyDelta, Query,
Delta,
PATCH_CONTENT_MAP_TYPE,
PatchContentType,
queryMediaTypes,
validation as v,
} from '@modal-sh/yasumi';
import {ErrorPlainResponse, PlainResponse} from '../response';

// TODO add handleQueryCollection()

export const handleQueryCollection: Middleware = async (req, res) => {
const {
body,
resource,
backend,
} = req;

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
try {
// check which attributes have specifics on the queries (e.g. fuzzy search on strings)
const dataSourceQuery = body as Query;
data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery);
}
} catch (cause) {
throw new ErrorPlainResponse(
'unableToFetchResourceCollection',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
}
);
}

const headers: Record<string, string> = {};
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}

return new PlainResponse({
headers,
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourceCollectionFetched',
body: data,
res,
});
};

export const handleGetCollection: Middleware = async (req, res) => {
const {
// TODO don't turn query into URLSearchParams just yet
query,
resource,
backend,
} = req;

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
try {
// check which attributes have specifics on the queries (e.g. fuzzy search on strings)
const dataSourceQuery = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize(
query.toString(),
// TODO compute processEntry options based on resource attribute metadata (e.g. fulltext, queryable attributes - firstname, lastname, middlename)
);
data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery);
}
} catch (cause) {
throw new ErrorPlainResponse(
'unableToFetchResourceCollection',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
}
);
}

const headers: Record<string, string> = {};
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}

return new PlainResponse({
headers,
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourceCollectionFetched',
body: data,
res,
});
};

const isResourceIdDefined = (resourceId?: string): resourceId is string => !(
typeof resourceId === 'undefined' || resourceId.trim().length < 1
);

export const handleGetItem: Middleware = async (req, res) => {
const { resource, resourceId } = req;

assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
}
)
);

let data: v.Output<typeof resource.schema> | null = null;
try {
data = await resource.dataSource.getById(resourceId);
} catch (cause) {
throw new ErrorPlainResponse(
'unableToFetchResource',
{
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
}
);
}

if (!(typeof data !== 'undefined' && data !== null)) {
throw new ErrorPlainResponse(
'resourceNotFound',
{
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res,
},
);
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourceFetched',
body: data,
res,
});
};

export const handleDeleteItem: Middleware = async (req, res) => {
const { resource, resourceId, backend } = req;

assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
}
)
);

let existing: unknown | null;
try {
existing = await resource.dataSource.getById(resourceId);
} catch (cause) {
throw new ErrorPlainResponse('unableToFetchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}

if (!existing && backend!.throwsErrorOnDeletingNotFound) {
throw new ErrorPlainResponse('deleteNonExistingResource', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res
});
}

try {
if (existing) {
// TODO should we still deal with the delete return?
await resource.dataSource.delete(resourceId);
}
} catch (cause) {
throw new ErrorPlainResponse('unableToDeleteResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res
})
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_NO_CONTENT,
statusMessage: 'resourceDeleted',
res,
});
};

export const handlePatchItem: Middleware = async (req, res) => {
const {
resource,
resourceId,
body,
headers,
} = req;

const idAttr = resource.state.shared.get('idAttr');
assert(
isIdAttributeDefined(idAttr),
new ErrorPlainResponse('resourceIdNotGiven', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
})
);

assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
}
)
);

let existing: unknown | null;
try {
existing = await resource.dataSource.getById(resourceId);
} catch (cause) {
throw new ErrorPlainResponse('unableToFetchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}

if (!existing) {
throw new ErrorPlainResponse('patchNonExistingResource', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
res,
});
}

let newObject: v.Output<typeof resource.schema> | null;
const patchType = PATCH_CONTENT_MAP_TYPE[headers['content-type'] as PatchContentType];

switch (patchType) {
case 'merge': {
try {
newObject = await resource.dataSource.patch(resourceId, body as object);
} catch (cause) {
throw new ErrorPlainResponse('unableToPatchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}
break;
}
case 'delta': {
let modifiedObject: Record<string, unknown>;
const { [idAttr]: id, ...theExisting } = existing as Record<string, unknown>;
try {
modifiedObject = await applyDelta(
theExisting as Record<string, unknown>,
body as Delta[],
resource.schema,
);
} catch (cause) {
throw new ErrorPlainResponse('invalidResourcePatch', {
cause,
statusCode: constants.HTTP_STATUS_UNPROCESSABLE_ENTITY,
res,
});
}

try {
newObject = await resource.dataSource.patch(resourceId, {
...modifiedObject,
[idAttr]: id, // TODO should ID belong to the resource?
});
} catch (cause) {
throw new ErrorPlainResponse('unableToPatchResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}
break;
}
default:
throw new ErrorPlainResponse('invalidResourcePatchType', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
});
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_OK,
statusMessage: 'resourcePatched',
body: newObject,
res,
});
};

const isIdAttributeDefined = (idAttr?: unknown): idAttr is string => (
typeof idAttr !== 'undefined'
);

export const handleCreateItem: Middleware = async (req, res) => {
const { resource, body, backend, basePath } = req;

const idAttr = resource.state.shared.get('idAttr');
assert(
isIdAttributeDefined(idAttr),
new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
})
);

let newId;
let params: v.Output<typeof resource.schema>;
try {
newId = await resource.dataSource.newId();
params = { ...body as Record<string, unknown> };
params[idAttr] = newId;
} catch (cause) {
throw new ErrorPlainResponse('unableToAssignIdFromResourceDataSource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}

const location = `${basePath}/${resource.state.routeName}/${newId}`;
res.emit('response', {
Location: location,
});
// already return 202 accepted here

let newObject;
let totalItemCount: number | undefined;

try {
if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
totalItemCount += 1;
}
newObject = await resource.dataSource.create(params);
} catch (cause) {
throw new ErrorPlainResponse('unableToCreateResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}

if (typeof totalItemCount !== 'undefined') {
return new PlainResponse({
statusCode: constants.HTTP_STATUS_CREATED,
headers: {
'Location': location,
'X-Resource-Total-Item-Count': totalItemCount.toString()
},
body: newObject,
statusMessage: 'resourceCreated',
res,
});
}

return new PlainResponse({
statusCode: constants.HTTP_STATUS_CREATED,
body: newObject,
headers: {
'Location': location,
},
statusMessage: 'resourceCreated',
res,
});
}

export const handleEmplaceItem: Middleware = async (req, res) => {
const { resource, resourceId, basePath, body, backend } = req;

assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
}
)
);

const idAttr = resource.state.shared.get('idAttr');
assert(
isIdAttributeDefined(idAttr),
new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
})
);

let newObject: v.Output<typeof resource.schema>;
let isCreated: boolean;
try {
const params = { ...body as Record<string, unknown> };
params[idAttr] = resourceId;
[newObject, isCreated] = await resource.dataSource.emplace(resourceId, params);
} catch (cause) {
throw new ErrorPlainResponse('unableToEmplaceResource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}

const headers: Record<string, string> = {};
let totalItemCount: number | undefined;
if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
if (isCreated) {
headers['Location'] = `${basePath}/${resource.state.routeName}/${resourceId}`;
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
}

return new PlainResponse({
statusCode: isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK,
headers,
statusMessage: (
isCreated
? 'resourceCreated'
: 'resourceReplaced'
),
body: newObject,
res,
});
}

+ 0
- 2
packages/servers/http/src/index.ts View File

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

+ 0
- 45
packages/servers/http/src/response.ts View File

@@ -1,45 +0,0 @@
import {Language, LanguageStatusMessageMap} from '@modal-sh/yasumi';
import {MiddlewareResponseError, Response} from '@modal-sh/yasumi/backend';

interface PlainResponseParams<T = unknown, U extends NodeJS.EventEmitter = NodeJS.EventEmitter> extends Response {
body?: T;
res: U;
}

interface HttpMiddlewareErrorParams<T = unknown, U extends NodeJS.EventEmitter = NodeJS.EventEmitter> extends Omit<PlainResponseParams<T, U>, 'statusMessage'> {
cause?: unknown
}

export class ErrorPlainResponse<T = unknown, U extends NodeJS.EventEmitter = NodeJS.EventEmitter> extends MiddlewareResponseError implements PlainResponseParams<T, U> {
readonly body?: T;
readonly res: U;

constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams<T, U>) {
super(statusMessage, params);
this.body = params.body;
this.res = params.res;
this.res.emit('response', this);
this.res.emit('close');
}
}

export class PlainResponse<T = unknown> implements Response {
readonly statusCode: Response['statusCode'];

readonly statusMessage?: keyof LanguageStatusMessageMap;

readonly headers: Response['headers'];

readonly body?: T;

constructor(args: PlainResponseParams<T>) {
this.statusCode = args.statusCode;
this.statusMessage = args.statusMessage;
this.headers = args.headers;
this.body = args.body;
args.res.emit('response', this);
args.res.emit('close');
}
}

// TODO stream response

+ 0
- 48
packages/servers/http/src/utils.ts View File

@@ -1,48 +0,0 @@
import {IncomingMessage} from 'http';
import {PATCH_CONTENT_TYPES} from '@modal-sh/yasumi';

export const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')
|| [
'application/json',
'application/xml',
'application/x-www-form-urlencoded',
...PATCH_CONTENT_TYPES,
].includes(mediaType)
);

export const getBody = (
req: 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);
})
});

interface LinkMapEntry {
url: string;
params: Record<string, string>;
}

export class LinkMap extends Set<LinkMapEntry> {
toString() {
const entries = Array.from(this.values());

return entries.map((e) => {
const params = Object.entries(e.params);

return [
`<${e.url}>`,
...params.map(([key, value]) => `${encodeURIComponent(key)}="${encodeURIComponent(value)}"`)
].join(';')
}).join(',');
}
}

+ 0
- 103
packages/servers/http/test/features/decorators.test.ts View File

@@ -1,103 +0,0 @@
import {describe, afterAll, beforeAll, it} from 'vitest';
import {Application, application, resource, Resource, validation as v} from '@modal-sh/yasumi';
import {Backend, DataSource, RequestContext} from '@modal-sh/yasumi/backend';
import {httpExtender, HttpServer} from '../../src';
import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils';

const PORT = 3001;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

describe('decorators', () => {
let Piano: Resource;
let app: Application;
let dataSource: DataSource;
let backend: Backend;
let server: HttpServer;
let client: TestClient;

beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: dummyGenerationStrategy,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});

app = application({
name: 'piano-service',
})
.language(TEST_LANGUAGE)
.resource(Piano);

dataSource = new DummyDataSource();

backend = app
.createBackend({
dataSource,
})
.use(httpExtender);

server = backend.createServer('http' as const, {
basePath: BASE_PATH
});

client = createTestClient({
host: HOST,
port: PORT,
})
.acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE)
.acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET);

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

// TODO add .inject() method
server.listen({
port: PORT
});
});
});

afterAll(() => new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

it('decorates requests', () => {
server.requestDecorator((req) => {
const reqMut = req as unknown as Record<string, unknown>;
reqMut['foo'] = 'bar';
return reqMut as unknown as RequestContext;
});

// TODO how to make assertions here
});
});

+ 0
- 604
packages/servers/http/test/handlers/default.test.ts View File

@@ -1,604 +0,0 @@
import {
beforeAll,
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {constants} from 'http2';
import {Backend, DataSource} from '@modal-sh/yasumi/backend';
import {
application,
resource,
validation as v,
Resource,
Application,
} from '@modal-sh/yasumi';
import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils';
import {httpExtender, HttpServer} from '../../src';

const PORT = 3000;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');

describe('happy path', () => {
let Piano: Resource;
let app: Application;
let dataSource: DataSource;
let backend: Backend;
let server: HttpServer;
let client: TestClient;

beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: dummyGenerationStrategy,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});

app = application({
name: 'piano-service',
})
.language(TEST_LANGUAGE)
.resource(Piano);

dataSource = new DummyDataSource();

backend = app
.createBackend({
dataSource,
})
.use(httpExtender);

server = backend.createServer('http', {
basePath: BASE_PATH
});

client = createTestClient({
host: HOST,
port: PORT,
})
.acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE)
.acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET);

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterAll(() => new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

describe('querying collections', () => {
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockResolvedValueOnce([] as never);
});

beforeEach(() => {
Piano.canFetchCollection();
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('returns data', async () => {
// const [res, resData] = await client({
// method: 'QUERY',
// path: `${BASE_PATH}/pianos`,
// headers: {
// 'content-type': 'application/x-www-form-urlencoded',
// },
// body: 'foo=bar',
// });

const [res, resData] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-original-method': 'QUERY',
},
body: 'foo=bar',
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual([]);
});
});

describe('serving collections', () => {
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockResolvedValueOnce([] as never);
});

beforeEach(() => {
Piano.canFetchCollection();
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual([]);
});

it('returns data on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
});
});

describe('serving items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

beforeEach(() => {
Piano.canFetchItem();
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual(existingResource);
});

it('returns data on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
});
});

describe('creating items', () => {
const newResourceData = {
brand: 'K. Kawai'
};

const responseData = {
id: 2,
...newResourceData,
};

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'newId')
.mockResolvedValueOnce(responseData.id as never);
});

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'create')
.mockResolvedValueOnce(responseData as never);
});

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});

it('returns data', async () => {
const [res, resData] = await client({
path: `${BASE_PATH}/pianos`,
method: 'POST',
body: newResourceData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...newResourceData,
id: 2
});
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('POST');
});
});

describe('patching items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

const patchData = {
brand: 'K. Kawai'
};

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'patch')
.mockResolvedValueOnce({
...existingResource,
...patchData,
} as never);
});

beforeEach(() => {
Piano.canPatch();
});

afterEach(() => {
Piano.canPatch(false);
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PATCH');
const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? [];
expect(acceptPatch).toContain('application/json-patch+json');
expect(acceptPatch).toContain('application/merge-patch+json');
});

describe('on merge', () => {
beforeEach(() => {
Piano.canPatch(false).canPatch(['merge']);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: patchData,
headers: {
'content-type': 'application/merge-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...existingResource,
...patchData,
});
});
});

describe('on delta', () => {
beforeEach(() => {
Piano.canPatch(false).canPatch(['delta']);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: [
{
op: 'replace',
path: 'brand',
value: patchData.brand,
},
],
headers: {
'content-type': 'application/json-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...existingResource,
...patchData,
});
});
});
});

describe('emplacing items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

const emplaceResourceData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});

it('returns data for replacement', async () => {
vi
.spyOn(DummyDataSource.prototype, 'emplace')
.mockResolvedValueOnce([{
...existingResource,
...emplaceResourceData,
}, false] as never);

const [res, resData] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
body: emplaceResourceData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceReplaced));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual(emplaceResourceData);
});

it('returns data for creation', async () => {
const newId = 2;

vi
.spyOn(DummyDataSource.prototype, 'emplace')
.mockResolvedValueOnce([{
...existingResource,
...emplaceResourceData,
id: newId
}, true] as never);

const [res, resData] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${newId}`,
body: {
...emplaceResourceData,
id: newId,
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...emplaceResourceData,
id: newId,
});
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PUT');
});
});

describe('deleting items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'delete')
.mockReturnValueOnce(Promise.resolve() as never);
});

beforeEach(() => {
Piano.canDelete();
});

afterEach(() => {
Piano.canDelete(false);
});

it('responds', async () => {
const [res, resData] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceDeleted));
expect(res.headers).not.toHaveProperty('content-type');
expect(resData).toBeUndefined();
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('DELETE');
});
});
});

+ 0
- 534
packages/servers/http/test/handlers/error-handling.test.ts View File

@@ -1,534 +0,0 @@
import {
beforeAll,
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {constants} from 'http2';
import {Backend, DataSource} from '@modal-sh/yasumi/backend';
import {application, resource, validation as v, Resource, Application, Delta} from '@modal-sh/yasumi';
import {
createTestClient,
TestClient,
DummyDataSource,
DummyError,
TEST_LANGUAGE,
dummyGenerationStrategy,
} from '../utils';
import {httpExtender, HttpServer} from '../../src';

const PORT = 3001;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');

describe('error handling', () => {
let Piano: Resource;
let app: Application;
let dataSource: DataSource;
let backend: Backend;
let server: HttpServer;
let client: TestClient;

beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: dummyGenerationStrategy,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});

app = application({
name: 'piano-service',
})
.language(TEST_LANGUAGE)
.resource(Piano);

dataSource = new DummyDataSource();

backend = app
.createBackend({
dataSource,
})
.use(httpExtender);

server = backend.createServer('http', {
basePath: BASE_PATH
});

client = createTestClient({
host: HOST,
port: PORT,
})
.acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE)
.acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET);

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterAll(() => new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

describe('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('throws on query', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection));
});

it('throws on HEAD method', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection));
});
});

describe('serving items', () => {
beforeEach(() => {
Piano.canFetchItem();
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('throws on query', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
});

it('throws on HEAD method', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
});

it('throws on item not found', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});

it('throws on item not found on HEAD method', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});
});

describe('creating items', () => {
const newData = {
brand: 'K. Kawai'
};

const existingResource = {
...newData,
id: 1,
};

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});

it('throws on error assigning ID', async () => {
vi
.spyOn(DummyDataSource.prototype, 'newId')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToAssignIdFromResourceDataSource));
});

it('throws on error creating resource', async () => {
vi
.spyOn(DummyDataSource.prototype, 'newId')
.mockResolvedValueOnce(existingResource.id as never);

vi
.spyOn(DummyDataSource.prototype, 'create')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToCreateResource));
});
});

describe('patching items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

// TODO add more tests

it('throws on unable to fetch existing item', async () => {
Piano.canPatch();
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: newData,
headers: {
'content-type': 'application/merge-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
Piano.canPatch(false);
});

it('throws on item to patch not found', async () => {
Piano.canPatch();
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: newData,
headers: {
'content-type': 'application/merge-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.patchNonExistingResource));
Piano.canPatch(false);
});

describe('on merge patch', () => {
const newMergeData = {
brand: 'K. Kawai'
};

beforeEach(() => {
Piano.canPatch(['merge']);
});

afterEach(() => {
Piano.canPatch(false);
});

it('throws on attempting to request a delta patch', async () => {
const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: newMergeData,
headers: {
'content-type': 'application/json-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType));
});
});

describe('on delta patch', () => {
beforeEach(() => {
Piano.canPatch(['delta']);
});

afterEach(() => {
Piano.canPatch(false);
});

it('throws on attempting to request a merge patch', async () => {
const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: { brand: 'Hello' },
headers: {
'content-type': 'application/merge-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType));
});

it('throws on operating with a delta to an attribute outside the schema', async () => {
const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: [
{
op: 'replace',
path: 'brandUnknown',
value: 'K. Kawai',
},
] satisfies Delta[],
headers: {
'content-type': 'application/json-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
});

it('throws on operating a delta with mismatched value type', async () => {
const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: [
{
op: 'replace',
path: 'brand',
value: 5,
},
] satisfies Delta[],
headers: {
'content-type': 'application/json-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
});

it('throws on performing an invalid delta', async () => {
const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: [
{
op: 'add',
path: 'brand',
value: 5,
},
] satisfies Delta[],
headers: {
'content-type': 'application/json-patch+json',
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
});
});
});

describe('emplacing items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

const newData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});

it('throws on unable to emplace', async () => {
vi
.spyOn(DummyDataSource.prototype, 'emplace')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: newData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToEmplaceResource));
});
});

describe('deleting items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

beforeEach(() => {
Piano.canDelete();
backend.throwsErrorOnDeletingNotFound();
});

afterEach(() => {
Piano.canDelete(false);
backend.throwsErrorOnDeletingNotFound(false);
});

it('throws on unable to check if item exists', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
});

it('throws on item not found', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.deleteNonExistingResource));
});

it('throws on unable to delete item', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);

vi
.spyOn(DummyDataSource.prototype, 'delete')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToDeleteResource));
});
});
});

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

@@ -1,480 +0,0 @@
import {IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http';
import {Method, DataSource} from '@modal-sh/yasumi/backend';
import {FALLBACK_LANGUAGE, Language} from '@modal-sh/yasumi';

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

+ 0
- 23
packages/servers/http/tsconfig.json View File

@@ -1,23 +0,0 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true
}
}

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

Loading…
Cancel
Save