Browse Source

Add type safety, provide granular error handling

Provide utility types for resource schema validation.

Also handle each step for POST and PUT handlers.
master
TheoryOfNekomata 8 months ago
parent
commit
3942b1efca
8 changed files with 269 additions and 158 deletions
  1. +5
    -5
      examples/basic/server.ts
  2. +54
    -40
      src/core.ts
  3. +3
    -3
      src/data-sources/file-jsonl.ts
  4. +182
    -107
      src/handlers.ts
  5. +1
    -1
      src/index.ts
  6. +6
    -0
      src/languages/en/index.ts
  7. +5
    -0
      src/validation.ts
  8. +13
    -2
      test/e2e/default.test.ts

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

@@ -1,7 +1,7 @@
import {
application,
resource,
valibot as v,
validation as v,
serializers,
encodings,
} from '../../src';
@@ -38,14 +38,14 @@ const User = resource(v.object(
},
v.never()
))
.name('User')
.fullText('bio')
.id('id', {
.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')
.fullText('bio');

const app = application({
name: 'piano-service',


+ 54
- 40
src/core.ts View File

@@ -2,7 +2,7 @@ import * as http from 'http';
import * as https from 'https';
import { constants } from 'http2';
import { pluralize } from 'inflection';
import {BaseSchema, ObjectSchema, Output} from 'valibot';
import * as v from 'valibot';
import { SerializerPair } from './serializers';
import {
handleCreateItem, handleDeleteItem, handleEmplaceItem,
@@ -20,11 +20,12 @@ import * as applicationJson from './serializers/application/json';

// TODO separate frontend and backend factory methods

export interface DataSource<T = object> {
export interface DataSource<T = object, Q = object> {
initialize(): Promise<unknown>;
getTotalCount?(): Promise<number>;
getMultiple(): Promise<T[]>;
getSingle(id: string): Promise<T | null>;
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]>;
@@ -36,12 +37,12 @@ export interface ApplicationParams {
dataSource?: (resource: Resource) => DataSource;
}

interface ResourceState<T extends BaseSchema> {
idAttr: string;
interface ResourceState<IdAttr extends string = string, IdSchema extends v.BaseSchema = v.BaseSchema> {
idAttr: IdAttr;
itemName: string;
collectionName: string;
routeName: string;
idConfig: ResourceIdConfig<T>;
idConfig: ResourceIdConfig<IdSchema>;
fullTextAttrs: Set<string>;
canCreate: boolean;
canFetchCollection: boolean;
@@ -51,11 +52,18 @@ interface ResourceState<T extends BaseSchema> {
canDelete: boolean;
}

export interface Resource<T extends BaseSchema = any, IdSchema extends BaseSchema = any> {
export interface Resource<
ResourceSchema extends v.BaseSchema = v.BaseSchema,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
> {
newId(dataSource: DataSource): string | number | unknown;
schema: T;
state: ResourceState<IdSchema>;
id(newIdAttr: string, params: ResourceIdConfig<IdSchema>): this;
schema: ResourceSchema;
state: ResourceState<IdAttr, IdSchema>;
id<NewIdAttr extends IdAttr, TheIdSchema extends v.BaseSchema>(
newIdAttr: NewIdAttr,
params: ResourceIdConfig<TheIdSchema>
): Resource<ResourceSchema, NewIdAttr, TheIdSchema>;
fullText(fullTextAttr: string): this;
name(n: string): this;
collection(n: string): this;
@@ -68,7 +76,7 @@ export interface Resource<T extends BaseSchema = any, IdSchema extends BaseSchem
canDelete(b?: boolean): this;
}

export interface ResourceWithDataSource<T extends BaseSchema = any> extends Resource<T> {
export interface ResourceWithDataSource<T extends v.BaseSchema> extends Resource<T> {
dataSource: DataSource;
}

@@ -76,14 +84,14 @@ interface GenerationStrategy {
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>;
}

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

const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => {
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId: string) => {
const middlewares = [] as [string, Middleware][];
if (mainResourceId === '') {
if (resource.state.canFetchCollection) {
@@ -111,7 +119,11 @@ const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => {
return middlewares;
};

export const resource = <T extends BaseSchema, Id extends BaseSchema = any>(schema: T): Resource<T, Id> => {
export const resource = <
ResourceSchema extends v.BaseSchema,
IdAttr extends string = string,
IdSchema extends v.BaseSchema = v.BaseSchema
>(schema: ResourceSchema): Resource<ResourceSchema, IdAttr, IdSchema> => {
const resourceState = {
fullTextAttrs: new Set<string>(),
canCreate: false,
@@ -120,13 +132,13 @@ export const resource = <T extends BaseSchema, Id extends BaseSchema = any>(sche
canPatch: false,
canEmplace: false,
canDelete: false,
} as Partial<ResourceState<Id>>;
} as ResourceState<IdAttr, IdSchema>;

return {
get state(): ResourceState<Id> {
get state(): ResourceState<IdAttr, IdSchema> {
return Object.freeze({
...resourceState
}) as unknown as ResourceState<Id>;
}) as unknown as ResourceState<IdAttr, IdSchema>;
},
canFetchCollection(b = true) {
resourceState.canFetchCollection = b;
@@ -152,10 +164,10 @@ export const resource = <T extends BaseSchema, Id extends BaseSchema = any>(sche
resourceState.canDelete = b;
return this;
},
id(newIdAttr: string, params: ResourceIdConfig<Id>) {
id<NewIdAttr extends IdAttr, NewIdSchema extends IdSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) {
resourceState.idAttr = newIdAttr;
resourceState.idConfig = params;
return this;
return this as Resource<ResourceSchema, NewIdAttr, NewIdSchema>;
},
newId(dataSource: DataSource) {
return resourceState?.idConfig?.generationStrategy?.(dataSource);
@@ -164,8 +176,8 @@ export const resource = <T extends BaseSchema, Id extends BaseSchema = any>(sche
if (
schema.type === 'object'
&& (
schema as unknown as ObjectSchema<
Record<string, BaseSchema>,
schema as unknown as v.ObjectSchema<
Record<string, v.BaseSchema>,
undefined,
Record<string, string>
>
@@ -207,8 +219,8 @@ export const resource = <T extends BaseSchema, Id extends BaseSchema = any>(sche
},
get schema() {
return schema;
}
} as Resource;
},
} as Resource<ResourceSchema, IdAttr, IdSchema>;
};

interface CreateServerParams {
@@ -232,7 +244,7 @@ interface HandlerState {
}

export interface ApplicationState {
resources: Set<ResourceWithDataSource>;
resources: Set<ResourceWithDataSource<any>>;
languages: Map<string, MessageCollection>;
serializers: Map<string, SerializerPair>;
encodings: Map<string, EncodingPair>;
@@ -256,6 +268,8 @@ export interface MessageCollection {
resourceFetched(resource: Resource): string;
resourceNotFound(resource: Resource): string;
deleteNonExistingResource(resource: Resource): string;
unableToGenerateIdFromResourceDataSource(resource: Resource): string;
unableToEmplaceResource(resource: Resource): string;
unableToSerializeResponse(): string;
unableToEncodeResponse(): string;
unableToDeleteResource(resource: Resource): string;
@@ -293,7 +307,7 @@ export interface BackendState {
showTotalItemCountOnCreateItem: boolean;
}

interface MiddlewareArgs {
interface MiddlewareArgs<T extends v.BaseSchema> {
handlerState: HandlerState;
backendState: BackendState;
appState: ApplicationState;
@@ -305,13 +319,13 @@ interface MiddlewareArgs {
errorResponseBodyLanguage: [string, MessageCollection];
errorResponseBodyEncoding: [string, EncodingPair];
errorResponseBodyMediaType: [string, SerializerPair];
resource: ResourceWithDataSource;
resource: ResourceWithDataSource<T>;
resourceId: string;
query: URLSearchParams;
}

export interface Middleware {
(args: MiddlewareArgs): RequestListenerWithReturn<HandlerState | Promise<HandlerState>>
<T extends v.BaseSchema = v.BaseSchema>(args: MiddlewareArgs<T>): RequestListenerWithReturn<HandlerState | Promise<HandlerState>>
}

export interface Backend {
@@ -332,14 +346,14 @@ export interface Application {
contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this;
language(languageCode: string, messageCollection: MessageCollection): this;
encoding(encoding: string, encodingPair: EncodingPair): this;
resource(resRaw: Partial<Resource>): this;
resource<T extends v.BaseSchema>(resRaw: Partial<Resource<T>>): this;
createBackend(): Backend;
createClient(): Client;
}

export const application = (appParams: ApplicationParams): Application => {
const appState: ApplicationState = {
resources: new Set<ResourceWithDataSource>(),
resources: new Set<ResourceWithDataSource<any>>(),
languages: new Map<string, MessageCollection>(),
serializers: new Map<string, SerializerPair>(),
encodings: new Map<string, EncodingPair>(),
@@ -362,13 +376,13 @@ export const application = (appParams: ApplicationParams): Application => {
appState.languages.set(languageCode, messageCollection);
return this;
},
resource(resRaw: Partial<Resource>) {
const res = resRaw as Partial<ResourceWithDataSource>;
resource<T extends v.BaseSchema>(resRaw: Partial<Resource<T>>) {
const res = resRaw as Partial<ResourceWithDataSource<T>>;
res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource);
if (typeof res.dataSource === 'undefined') {
throw new Error(`Resource ${res.state!.itemName} must have a data source.`);
}
appState.resources.add(res as ResourceWithDataSource);
appState.resources.add(res as ResourceWithDataSource<T>);
return this;
},
createClient(): Client {
@@ -411,7 +425,6 @@ export const application = (appParams: ApplicationParams): Application => {
showTotalItemCountOnCreateItem: false,
throws404OnDeletingNotFound: false,
checksSerializersOnDelete: false,

};

return {
@@ -466,6 +479,7 @@ export const application = (appParams: ApplicationParams): Application => {
const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding;
const errorEncoding = appState.encodings.get(errorEncodingKey) ?? fallbackEncoding;

// TODO refactor
const [currentLanguageCode, currentLanguageMessages] = availableLanguages.find(([code]) => code === languageCandidate) ?? [];
if (typeof currentLanguageCode === 'undefined' || typeof currentLanguageMessages === 'undefined') {
const data = errorMessageCollection.bodies.languageNotAcceptable();
@@ -513,7 +527,7 @@ export const application = (appParams: ApplicationParams): Application => {
return;
}

const middlewareArgs: Omit<MiddlewareArgs, 'resource' | 'resourceId'> = {
const middlewareArgs: Omit<MiddlewareArgs<never>, 'resource' | 'resourceId'> = {
handlerState: {
handled: false
},
@@ -530,13 +544,13 @@ export const application = (appParams: ApplicationParams): Application => {
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
};

const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res);
const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs<never>)(req, res);
if (methodAndUrl.handled) {
return;
}

if (url === '/') {
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res);
const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs<never>)(req, res);
if (middlewareState.handled) {
return;
}


+ 3
- 3
src/data-sources/file-jsonl.ts View File

@@ -29,7 +29,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
return [...this.data];
}

async getSingle(idSerialized: 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);

@@ -73,7 +73,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async emplace(idSerialized: string, dataWithId: T) {
const existing = await this.getSingle(idSerialized);
const existing = await this.getById(idSerialized);
const id = this.resource.state.idConfig.deserialize(idSerialized);
const { [this.resource.state.idAttr]: idFromResource, ...data } = dataWithId;
const dataToEmplace = {
@@ -100,7 +100,7 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async patch(idSerialized: string, data: Partial<T>) {
const existing = await this.getSingle(idSerialized);
const existing = await this.getById(idSerialized);
if (!existing) {
return null;
}


+ 182
- 107
src/handlers.ts View File

@@ -43,13 +43,13 @@ export const handleGetRoot: Middleware = ({
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
}) => (_req: IncomingMessage, res: ServerResponse) => {
const singleResDatum = {
const data = {
name: appParams.name
};

let serialized;
try {
serialized = responseBodySerializerPair.serialize(singleResDatum);
serialized = responseBodySerializerPair.serialize(data);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -61,9 +61,9 @@ export const handleGetRoot: Middleware = ({
};
}

let theFormatted;
let encoded;
try {
theFormatted = encoding.encode(serialized);
encoded = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -99,7 +99,7 @@ export const handleGetRoot: Middleware = ({
}
res.writeHead(constants.HTTP_STATUS_OK, theHeaders);
res.statusMessage = responseBodyMessageCollection.statusMessages.ok();
res.end(theFormatted);
res.end(encoded);
return {
handled: true
};
@@ -112,6 +112,7 @@ export const handleGetCollection: Middleware = ({
responseBodyEncoding: [encodingKey, encoding],
errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection],
backendState,
query,
}) => async (_req: IncomingMessage, res: ServerResponse) => {
try {
await resource.dataSource.initialize();
@@ -126,13 +127,13 @@ export const handleGetCollection: Middleware = ({
};
}

let resData: Object[];
let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
try {
// TODO querying mechanism
resData = await resource.dataSource.getMultiple(); // TODO paginated responses per resource
data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource
if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
totalItemCount = await resource.dataSource.getTotalCount(query);
}
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
@@ -147,7 +148,7 @@ export const handleGetCollection: Middleware = ({

let serialized;
try {
serialized = responseBodySerializerPair.serialize(resData);
serialized = responseBodySerializerPair.serialize(data);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -159,9 +160,9 @@ export const handleGetCollection: Middleware = ({
};
}

let theFormatted;
let encoded;
try {
theFormatted = encoding.encode(serialized);
encoded = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -185,7 +186,7 @@ export const handleGetCollection: Middleware = ({

res.writeHead(constants.HTTP_STATUS_OK, headers);
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCollectionFetched(resource);
res.end(theFormatted);
res.end(encoded);
return {
handled: true
};
@@ -212,9 +213,9 @@ export const handleGetItem: Middleware = ({
};
}

let singleResDatum: Object | null = null;
let data: v.Output<typeof resource.schema> | null = null;
try {
singleResDatum = await resource.dataSource.getSingle(resourceId);
data = await resource.dataSource.getById(resourceId);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -228,7 +229,7 @@ export const handleGetItem: Middleware = ({

let serialized: string | null;
try {
serialized = singleResDatum === null ? null : responseBodySerializerPair.serialize(singleResDatum);
serialized = data === null ? null : responseBodySerializerPair.serialize(data);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -240,9 +241,9 @@ export const handleGetItem: Middleware = ({
};
}

let theFormatted;
let encoded;
try {
theFormatted = serialized === null ? null : encoding.encode(serialized);
encoded = serialized === null ? null : encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -254,14 +255,14 @@ export const handleGetItem: Middleware = ({
};
}

if (theFormatted) {
if (encoded) {
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
});
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceFetched(resource)
res.end(theFormatted);
res.end(encoded);
return {
handled: true
};
@@ -298,50 +299,50 @@ export const handleDeleteItem: Middleware = ({
};
}

let response;
let existing: unknown | null;
try {
response = await resource.dataSource.delete(resourceId);
existing = await resource.dataSource.getById(resourceId);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource);
res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource);
res.end();
return {
handled: true
};
}

const throwOnNotFound = !response && backendState.throws404OnDeletingNotFound;

res.writeHead(
throwOnNotFound
? constants.HTTP_STATUS_NOT_FOUND
: constants.HTTP_STATUS_NO_CONTENT,

throwOnNotFound
? {
'Content-Language': errorLanguageCode,
// TODO provide more details
}
: {
'Content-Language': languageCode,
}
);
res.statusMessage = (
throwOnNotFound
? errorMessageCollection.statusMessages.deleteNonExistingResource(resource)
: responseBodyMessageCollection.statusMessages.resourceDeleted(resource)
);
if (!existing && backendState.throws404OnDeletingNotFound) {
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.deleteNonExistingResource(resource);
res.end();
return {
handled: true
};
}

if (throwOnNotFound) {
// TODO provide error message
try {
if (existing) {
await resource.dataSource.delete(resourceId);
}
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource);
res.end();
return {
handled: true
};
}

res.writeHead(constants.HTTP_STATUS_NO_CONTENT, {
'Content-Language': languageCode,
});
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceDeleted(resource);
res.end();
return {
handled: true
@@ -384,9 +385,9 @@ export const handlePatchItem: Middleware = ({
};
}

let existing: object | null;
let existing: unknown | null;
try {
existing = await resource.dataSource.getSingle(resourceId);
existing = await resource.dataSource.getById(resourceId);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -411,7 +412,7 @@ export const handlePatchItem: Middleware = ({

let bodyDeserialized: unknown;
try {
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
@@ -444,22 +445,21 @@ export const handlePatchItem: Middleware = ({
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const theFormatted = errorEncoding.encode(serialized);
const encoded = errorEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorMediaType,
'Content-Encoding': errorEncodingKey,
})
res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource);
res.end(theFormatted);
res.end(encoded);
return {
handled: true,
};
}

const params = bodyDeserialized as Record<string, unknown>;

let newObject: object | null;
let newObject: v.Output<typeof resource.schema> | null;
try {
newObject = await resource.dataSource.patch(resourceId, params);
} catch {
@@ -487,9 +487,9 @@ export const handlePatchItem: Middleware = ({
};
}

let theFormatted;
let encoded;
try {
theFormatted = encoding.encode(serialized);
encoded = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
@@ -507,7 +507,7 @@ export const handlePatchItem: Middleware = ({
'Content-Encoding': encodingKey,
});
res.statusMessage = responseBodyMessageCollection.statusMessages.resourcePatched(resource);
res.end(theFormatted);
res.end(encoded);
return {
handled: true
};
@@ -567,14 +567,14 @@ export const handleCreateItem: Middleware = ({
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const theFormatted = errorEncoding.encode(serialized);
const encoded = errorEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorMediaType,
'Content-Encoding': errorEncodingKey,
})
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource);
res.end(theFormatted);
res.end(encoded);
return {
handled: true,
};
@@ -593,37 +593,79 @@ export const handleCreateItem: Middleware = ({
};
}

//v.Output<typeof resource.schema>

let newId;
let params: v.Output<typeof resource.schema>;
try {
// TODO error handling for each process
const newId = await resource.newId(resource.dataSource);
const params = bodyDeserialized as Record<string, unknown>;
newId = await resource.newId(resource.dataSource);
params = bodyDeserialized as Record<string, unknown>;
params[resource.state.idAttr] = newId;
const newObject = await resource.dataSource.create(params);
let totalItemCount: number | undefined;
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToGenerateIdFromResourceDataSource(resource);
res.end();
return {
handled: true,
};
// noop
// TODO
}

let newObject;
let totalItemCount: number | undefined;
try {
newObject = await resource.dataSource.create(params);
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}`
} catch {
// noop
// TODO
}

let serialized;
try {
serialized = responseBodySerializerPair.serialize(newObject);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
const serialized = responseBodySerializerPair.serialize(newObject);
const theFormatted = encoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_CREATED, headers);
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCreated(resource);
res.end(theFormatted);
}

let encoded;
try {
encoded = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
})
res.statusMessage = `Could Not Return ${resource.state.itemName}`;
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}`
};
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
res.writeHead(constants.HTTP_STATUS_CREATED, headers);
res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCreated(resource);
res.end(encoded);
return {
handled: true
};
@@ -656,7 +698,7 @@ export const handleEmplaceItem: Middleware = ({

let bodyDeserialized: unknown;
try {
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema
bodyDeserialized = await getBody(
req,
requestBodyDeserializerPair,
@@ -694,14 +736,14 @@ export const handleEmplaceItem: Middleware = ({
`${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}`
)),
);
const theFormatted = errorEncoding.encode(serialized);
const encoded = errorEncoding.encode(serialized);
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {
...headers,
'Content-Type': errorMediaType,
'Content-Encoding': errorEncodingKey,
})
res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource);
res.end(theFormatted);
res.end(encoded);
return {
handled: true,
};
@@ -720,40 +762,73 @@ export const handleEmplaceItem: Middleware = ({
};
}

let newObject: v.Output<typeof resource.schema>;
let isCreated: boolean;
try {
// TODO error handling for each process
const params = bodyDeserialized as Record<string, unknown>;
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string);
const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params);
const serialized = responseBodySerializerPair.serialize(newObject);
const theFormatted = encoding.encode(serialized);
const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
[newObject, isCreated] = await resource.dataSource.emplace(resourceId, params);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEmplaceResource(resource);
res.end();
return {
handled: true
};
let totalItemCount: number | undefined;
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
if (isCreated) {
headers['Location'] = `${serverParams.baseUrl}/${resource.state.routeName}/${resourceId}`;
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
}
res.writeHead(isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, headers);
res.statusMessage = (
isCreated
? responseBodyMessageCollection.statusMessages.resourceCreated(resource)
: responseBodyMessageCollection.statusMessages.resourceReplaced(resource)
);
res.end(theFormatted);
}

let serialized;
try {
serialized = responseBodySerializerPair.serialize(newObject);
} catch {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.statusMessage = `Could Not Return ${resource.state.itemName}`;
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse();
res.end();
return {
handled: true,
};
}

let encoded;
try {
encoded = encoding.encode(serialized);
} catch {
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, {
'Content-Language': errorLanguageCode,
});
res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse();
res.end();
return {
handled: true,
};
}

const headers: Record<string, string> = {
'Content-Type': responseBodyMediaType,
'Content-Language': languageCode,
'Content-Encoding': encodingKey,
};
let totalItemCount: number | undefined;
if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
}
if (isCreated) {
headers['Location'] = `${serverParams.baseUrl}/${resource.state.routeName}/${resourceId}`;
if (typeof totalItemCount !== 'undefined') {
headers['X-Resource-Total-Item-Count'] = totalItemCount.toString();
}
}
res.writeHead(isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, headers);
res.statusMessage = (
isCreated
? responseBodyMessageCollection.statusMessages.resourceCreated(resource)
: responseBodyMessageCollection.statusMessages.resourceReplaced(resource)
);
res.end(encoded);
return {
handled: true
};


+ 1
- 1
src/index.ts View File

@@ -1,5 +1,5 @@
export * from './core';
export * as valibot from './validation';
export * as validation from './validation';
export * as dataSources from './data-sources';
export * as serializers from './serializers';
export * as encodings from './encodings';

+ 6
- 0
src/languages/en/index.ts View File

@@ -79,6 +79,12 @@ export const messages: MessageCollection = {
},
resourceReplaced(resource: Resource): string {
return `${resource.state.itemName} Replaced`;
},
unableToGenerateIdFromResourceDataSource(resource: Resource): string {
return `Unable To Generate ID From ${resource.state.itemName} Data Source`;
},
unableToEmplaceResource(resource: Resource): string {
return `Unable To Emplace ${resource.state.itemName}`;
}
},
bodies: {


+ 5
- 0
src/validation.ts View File

@@ -1,5 +1,6 @@
import * as v from 'valibot';
export * from 'valibot';
import { Resource } from './core';

export const datelike = () => v.transform(
v.union([
@@ -11,3 +12,7 @@ export const datelike = () => v.transform(
(value) => new Date(value).toISOString(),
v.string([v.isoTimestamp()])
);

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

+ 13
- 2
test/e2e/default.test.ts View File

@@ -6,6 +6,7 @@ import {
describe,
expect,
it,
test,
} from 'vitest';
import {
tmpdir
@@ -26,7 +27,7 @@ import {
Resource,
resource,
serializers,
valibot as v,
validation as v,
} from '../../src';
import {request, Server} from 'http';
import {constants} from 'http2';
@@ -79,7 +80,7 @@ describe('yasumi', () => {
v.never()
))
.name('Piano')
.id('id', {
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
@@ -204,6 +205,7 @@ describe('yasumi', () => {

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
// TODO all responses should have serialized ids
const req = request(
{
host: HOST,
@@ -660,4 +662,13 @@ describe('yasumi', () => {
});
});
});

// https://github.com/mayajs/maya/blob/main/test/index.test.ts
//
// peak unit test
describe("Contribute to see a unit test", () => {
test("should have a unit test", () => {
expect("Is this a unit test?").not.toEqual("Yes this is a unit test.");
});
});
});

Loading…
Cancel
Save