Browse Source

Update data source

Isolate data source from the rest of the backend code.
master
TheoryOfNekomata 6 months ago
parent
commit
52af811619
14 changed files with 431 additions and 235 deletions
  1. +2
    -6
      examples/basic/data-source.ts
  2. +14
    -14
      examples/basic/server.ts
  3. +4
    -4
      src/backend/common.ts
  4. +4
    -18
      src/backend/core.ts
  5. +31
    -10
      src/backend/data-source.ts
  6. +167
    -16
      src/backend/data-sources/file-jsonl.ts
  7. +6
    -6
      src/backend/http/decorators/backend/resource.ts
  8. +86
    -31
      src/backend/http/handlers.ts
  9. +35
    -16
      src/backend/http/server.ts
  10. +6
    -17
      src/backend/http/utils.ts
  11. +5
    -1
      src/common/app.ts
  12. +4
    -0
      src/common/language.ts
  13. +24
    -66
      src/common/resource.ts
  14. +43
    -30
      test/e2e/default.test.ts

+ 2
- 6
examples/basic/data-source.ts View File

@@ -1,9 +1,7 @@
import {dataSources, Resource} from '../../src';
import {DataSource} from '../../src/backend/data-source';
import {BaseDataSource} from '../../src/common/data-source';

export const autoIncrement = async (dataSource: BaseDataSource) => {
const data = await (dataSource as DataSource).getMultiple() as Record<string, string>[];
export const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[];

const highestId = data.reduce<number>(
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
@@ -16,5 +14,3 @@ export const autoIncrement = async (dataSource: BaseDataSource) => {

return 1;
};

export const dataSource = (resource: Resource) => new dataSources.jsonlFile.DataSource(resource, 'examples/basic');

+ 14
- 14
examples/basic/server.ts View File

@@ -1,10 +1,10 @@
import {
application,
application, dataSources,
resource,
validation as v,
} from '../../src';
import {TEXT_SERIALIZER_PAIR} from './serializers';
import {autoIncrement, dataSource} from './data-source';
import {autoIncrement} from './data-source';

const Piano = resource(v.object(
{
@@ -14,18 +14,18 @@ const Piano = resource(v.object(
))
.name('Piano')
.route('pianos')
.id('id', {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
})
.canFetchItem()
.canFetchCollection()
.canCreate()
.canEmplace()
.canPatch()
.canDelete();
.canDelete()
.id('id', {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});

const User = resource(v.object(
{
@@ -37,15 +37,15 @@ const User = resource(v.object(
},
v.never()
))
.name('User')
.route('users')
.fullText('bio')
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
})
.name('User')
.route('users')
.fullText('bio');
});

const app = application({
name: 'piano-service',
@@ -55,7 +55,7 @@ const app = application({
.resource(User);

const backend = app.createBackend({
dataSource,
dataSource: new dataSources.jsonlFile.DataSource('examples/basic'),
});

const server = backend.createHttpServer({


+ 4
- 4
src/backend/common.ts View File

@@ -1,12 +1,12 @@
import {ApplicationState, ContentNegotiation, Resource} from '../common';
import {BaseDataSource} from '../common/data-source';
import {ApplicationState, ContentNegotiation} from '../common';
import {DataSource} from './data-source';

export interface BackendState {
app: ApplicationState;
dataSource: (resource: Resource) => BaseDataSource;
dataSource: DataSource;
cn: ContentNegotiation;
showTotalItemCountOnGetCollection: boolean;
throws404OnDeletingNotFound: boolean;
throwsErrorOnDeletingNotFound: boolean;
checksSerializersOnDelete: boolean;
showTotalItemCountOnCreateItem: boolean;
}


+ 4
- 18
src/backend/core.ts View File

@@ -1,25 +1,11 @@
import * as v from 'valibot';
import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common';
import http from 'http';
import {createServer, CreateServerParams} from './http/server';
import https from 'https';
import {BackendState} from './common';
import {BaseDataSource} from '../common/data-source';
import {DataSource} from './data-source';

export interface BackendResource<
DataSourceType extends BaseDataSource = DataSource,
ResourceSchema extends v.BaseSchema = v.BaseSchema,
ResourceName extends string = string,
ResourceRouteName extends string = string,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> extends Resource<ResourceSchema, ResourceName, ResourceRouteName, IdAttr, IdSchema> {
newId(dataSource: DataSourceType): string | number | unknown;
dataSource: DataSourceType;
}

export interface BackendBuilder<T extends BaseDataSource = BaseDataSource> {
export interface BackendBuilder<T extends DataSource = DataSource> {
showTotalItemCountOnGetCollection(b?: boolean): this;
showTotalItemCountOnCreateItem(b?: boolean): this;
checksSerializersOnDelete(b?: boolean): this;
@@ -30,7 +16,7 @@ export interface BackendBuilder<T extends BaseDataSource = BaseDataSource> {

export interface CreateBackendParams {
app: ApplicationState;
dataSource: (resource: Resource) => BaseDataSource;
dataSource: DataSource;
}

export const createBackend = (params: CreateBackendParams) => {
@@ -44,7 +30,7 @@ export const createBackend = (params: CreateBackendParams) => {
},
showTotalItemCountOnGetCollection: false,
showTotalItemCountOnCreateItem: false,
throws404OnDeletingNotFound: false,
throwsErrorOnDeletingNotFound: false,
checksSerializersOnDelete: false,
};

@@ -58,7 +44,7 @@ export const createBackend = (params: CreateBackendParams) => {
return this;
},
throwsErrorOnDeletingNotFound(b = true) {
backendState.throws404OnDeletingNotFound = b;
backendState.throwsErrorOnDeletingNotFound = b;
return this;
},
checksSerializersOnDelete(b = true) {


+ 31
- 10
src/backend/data-source.ts View File

@@ -1,13 +1,34 @@
import {BaseDataSource} from '../common/data-source';
import * as v from 'valibot';
import {Resource} from '../common';

export interface DataSource<T = object, Q = object> extends BaseDataSource {
type IsCreated = boolean;

type TotalCount = number;

type DeleteResult = unknown;

export interface DataSource<Schema = object, Query = object, ID = string> {
initialize(): Promise<unknown>;
getTotalCount?(query?: Q): Promise<number>;
getMultiple(query?: Q): Promise<T[]>;
getById(id: string): Promise<T | null>;
getSingle?(query?: Q): Promise<T | null>;
create(data: T): Promise<T>;
delete(id: string): Promise<unknown>;
emplace(id: string, data: T): Promise<[T, boolean]>;
patch(id: string, data: Partial<T>): Promise<T | null>;
getTotalCount?(query?: Query): Promise<TotalCount>;
getMultiple(query?: Query): Promise<Schema[]>;
getById(id: ID): Promise<Schema | null>;
getSingle?(query?: Query): Promise<Schema | null>;
create(data: Schema): Promise<Schema>;
delete(id: ID): Promise<DeleteResult>;
emplace(id: ID, data: Schema): Promise<[Schema, IsCreated]>;
patch(id: ID, data: Partial<Schema>): Promise<Schema | 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>;
}

+ 167
- 16
src/backend/data-sources/file-jsonl.ts View File

@@ -1,18 +1,64 @@
import {readFile, writeFile} from 'fs/promises';
import {join} from 'path';
import {DataSource as DataSourceInterface} from '../data-source';
import {DataSource as DataSourceInterface, ResourceIdConfig} from '../data-source';
import {Resource} from '../..';
import * as v from 'valibot';

declare module '../..' {


interface BaseResourceState {
idAttr: string;
idConfig: ResourceIdConfig<v.BaseSchema>
}

interface Resource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
CurrentIdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> {
dataSource?: DataSourceInterface;
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends IdSchema>(
newIdAttr: NewIdAttr,
params: ResourceIdConfig<TheIdSchema>
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>;
fullText(fullTextAttr: string): this;
}
}

export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> {
private readonly path: string;
private path?: string;

private resource?: Resource;

data: T[] = [];

constructor(private readonly resource: Resource, baseDir = '') {
this.path = join(baseDir, `${this.resource.state.routeName}.jsonl`);
constructor(private readonly baseDir = '') {
// noop
}

prepareResource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string
>(resource: Resource<Schema, CurrentName, CurrentRouteName>) {
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`);
resource.dataSource = resource.dataSource ?? this;
const originalResourceId = resource.id;
resource.id = <NewIdAttr extends string, NewIdSchema extends v.BaseSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => {
originalResourceId(newIdAttr, params);
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>;
};
this.resource = resource;
}

async initialize() {
if (typeof this.path !== 'string') {
throw new Error('Resource not prepared.');
}

try {
const fileContents = await readFile(this.path, 'utf-8');
const lines = fileContents.split('\n');
@@ -30,9 +76,38 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
return [...this.data];
}

async newId() {
const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig<any>;
if (typeof idConfig === 'undefined') {
throw new Error('Resource not prepared.');
}

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

async getById(idSerialized: string) {
const id = this.resource.state.idConfig.deserialize(idSerialized);
const foundData = this.data.find((s) => s[this.resource.state.idAttr as string] === id);
if (typeof this.resource === 'undefined') {
throw new Error('Resource not prepared.');
}

if (typeof this.path !== 'string') {
throw new Error('Resource not prepared.');
}

const theIdAttr = this.resource.state.shared.get('idAttr');
if (typeof theIdAttr === 'undefined') {
throw new Error('Resource not prepared.');
}
const idAttr = theIdAttr as string;
const theIdConfigRaw = this.resource.state.shared.get('idConfig');
if (typeof theIdConfigRaw === 'undefined') {
throw new Error('Resource not prepared.');
}
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>;

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

if (foundData) {
return {
@@ -44,12 +119,31 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async create(data: T) {
if (typeof this.resource === 'undefined') {
throw new Error('Resource not prepared.');
}

if (typeof this.path !== 'string') {
throw new Error('Resource not prepared.');
}

const theIdAttr = this.resource.state.shared.get('idAttr');
if (typeof theIdAttr === 'undefined') {
throw new Error('Resource not prepared.');
}
const idAttr = theIdAttr as string;
const theIdConfigRaw = this.resource.state.shared.get('idConfig');
if (typeof theIdConfigRaw === 'undefined') {
throw new Error('Resource not prepared.');
}
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>;

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

if (this.resource.state.idAttr in newData) {
newData[this.resource.state.idAttr] = this.resource.state.idConfig.deserialize(newData[this.resource.state.idAttr] as string);
if (idAttr in newData) {
newData[idAttr] = theIdConfig.deserialize(newData[idAttr] as string);
}

const newCollection = [
@@ -63,10 +157,29 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async delete(idSerialized: string) {
if (typeof this.resource === 'undefined') {
throw new Error('Resource not prepared.');
}

if (typeof this.path !== 'string') {
throw new Error('Resource not prepared.');
}

const theIdAttr = this.resource.state.shared.get('idAttr');
if (typeof theIdAttr === 'undefined') {
throw new Error('Resource not prepared.');
}
const idAttr = theIdAttr as string;
const theIdConfigRaw = this.resource.state.shared.get('idConfig');
if (typeof theIdConfigRaw === 'undefined') {
throw new Error('Resource not prepared.');
}
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>;

const oldDataLength = this.data.length;

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

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

@@ -74,17 +187,36 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async emplace(idSerialized: string, dataWithId: T) {
if (typeof this.resource === 'undefined') {
throw new Error('Resource not prepared.');
}

if (typeof this.path !== 'string') {
throw new Error('Resource not prepared.');
}

const theIdAttr = this.resource.state.shared.get('idAttr');
if (typeof theIdAttr === 'undefined') {
throw new Error('Resource not prepared.');
}
const idAttr = theIdAttr as string;
const theIdConfigRaw = this.resource.state.shared.get('idConfig');
if (typeof theIdConfigRaw === 'undefined') {
throw new Error('Resource not prepared.');
}
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>;

const existing = await this.getById(idSerialized);
const id = this.resource.state.idConfig.deserialize(idSerialized);
const { [this.resource.state.idAttr]: idFromResource, ...data } = dataWithId;
const id = theIdConfig.deserialize(idSerialized);
const { [idAttr]: idFromResource, ...data } = dataWithId;
const dataToEmplace = {
...data,
[this.resource.state.idAttr]: id,
[idAttr]: id,
} as T;

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

@@ -101,6 +233,25 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async patch(idSerialized: string, data: Partial<T>) {
if (typeof this.resource === 'undefined') {
throw new Error('Resource not prepared.');
}

if (typeof this.path !== 'string') {
throw new Error('Resource not prepared.');
}

const theIdAttr = this.resource.state.shared.get('idAttr');
if (typeof theIdAttr === 'undefined') {
throw new Error('Resource not prepared.');
}
const idAttr = theIdAttr as string;
const theIdConfigRaw = this.resource.state.shared.get('idConfig');
if (typeof theIdConfigRaw === 'undefined') {
throw new Error('Resource not prepared.');
}
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>;

const existing = await this.getById(idSerialized);
if (!existing) {
return null;
@@ -111,9 +262,9 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
...data,
}

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



+ 6
- 6
src/backend/http/decorators/backend/resource.ts View File

@@ -1,7 +1,5 @@
import {RequestDecorator} from '../../../common';
import {DataSource} from '../../../data-source';
import {Resource} from '../../../../common';
import {BackendResource} from '../../../core';

declare module '../../../common' {
interface RequestContext {
@@ -11,14 +9,16 @@ declare module '../../../common' {
}

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

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

return req;


+ 86
- 31
src/backend/http/handlers.ts View File

@@ -2,7 +2,6 @@ import { constants } from 'http2';
import * as v from 'valibot';
import {HttpMiddlewareError, PlainResponse, Middleware} from './server';
import {LinkMap} from './utils';
import {BackendResource} from '../core';

export const handleGetRoot: Middleware = (req) => {
const { backend, basePath } = req;
@@ -41,12 +40,19 @@ export const handleGetRoot: Middleware = (req) => {
};

export const handleGetCollection: Middleware = async (req) => {
const { query, resource: resourceRaw, backend } = req;
const { query, resource, backend } = req;

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
const resource = resourceRaw as BackendResource;

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
@@ -80,12 +86,19 @@ export const handleGetCollection: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
const resource = resourceRaw as BackendResource;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
@@ -135,12 +148,19 @@ export const handleGetItem: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
const resource = resourceRaw as BackendResource;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
@@ -161,7 +181,7 @@ export const handleDeleteItem: Middleware = async (req) => {
});
}

if (!existing && backend!.throws404OnDeletingNotFound) {
if (!existing && backend!.throwsErrorOnDeletingNotFound) {
throw new HttpMiddlewareError('deleteNonExistingResource', {
statusCode: constants.HTTP_STATUS_NOT_FOUND
});
@@ -185,12 +205,19 @@ export const handleDeleteItem: Middleware = async (req) => {
};

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

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
const resource = resourceRaw as BackendResource;

if (typeof resourceId === 'undefined') {
throw new HttpMiddlewareError(
@@ -232,24 +259,37 @@ export const handlePatchItem: Middleware = async (req) => {
statusMessage: 'resourcePatched',
body: newObject,
});

// TODO finish the rest of the handlers!!!
};

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

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const idAttrRaw = resource.state.shared.get('idAttr');
if (typeof idAttrRaw === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
const resource = resourceRaw as BackendResource;
const idAttr = idAttrRaw as string;

let newId;
let params: v.Output<typeof resource.schema>;
try {
newId = await resource.newId(resource.dataSource);
newId = await resource.dataSource.newId();
params = { ...body as Record<string, unknown> };
params[resource.state.idAttr] = newId;
params[idAttr] = newId;
} catch (cause) {
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', {
cause,
@@ -296,18 +336,33 @@ export const handleCreateItem: Middleware = async (req) => {
}

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

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resourceRaw === 'undefined') {
throw new Error('No resource');
if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const idAttrRaw = resource.state.shared.get('idAttr');
if (typeof idAttrRaw === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
const resource = resourceRaw as BackendResource;
const idAttr = idAttrRaw as string;

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


+ 35
- 16
src/backend/http/server.ts View File

@@ -13,9 +13,6 @@ import {
handleGetRoot,
handlePatchItem,
} from './handlers';
import {
BackendResource,
} from '../core';
import {getBody} from './utils';
import {decorateRequestWithBackend} from './decorators/backend';
import {decorateRequestWithMethod} from './decorators/method';
@@ -94,14 +91,18 @@ export interface Middleware<Req extends RequestContext = RequestContext> {
(req: Req): undefined | Response | Promise<undefined | Response>;
}

const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, mainResourceId = '') => {
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, mainResourceId?: string) => {
const middlewares = [] as [string, Middleware, v.BaseSchema?][];

if (typeof resource === 'undefined') {
return middlewares;
}

if (mainResourceId === '') {
if (typeof resource.dataSource === 'undefined') {
return middlewares;
}

if (typeof mainResourceId !== 'string') {
if (resource.state.canFetchCollection) {
middlewares.push(['GET', handleGetCollection]);
}
@@ -116,14 +117,16 @@ const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, m
}
if (resource.state.canEmplace) {
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;
const putSchema = (
schema.type === 'object'
? v.merge([
schema as v.ObjectSchema<any>,
v.object({
[resource.state.idAttr]: v.transform(
[idAttr]: v.transform(
v.any(),
input => resource.state.idConfig.serialize(input),
input => idConfig!.serialize(input),
v.literal(mainResourceId)
)
})
@@ -178,13 +181,18 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr

if (typeof req.resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof req.resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const resource = req.resource as BackendResource;
try {
await resource.dataSource.initialize();
await req.resource.dataSource.initialize();
} catch (cause) {
throw new HttpMiddlewareError(
'unableToInitializeResourceDataSource',
@@ -211,8 +219,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
}

if (schema) {
const availableSerializers = Array.from(req.backend.app.mediaTypes.values());
const availableCharsets = Array.from(req.backend.app.charsets.values());
const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream';
const fragments = contentTypeHeader.split(';');
const mediaType = fragments[0];
@@ -227,9 +233,22 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
? charsetRaw.slice(1, -1).trim()
: charsetRaw.trim()
)
const deserializerPair = availableSerializers.find((l) => l.name === mediaType);
const encodingPair = availableCharsets.find((l) => l.name === charset);
(req as unknown as Record<string, unknown>).body = await getBody(req, schema, encodingPair, deserializerPair);
const theBodyBuffer = await getBody(req);
const encodingPair = req.backend.app.charsets.get(charset);
if (typeof encodingPair === 'undefined') {
throw new HttpMiddlewareError('unableToDecodeResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
});
}
const deserializerPair = req.backend.app.mediaTypes.get(mediaType);
if (typeof deserializerPair === 'undefined') {
throw new HttpMiddlewareError('unableToDeserializeResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
});
}
const theBodyStr = encodingPair.decode(theBodyBuffer);
const theBody = deserializerPair.deserialize(theBodyStr);
req.body = await v.parseAsync(schema, theBody, { abortEarly: false, abortPipeEarly: false });
}

const result = await middleware(req);
@@ -284,7 +303,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr

const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => {
const req = await decorateRequest(reqRaw);
const middlewares = getAllowedMiddlewares(req.resource, req.resourceId ?? '');
const middlewares = getAllowedMiddlewares(req.resource, req.resourceId);
const processRequestFn = processRequest(middlewares);
let middlewareState: Response;
try {


+ 6
- 17
src/backend/http/utils.ts View File

@@ -1,30 +1,19 @@
import {IncomingMessage} from 'http';
import {MediaType, Charset} from '../../common';
import {BaseSchema, parseAsync} from 'valibot';

export const getBody = (
req: IncomingMessage,
schema: BaseSchema,
encodingPair?: Charset,
deserializer?: MediaType,
) => new Promise((resolve, reject) => {
) => new Promise<Buffer>((resolve, reject) => {
let body = Buffer.from('');
req.on('data', (chunk) => {
body = Buffer.concat([body, chunk]);
});
req.on('end', async () => {
const bodyStr = encodingPair?.decode(body) ?? body.toString();
try {
const bodyDeserialized = await parseAsync(
schema,
deserializer?.deserialize(bodyStr) ?? body,
{abortEarly: false},
);
resolve(bodyDeserialized);
} catch (err) {
reject(err);
}
resolve(body);
});

req.on('error', (err) => {
reject(err);
})
});

interface LinkMapEntry {


+ 5
- 1
src/common/app.ts View File

@@ -24,7 +24,11 @@ export interface ApplicationBuilder {
mediaType(mediaType: MediaType): this;
language(language: Language): this;
charset(charset: Charset): this;
resource<T extends v.BaseSchema>(resRaw: Resource<T>): this;
resource<
Schema extends v.BaseSchema,
CurrentItemName extends string = string,
CurrentRouteName extends string = string
>(resRaw: Resource<Schema, CurrentItemName, CurrentRouteName>): this;
createBackend(params: Omit<CreateBackendParams, 'app'>): BackendBuilder;
createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder;
}


+ 4
- 0
src/common/language.ts View File

@@ -22,6 +22,8 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [
'unableToSerializeResponse',
'unableToEncodeResponse',
'unableToDeleteResource',
'unableToDeserializeResource',
'unableToDecodeResource',
'resourceDeleted',
'unableToDeserializeRequest',
'patchNonExistingResource',
@@ -64,6 +66,8 @@ export const FALLBACK_LANGUAGE = {
unableToDeleteResource: 'Unable To Delete $RESOURCE',
languageNotAcceptable: 'Language Not Acceptable',
encodingNotAcceptable: 'Encoding 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',


+ 24
- 66
src/common/resource.ts View File

@@ -1,24 +1,12 @@
import * as v from 'valibot';
import {BaseDataSource, GenerationStrategy} from './data-source';

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

export interface ResourceState<
ItemName extends string = string,
RouteName extends string = string,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
RouteName extends string = string
> {
idAttr: IdAttr;
shared: Map<string, unknown>;
itemName: ItemName;
routeName: RouteName;
idConfig: ResourceIdConfig<IdSchema>;
fullTextAttrs: Set<string>;
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
@@ -30,20 +18,12 @@ export interface ResourceState<
export interface Resource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
CurrentIdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
CurrentRouteName extends string = string
> {
dataSource?: unknown;
schema: Schema;
state: ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends v.BaseSchema>(
newIdAttr: NewIdAttr,
params: ResourceIdConfig<TheIdSchema>
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>;
fullText(fullTextAttr: string): this;
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>;
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>;
state: ResourceState<CurrentName, CurrentRouteName>;
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName>;
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName>;
canFetchCollection(b?: boolean): this;
canFetchItem(b?: boolean): this;
canCreate(b?: boolean): this;
@@ -55,25 +35,23 @@ export interface Resource<
export const resource = <
Schema extends v.BaseSchema,
CurrentName extends string = string,
CurrentRouteName extends string = string,
CurrentIdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> => {
CurrentRouteName extends string = string
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => {
const resourceState = {
fullTextAttrs: new Set<string>(),
shared: new Map(),
canCreate: false,
canFetchCollection: false,
canFetchItem: false,
canPatch: false,
canEmplace: false,
canDelete: false,
} as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
} as ResourceState<CurrentName, CurrentRouteName>;

return {
get state(): ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema> {
get state(): ResourceState<CurrentName, CurrentRouteName> {
return Object.freeze({
...resourceState
}) as unknown as ResourceState<CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
}) as unknown as ResourceState<CurrentName, CurrentRouteName>;
},
canFetchCollection(b = true) {
resourceState.canFetchCollection = b;
@@ -99,42 +77,24 @@ export const resource = <
resourceState.canDelete = b;
return this;
},
id<NewIdAttr extends CurrentIdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) {
resourceState.idAttr = newIdAttr;
resourceState.idConfig = params;
return this as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>;
},
newId(dataSource: BaseDataSource) {
return resourceState?.idConfig?.generationStrategy?.(dataSource);
id(idName, config) {
resourceState.shared.set('idAttr', idName);
resourceState.shared.set('idConfig', config);
return this;
},
fullText(fullTextAttr: string) {
if (
schema.type === 'object'
&& (
schema as unknown as v.ObjectSchema<
Record<string, v.BaseSchema>,
undefined,
Record<string, string>
>
)
.entries[fullTextAttr]?.type === 'string'
) {
resourceState.fullTextAttrs?.add(fullTextAttr);
return this;
}

throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`);
fullText(attrName) {
const fullTextAttrs = (resourceState.shared.get('fullText') ?? new Set()) as Set<string>;
fullTextAttrs.add(attrName);
resourceState.shared.set('fullText', fullTextAttrs);
return this;
},
name<NewName extends CurrentName>(n: NewName) {
resourceState.itemName = n;
return this as Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>;
return this as Resource<Schema, NewName, CurrentRouteName>;
},
route<NewRouteName extends CurrentRouteName>(n: NewRouteName) {
resourceState.routeName = n;
return this as Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>;
},
get idAttr() {
return resourceState.idAttr;
return this as Resource<Schema, CurrentName, NewRouteName>;
},
get itemName() {
return resourceState.itemName;
@@ -145,9 +105,7 @@ export const resource = <
get schema() {
return schema;
},
} as Resource<Schema, CurrentName, CurrentRouteName, CurrentIdAttr, IdSchema>;
} as Resource<Schema, CurrentName, CurrentRouteName>;
};

export type ResourceType<R extends Resource> = v.Output<R['schema']>;

export type ResourceTypeWithId<R extends Resource> = ResourceType<R> & Record<R['state']['idAttr'], v.Output<R['state']['idConfig']['schema']>>;

+ 43
- 30
test/e2e/default.test.ts View File

@@ -25,9 +25,11 @@ import { dataSources } from '../../src/backend';
import { application, resource, validation as v, Resource } from '../../src';

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

const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[];
@@ -74,7 +76,7 @@ describe('yasumi', () => {
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement as any,
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
@@ -90,7 +92,7 @@ describe('yasumi', () => {

const backend = app
.createBackend({
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
dataSource: new dataSources.jsonlFile.DataSource(baseDir),
})
.throwsErrorOnDeletingNotFound();

@@ -146,8 +148,8 @@ describe('yasumi', () => {
path: '/api/pianos',
method: 'GET',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept-Language': 'en',
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -165,7 +167,7 @@ describe('yasumi', () => {
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual([]);
resolve();
@@ -190,8 +192,8 @@ describe('yasumi', () => {
path: '/api/pianos',
method: 'HEAD',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept-Language': 'en',
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -247,7 +249,8 @@ describe('yasumi', () => {
path: '/api/pianos/1',
method: 'GET',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -264,7 +267,7 @@ describe('yasumi', () => {
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(data);
resolve();
@@ -290,7 +293,8 @@ describe('yasumi', () => {
path: '/api/pianos/1',
method: 'HEAD',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -320,7 +324,8 @@ describe('yasumi', () => {
path: '/api/pianos/2',
method: 'GET',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -351,7 +356,8 @@ describe('yasumi', () => {
path: '/api/pianos/2',
method: 'HEAD',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -406,8 +412,9 @@ describe('yasumi', () => {
path: '/api/pianos',
method: 'POST',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
@@ -424,7 +431,7 @@ describe('yasumi', () => {
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
@@ -478,8 +485,9 @@ describe('yasumi', () => {
path: `/api/pianos/${data.id}`,
method: 'PATCH',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
@@ -496,7 +504,7 @@ describe('yasumi', () => {
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...data,
@@ -525,8 +533,9 @@ describe('yasumi', () => {
path: '/api/pianos/2',
method: 'PATCH',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
@@ -582,8 +591,9 @@ describe('yasumi', () => {
path: `/api/pianos/${newData.id}`,
method: 'PUT',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
@@ -600,7 +610,7 @@ describe('yasumi', () => {
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(newData);
resolve();
@@ -628,8 +638,9 @@ describe('yasumi', () => {
path: `/api/pianos/${id}`,
method: 'PUT',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Content-Type': ACCEPT,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
@@ -646,7 +657,7 @@ describe('yasumi', () => {
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
@@ -698,7 +709,8 @@ describe('yasumi', () => {
path: `/api/pianos/${data.id}`,
method: 'DELETE',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
@@ -728,7 +740,8 @@ describe('yasumi', () => {
path: '/api/pianos/2',
method: 'DELETE',
headers: {
'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {


Loading…
Cancel
Save