Browse Source

Implement resource query

Add implementation for fetch method in tests.
refactor/new-arch
TheoryOfNekomata 7 months ago
parent
commit
feb105d292
17 changed files with 289 additions and 97 deletions
  1. +5
    -0
      packages/core/src/backend/common.ts
  2. +37
    -0
      packages/core/src/backend/data-source.ts
  3. +1
    -0
      packages/core/src/backend/index.ts
  4. +0
    -2
      packages/core/src/common/data-source.ts
  5. +4
    -1
      packages/core/src/common/endpoint.ts
  6. +1
    -1
      packages/core/src/common/index.ts
  7. +53
    -0
      packages/core/src/common/queries/common.ts
  8. +1
    -0
      packages/core/src/common/queries/index.ts
  9. +2
    -1
      packages/core/src/common/recipe.ts
  10. +14
    -13
      packages/core/src/common/response.ts
  11. +28
    -18
      packages/core/src/extenders/http/backend/core.ts
  12. +0
    -52
      packages/core/src/recipes/resource.ts
  13. +84
    -0
      packages/core/src/recipes/resource/core.ts
  14. +2
    -0
      packages/core/src/recipes/resource/index.ts
  15. +9
    -0
      packages/core/src/recipes/resource/response.ts
  16. +47
    -7
      packages/core/test/http/default.test.ts
  17. +1
    -2
      packages/core/test/index.test.ts

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

@@ -1,7 +1,9 @@
import {App as BaseApp, AppOperations, BaseAppState, Endpoint, Response} from '../common';
import {DataSource} from './data-source';

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

export interface ImplementationContext {
@@ -14,6 +16,7 @@ type ImplementationFunction = (params: ImplementationContext) => Promise<Respons

export interface Backend<App extends BaseApp = BaseApp> {
app: App;
dataSource?: DataSource;
implementations: Map<string, ImplementationFunction>;
implementOperation<Operation extends AppOperations<App>>(
operation: Operation, implementation: ImplementationFunction): this;
@@ -21,10 +24,12 @@ export interface Backend<App extends BaseApp = BaseApp> {

class BackendInstance<App extends BaseApp> implements Backend<App> {
readonly app: App;
readonly dataSource?: DataSource;
readonly implementations: Map<string, ImplementationFunction>;

constructor(params: BackendParams<App>) {
this.app = params.app;
this.dataSource = params.dataSource;
this.implementations = new Map<string, ImplementationFunction>();
}



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

@@ -0,0 +1,37 @@
import {QueryAndGrouping, validation as v} from '../common';

export interface EmplaceDetails {
isCreated: boolean;
}

export type DataSourceQuery = QueryAndGrouping;

export interface DataSource<
ItemData extends object = object,
ID extends unknown = unknown,
Query extends DataSourceQuery = DataSourceQuery,
Emplace extends EmplaceDetails = EmplaceDetails,
DeleteResult = unknown,
> {
initialize(): Promise<unknown>;
getTotalCount?(query?: Query): Promise<number>;
getMultiple(query?: Query): Promise<ItemData[]>;
getById(id: ID): Promise<ItemData | null>;
getSingle?(query?: Query): Promise<ItemData | null>;
create(data: ItemData): Promise<ItemData>;
delete(id: ID): Promise<DeleteResult>;
emplace(id: ID, data: ItemData): Promise<[ItemData, Emplace]>;
patch(id: ID, data: Partial<ItemData>): Promise<ItemData | null>;
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>;
}

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

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

+ 0
- 2
packages/core/src/common/data-source.ts View File

@@ -1,2 +0,0 @@

export interface DataSource {}

+ 4
- 1
packages/core/src/common/endpoint.ts View File

@@ -1,4 +1,4 @@
import {DataSource} from './data-source';
import {DataSource} from '../backend/data-source';
import {validation as v} from '.';

export type EndpointQueue = [Endpoint, Record<string, unknown> | undefined][];
@@ -87,6 +87,7 @@ export interface Endpoint<
State extends BaseEndpointState = BaseEndpointState
> {
name: Name;
dataSource?: DataSource;
schema: Schema;
params: Set<string>;
operations: Set<string>;
@@ -126,6 +127,7 @@ class EndpointInstance<
State extends BaseEndpointState
> implements Endpoint<Params['name'], Params['schema'], State> {
readonly name: Params['name'];
readonly dataSource: Params['dataSource'];
readonly operations: Set<string>;
readonly params: Set<string>;
readonly schema: Params['schema'];
@@ -135,6 +137,7 @@ class EndpointInstance<
this.schema = params.schema;
this.operations = new Set<string>();
this.params = new Set<string>();
this.dataSource = params.dataSource;
}

can<OpName extends string = string, OpValue extends OpValueType = OpValueType>(


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

@@ -1,10 +1,10 @@
export * from './app';
export * from './charset';
export * from './data-source';
export * from './endpoint';
export * from './language';
export * from './media-type';
export * from './operation';
export * from './queries';
export * from './response';
export * from './service';
export * as statusCodes from './status-codes';


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

@@ -0,0 +1,53 @@
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
> {}

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

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

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

@@ -1,4 +1,4 @@
import {Backend} from '../backend';
import {Backend, DataSource} from '../backend';
import {Operation} from './operation';
import {App} from './app';
import {Endpoint} from './endpoint';
@@ -6,6 +6,7 @@ import {Endpoint} from './endpoint';
export interface RecipeState<A extends App = App> {
app: A;
backend?: Backend<A>;
dataSource?: DataSource;
operations?: Record<string, Operation>;
endpoints?: Record<string, Endpoint>;
}


+ 14
- 13
packages/core/src/common/response.ts View File

@@ -2,13 +2,13 @@ import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes';

type FetchResponse = Awaited<ReturnType<typeof fetch>>;

export interface Response {
export interface Response<B extends unknown = unknown> {
statusCode: number;
statusMessage: string;
body?: Buffer;
body?: B;
}

export interface ErrorResponse extends Error, Response {}
export interface ErrorResponse<B extends unknown = unknown> extends Error, Response<B> {}

export interface HttpResponseConstructor<R extends Response> {
new (...args: any[]): R;
@@ -20,24 +20,25 @@ export interface HttpResponseErrorConstructor<R extends Response> extends HttpRe
}

export interface HttpSuccessResponseConstructor<R extends Response> extends HttpResponseConstructor<R> {
new (response: Partial<Omit<Response, 'statusCode' | 'res'>>): R;
new (response: Partial<Omit<Response, 'statusCode'>>): R;
}

export interface HttpErrorOptions extends ErrorOptions {
body?: Response['body'];
export interface HttpErrorOptions<B extends unknown = unknown> extends ErrorOptions {
body?: B;
}

export const HttpResponse = <
B extends unknown = unknown,
T extends StatusCode = StatusCode,
R extends Response = T extends ErrorStatusCode ? ErrorResponse : Response,
R extends Response = T extends ErrorStatusCode ? ErrorResponse<B> : Response<B>,
>(statusCode: T): T extends ErrorStatusCode ? HttpResponseErrorConstructor<R> : HttpSuccessResponseConstructor<R> => {
if (isErrorStatusCode(statusCode)) {
return class HttpErrorResponse extends Error implements ErrorResponse {
return class HttpErrorResponse extends Error implements ErrorResponse<B> {
readonly statusMessage: string;
readonly statusCode: T;
readonly body?: Buffer;
readonly body?: B;

constructor(message?: string, options?: HttpErrorOptions) {
constructor(message?: string, options?: HttpErrorOptions<B>) {
super(message, options);
this.name = this.statusMessage = message ?? '';
this.statusCode = statusCode;
@@ -47,11 +48,11 @@ export const HttpResponse = <
} as unknown as HttpResponseErrorConstructor<R>;
}

return class HttpSuccessResponse implements Response {
return class HttpSuccessResponse implements Response<B> {
readonly statusMessage: string;
readonly statusCode: T;
readonly body?: Buffer;
constructor(params: Partial<Omit<Response, 'statusCode'>>) {
readonly body?: B;
constructor(params: Partial<Omit<Response<B>, 'statusCode'>>) {
this.statusCode = statusCode;
this.statusMessage = params.statusMessage ?? '';
this.body = params.body;


+ 28
- 18
packages/core/src/extenders/http/backend/core.ts View File

@@ -1,18 +1,18 @@
import {parseToEndpointQueue, ServiceParams} from '../../../common';
import {ErrorResponse, parseToEndpointQueue, ServiceParams} from '../../../common';
import {
Backend as BaseBackend,
Server,
ServerRequest,
ServerResponse,
ServerRequestContext,
ServerResponseContext,
ServerParams,
} from '../../../backend';
import http from 'http';
import { statusCodes } from '../../../common';

declare module '../../../backend' {
interface ServerRequest extends http.IncomingMessage {}
interface ServerRequestContext extends http.IncomingMessage {}

interface ServerResponse extends http.ServerResponse {}
interface ServerResponseContext extends http.ServerResponse {}
}

class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
@@ -23,7 +23,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
this.backend = params.backend;
}

private readonly requestListener = async (req: ServerRequest, res: ServerResponse) => {
private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => {
// const endpoints = this.backend.app.endpoints;
if (typeof req.method === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {});
@@ -81,21 +81,31 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {

// TODO add flag on implementation context if CQRS should be enabled

const responseSpec = await implementation({
endpoint,
params: endpointParams ?? {},
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined,
});
try {
const responseSpec = await implementation({
endpoint,
params: endpointParams ?? {},
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined,
});

if (typeof responseSpec === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {});
res.end();
return;
}

if (typeof responseSpec === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {});
const bodyToSerialize = responseSpec.body;
console.log(bodyToSerialize);

res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code
res.writeHead(responseSpec.statusCode, {});
res.end();
} catch (errorResponseSpecRaw) {
const responseSpec = errorResponseSpecRaw as ErrorResponse;
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code
res.writeHead(responseSpec.statusCode, {});
res.end();
return;
}

res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code
res.writeHead(responseSpec.statusCode, {});
res.end();
};

serve(params: ServiceParams) {


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

@@ -1,52 +0,0 @@
import {Recipe} from '../common/recipe';
import {endpoint, HttpResponse, operation, validation as v} from '../common';
import {backend} from '../backend';

interface AddResourceRecipeParams {
endpointName: string;
}

export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a) => {
const operations = {
fetch: operation({
name: 'fetch' as const,
}),
};

const theEndpoint = endpoint({
name: params.endpointName,
schema: v.object({
username: v.string(),
}),
})
.param('resourceId')
.can('fetch');

const enhancedApp = a.app
.operation(operations.fetch)
.endpoint(theEndpoint);

const theBackend = a.backend ?? backend({
app: enhancedApp,
});

theBackend
.implementOperation('fetch', async (ctx) => {
// need to genericise the response here so we don't depend on the HTTP responses.

return new YesResponse({
statusMessage: 'Yes',
});
});

return {
operations,
app: enhancedApp,
backend: theBackend,
endpoints: {
[params.endpointName]: theEndpoint,
},
};
};

export class YesResponse extends HttpResponse(204) {}

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

@@ -0,0 +1,84 @@
import {Recipe} from '../../common/recipe';
import {endpoint, operation, validation as v} from '../../common';
import {backend, DataSource} from '../../backend';
import {
DataSourceNotFoundResponseError,
ItemNotFoundReponseError,
ResourceCollectionFetchedResponse,
ResourceItemFetchedResponse,
} from './response';

interface AddResourceRecipeParams {
endpointName: string;
dataSource?: DataSource;
}

export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a) => {
const operations = {
fetch: operation({
name: 'fetch' as const,
}),
};

const theEndpoint = endpoint({
name: params.endpointName,
schema: v.object({
username: v.string(),
}),
})
.param('resourceId')
.can('fetch');

const enhancedApp = a.app
.operation(operations.fetch)
.endpoint(theEndpoint);

const theBackend = a.backend ?? backend({
app: enhancedApp,
dataSource: params.dataSource,
});

theBackend
.implementOperation('fetch', async (ctx) => {
const dataSource: DataSource = ctx.endpoint.dataSource ?? theBackend.dataSource ?? {} as DataSource;
// need to genericise the response here so we don't depend on the HTTP responses.
const { resourceId } = ctx.params;
const { getById, getMultiple } = dataSource;

if (typeof resourceId === 'undefined') {
if (typeof getMultiple === 'undefined') {
throw new DataSourceNotFoundResponseError();
}

// TODO add query here
const items = await getMultiple();
return new ResourceCollectionFetchedResponse({
statusMessage: 'Resource Collection Fetched',
body: items,
});
}

if (typeof getById === 'undefined') {
throw new DataSourceNotFoundResponseError();
}

const item = await getById(resourceId);
if (!item) {
throw new ItemNotFoundReponseError();
}

return new ResourceItemFetchedResponse({
statusMessage: 'Resource Item Fetched',
body: item,
});
});

return {
operations,
app: enhancedApp,
backend: theBackend,
endpoints: {
[params.endpointName]: theEndpoint,
},
};
};

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

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

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

@@ -0,0 +1,9 @@
import {HttpResponse, statusCodes} from '../../common';

export class ResourceItemFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {}

export class ResourceCollectionFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {}

export class DataSourceNotFoundResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {}

export class ItemNotFoundReponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_FOUND) {}

+ 47
- 7
packages/core/test/http/default.test.ts View File

@@ -4,6 +4,7 @@ import {
afterAll,
it,
expect,
vi, Mock,
} from 'vitest';

import {
@@ -11,18 +12,38 @@ import {
Endpoint,
Operation,
} from '../../src/common';
import {Server} from '../../src/backend';
import {DataSource, DataSourceQuery, EmplaceDetails, Server} from '../../src/backend';
import {Client} from '../../src/client';
import {server} from '../../src/extenders/http/backend';
import {client} from '../../src/extenders/http/client';
import {composeRecipes} from '../../src/common/recipe';
import {addResourceRecipe, YesResponse} from '../../src/recipes/resource';
import {addResourceRecipe, ResourceItemFetchedResponse} from '../../src/recipes/resource';

describe('default', () => {
let theClient: Client;
let theServer: Server;
let theRawEndpoint: Endpoint;
let theOperation: Operation;
let dataSource: Record<keyof DataSource, Mock>;

beforeAll(() => {
dataSource = {
create: vi.fn(async (data) => data),
getById: vi.fn(async () => ({})),
delete: vi.fn(),
emplace: vi.fn(async () => [{}, { isCreated: false }]),
getMultiple: vi.fn(async () => []),
getSingle: vi.fn(async () => ({})),
getTotalCount: vi.fn(async () => 1),
newId: vi.fn(async () => 1),
patch: vi.fn(async (id, data) => ({ ...data, id })),
initialize: vi.fn(async () => {}),
};
});

afterAll(() => {
dataSource.getById.mockReset();
});

beforeAll(async () => {
const theRawApp = app({
@@ -34,8 +55,8 @@ describe('default', () => {
operations,
backend: theBackend,
} = composeRecipes([
addResourceRecipe({ endpointName: 'users' }),
addResourceRecipe({ endpointName: 'posts' })
addResourceRecipe({ endpointName: 'users', dataSource, }),
addResourceRecipe({ endpointName: 'posts', dataSource, })
])({
app: theRawApp,
});
@@ -78,9 +99,28 @@ describe('default', () => {
foo: 'bar',
}));

const response = YesResponse.fromFetchResponse(responseRaw);
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw);

expect(response).toHaveProperty('statusCode', 200);
expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched');
});

it('works for items', async () => {
// TODO create wrapper for fetch's Response here
//
// should we create a helper object to process client-side received response from server's sent response?
//
// the motivation is to remove the manual deserialization from the client (provide serialization on the response
// object so as the client is not limited to .text(), .json(), .arrayBuffer() etc)
const responseRaw = await theClient
.at(theRawEndpoint, { resourceId: 3 })
.makeRequest(theOperation, new URLSearchParams({
foo: 'bar',
}));

const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw);

expect(response).toHaveProperty('statusCode', 204);
expect(response).toHaveProperty('statusMessage', 'Yes');
expect(response).toHaveProperty('statusCode', 200);
expect(response).toHaveProperty('statusMessage', 'Resource Item Fetched');
});
});

+ 1
- 2
packages/core/test/index.test.ts View File

@@ -2,7 +2,6 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest';
import {
App,
app,
DataSource,
Endpoint,
endpoint,
Operation,
@@ -10,7 +9,7 @@ import {
statusCodes,
validation as v,
} from '../src/common';
import {Backend, backend, Server} from '../src/backend';
import {Backend, backend, DataSource, Server} from '../src/backend';
import {Client} from '../src/client';
import {server} from '../src/extenders/http/backend';
import {client} from '../src/extenders/http/client';


Loading…
Cancel
Save