From 51348cf438880015cff62358ce4c32615987e0bd Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sat, 8 Jun 2024 16:45:27 +0800 Subject: [PATCH] Implement create operation Get create operation logic from old arch. --- packages/core/src/backend/common.ts | 11 +- packages/core/src/common/endpoint.ts | 10 +- packages/core/src/common/language.ts | 56 ++--- packages/core/src/common/queries/index.ts | 1 + packages/core/src/common/queries/parsing.ts | 235 ++++++++++++++++++ packages/extenders/http/src/backend/core.ts | 22 ++ packages/extenders/http/src/client/core.ts | 2 + packages/recipes/resource/src/core.ts | 1 + .../resource/src/implementation/create.ts | 78 +++++- .../resource/src/implementation/fetch.ts | 35 +-- packages/recipes/resource/src/response.ts | 12 +- 11 files changed, 415 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/common/queries/parsing.ts diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 38b6dec..c9b44c6 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -1,4 +1,11 @@ -import {App as BaseApp, AppOperations, BaseAppState, Endpoint, Response} from '../common'; +import { + App as BaseApp, + AppOperations, + BaseAppState, + Endpoint, + Language, + Response, +} from '../common'; import {DataSource} from './data-source'; interface BackendParams { @@ -12,6 +19,8 @@ export interface ImplementationContext { params: Record; query?: URLSearchParams; dataSource?: DataSource; + language: Language; + setLocation(location: string): void; } export type ImplementationFunction = (params: ImplementationContext) => Promise; diff --git a/packages/core/src/common/endpoint.ts b/packages/core/src/common/endpoint.ts index 2d439fc..0a59d70 100644 --- a/packages/core/src/common/endpoint.ts +++ b/packages/core/src/common/endpoint.ts @@ -92,6 +92,7 @@ export interface Endpoint< schema: Schema; params: Set; operations: Set; + metadata: Map; can( op: OpName, value?: OpValue @@ -110,7 +111,8 @@ export interface Endpoint< operations: State['operations']; params: State['params'] extends string[] ? readonly [...State['params'], ParamName]: [ParamName]; } - > + >; + id(id: IdAttr): this; } export type GetEndpointParams = T extends Endpoint ? ( @@ -132,6 +134,7 @@ class EndpointInstance< readonly operations: Set; readonly params: Set; readonly schema: Params['schema']; + readonly metadata: Map; constructor(params: Params) { this.name = params.name; @@ -139,6 +142,7 @@ class EndpointInstance< this.operations = new Set(); this.params = new Set(); this.dataSource = params.dataSource; + this.metadata = new Map(); } can( @@ -159,7 +163,11 @@ class EndpointInstance< name: ParamName ) { this.params.add(name); + return this; + } + id(id: IdAttr) { + this.metadata.set('idAttr', id); return this; } } diff --git a/packages/core/src/common/language.ts b/packages/core/src/common/language.ts index ad4f73e..130c1b9 100644 --- a/packages/core/src/common/language.ts +++ b/packages/core/src/common/language.ts @@ -15,7 +15,7 @@ export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [ 'badRequest', 'deleteNonExistingResource', 'unableToCreateResource', - 'unableToBindResourceDataSource', + 'dataSourceMethodNotImplemented', 'unableToGenerateIdFromResourceDataSource', 'unableToAssignIdFromResourceDataSource', 'unableToEmplaceResource', @@ -67,41 +67,41 @@ export const FALLBACK_LANGUAGE = { statusMessages: { unableToSerializeResponse: 'Unable To Serialize Response', unableToEncodeResponse: 'Unable To Encode Response', - unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source', - unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', - unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', - unableToFetchResource: 'Unable To Fetch $RESOURCE', - unableToDeleteResource: 'Unable To Delete $RESOURCE', + dataSourceMethodNotImplemented: 'Unable To Bind Resource Data Source', + unableToInitializeResourceDataSource: 'Unable To Initialize Resource Data Source', + unableToFetchResourceCollection: 'Unable To Fetch Resource Collection', + unableToFetchResource: 'Unable To Fetch Resource', + unableToDeleteResource: 'Unable To Delete Resource', languageNotAcceptable: 'Language Not Acceptable', characterSetNotAcceptable: 'Character Set Not Acceptable', - unableToDeserializeResource: 'Unable To Deserialize $RESOURCE', - unableToDecodeResource: 'Unable To Decode $RESOURCE', + unableToDeserializeResource: 'Unable To Deserialize Resource', + unableToDecodeResource: 'Unable To Decode Resource', mediaTypeNotAcceptable: 'Media Type Not Acceptable', methodNotAllowed: 'Method Not Allowed', urlNotFound: 'URL Not Found', badRequest: 'Bad Request', ok: 'OK', provideOptions: 'Provide Options', - resourceCollectionFetched: '$RESOURCE Collection Fetched', - resourceCollectionQueried: '$RESOURCE Collection Queried', - resourceFetched: '$RESOURCE Fetched', - resourceNotFound: '$RESOURCE Not Found', - deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', - resourceDeleted: '$RESOURCE Deleted', + resourceCollectionFetched: 'Resource Collection Fetched', + resourceCollectionQueried: 'Resource Collection Queried', + resourceFetched: 'Resource Fetched', + resourceNotFound: 'Resource Not Found', + deleteNonExistingResource: 'Delete Non-Existing Resource', + resourceDeleted: 'Resource Deleted', unableToDeserializeRequest: 'Unable To Deserialize Request', - patchNonExistingResource: 'Patch Non-Existing $RESOURCE', - unableToPatchResource: 'Unable To Patch $RESOURCE', - invalidResourcePatch: 'Invalid $RESOURCE Patch', - invalidResourcePatchType: 'Invalid $RESOURCE Patch Type', - invalidResource: 'Invalid $RESOURCE', - resourcePatched: '$RESOURCE Patched', - resourceCreated: '$RESOURCE Created', - resourceReplaced: '$RESOURCE Replaced', - unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', - unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source', - unableToEmplaceResource: 'Unable To Emplace $RESOURCE', - resourceIdNotGiven: '$RESOURCE ID Not Given', - unableToCreateResource: 'Unable To Create $RESOURCE', + patchNonExistingResource: 'Patch Non-Existing Resource', + unableToPatchResource: 'Unable To Patch Resource', + invalidResourcePatch: 'Invalid Resource Patch', + invalidResourcePatchType: 'Invalid Resource Patch Type', + invalidResource: 'Invalid Resource', + resourcePatched: 'Resource Patched', + resourceCreated: 'Resource Created', + resourceReplaced: 'Resource Replaced', + unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From Resource Data Source', + unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From Resource Data Source', + unableToEmplaceResource: 'Unable To Emplace Resource', + resourceIdNotGiven: 'Resource ID Not Given', + unableToCreateResource: 'Unable To Create Resource', notImplemented: 'Not Implemented', internalServerError: 'Internal Server Error', }, @@ -211,7 +211,7 @@ export const FALLBACK_LANGUAGE = { 'Contact the administrator regarding missing configuration or unavailability of dependencies.', ], ], - unableToBindResourceDataSource: [ + dataSourceMethodNotImplemented: [ 'The resource could not be associated from the data source.', [ 'Try the request again at a later time.', diff --git a/packages/core/src/common/queries/index.ts b/packages/core/src/common/queries/index.ts index d0b9323..ef32e3e 100644 --- a/packages/core/src/common/queries/index.ts +++ b/packages/core/src/common/queries/index.ts @@ -1 +1,2 @@ export * from './common'; +export * from './parsing'; diff --git a/packages/core/src/common/queries/parsing.ts b/packages/core/src/common/queries/parsing.ts new file mode 100644 index 0000000..91c35ce --- /dev/null +++ b/packages/core/src/common/queries/parsing.ts @@ -0,0 +1,235 @@ +import { + QueryAndGrouping, + QueryAnyExpression, + QueryOrGrouping, +} from './common'; + +export class QueryParsingError extends Error {} + +export class DeserializeError extends QueryParsingError {} + +export class SerializeError extends QueryParsingError {} + +interface ProcessEntryBase { + type: string; +} + +const AVAILABLE_MATCH_TYPES = [ + 'startsWith', + 'endsWith', + 'includes', + 'regexp', +] as const; + +type MatchType = typeof AVAILABLE_MATCH_TYPES[number]; + +interface ProcessEntryString extends ProcessEntryBase { + type: 'string'; + matchType?: MatchType; + caseInsensitiveMatch?: boolean; +} + +interface ProcessEntryNumber extends ProcessEntryBase { + type: 'number'; + decimal?: boolean; +} + +interface ProcessEntryBoolean extends ProcessEntryBase { + type: 'boolean'; + truthyStrings?: string[]; +} + +type ProcessEntry = ProcessEntryString | ProcessEntryNumber | ProcessEntryBoolean; + +export const name = 'application/x-www-form-urlencoded' as const; + +class DeserializeInvalidFormatError extends DeserializeError {} + +const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record) => { + const defaultCoerceValues = { + type: 'string' + } as ProcessEntry; + const coerceValues = processEntriesMap?.[lhs] ?? defaultCoerceValues; + + if (coerceValues?.type === 'number') { + return { + lhs, + operator: '=', + rhs: Number(rhs) + } as QueryAnyExpression; + } + + if (coerceValues?.type === 'boolean') { + const truthyStrings = [ + ...(coerceValues.truthyStrings ?? []).map((s) => s.trim().toLowerCase()), + 'true', + ]; + + return { + lhs, + operator: '=', + rhs: truthyStrings.includes(rhs) + } as QueryAnyExpression; + } + + if (coerceValues?.type === 'string') { + switch (coerceValues?.matchType) { + case 'startsWith': { + return { + lhs, + operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', + rhs: `%${rhs}`, + } as QueryAnyExpression; + } + case 'endsWith': { + return { + lhs, + operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', + rhs: `${rhs}%`, + } as QueryAnyExpression; + } + case 'includes': { + return { + lhs, + operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', + rhs: `%${rhs}%`, + } as QueryAnyExpression; + } + case 'regexp': { + return { + lhs, + operator: 'REGEXP', + rhs: new RegExp(rhs, coerceValues.caseInsensitiveMatch ? 'i' : ''), + } as QueryAnyExpression; + } + default: + break; + } + + return { + lhs, + operator: '=', + rhs, + } as QueryAnyExpression; + } + + const unknownCoerceValues = coerceValues as unknown as Record; + throw new DeserializeInvalidFormatError(`Invalid coercion type: ${unknownCoerceValues.type}`); + // this will be sent to the data source, e.g., the SQL query + // we can also make this function act as a "sanitizer" +} + +interface DeserializeOptions { + processEntries?: Record; +} + +const doesGroupHaveExpression = (ex2: QueryAnyExpression, key: string) => { + if ('operator' in ex2) { + return ex2.lhs === key; + } + + if ('name' in ex2) { + return ex2.name === key; + } + + return false; +}; + +export const fromUrlSearchParams = (q: URLSearchParams, options = {} as DeserializeOptions) => ( + Array.from(q.entries()).reduce( + (queries, [key, value]) => { + const defaultOr = { + type: 'or' as const, + expressions: [], + } as QueryOrGrouping; + const existingOr = queries.expressions.find((ex) => ( + ex.expressions.some((ex2) => doesGroupHaveExpression(ex2, key)) + )) ?? defaultOr; + const existingLhs = existingOr.expressions.find((ex) => doesGroupHaveExpression(ex, key)); + const newExpression = normalizeRhs(key, value, options.processEntries); + + if (typeof existingLhs === 'undefined') { + return { + ...queries, + expressions: [ + ...queries.expressions, + { + type: 'or' as const, + expressions: [ + newExpression, + ], + }, + ], + }; + } + + return { + ...queries, + expressions: queries.expressions.map((ex) => ( + ex.expressions.some((ex2) => !doesGroupHaveExpression(ex2, key)) + ? ex + : { + ...existingOr, + expressions: [ + ...(existingOr.expressions ?? []), + newExpression, + ], + } + )), + }; + }, + { + type: 'and' as const, + expressions: [], + } as QueryAndGrouping + ) +); + +class SerializeInvalidExpressionError extends SerializeError {} + +const serializeExpression = (ex2: QueryAnyExpression) => { + if ('name' in ex2) { + return [ex2.name, `(${ex2.args.map((s) => s.toString()).join(',')})`]; + } + + if (ex2.rhs instanceof RegExp) { + if (ex2.operator !== 'REGEXP') { + throw new SerializeInvalidExpressionError(`Invalid rhs given for operator: ${ex2.lhs} ${ex2.operator} `); + } + + return [ex2.lhs, ex2.rhs.toString()]; + } + + switch (typeof ex2.rhs) { + case 'string': { + switch (ex2.operator) { + case 'ILIKE': + case 'LIKE': + return [ex2.lhs, ex2.rhs.replace(/^%+/, '').replace(/%+$/, '')]; + case '=': + return [ex2.lhs, ex2.rhs]; + default: + break; + } + throw new SerializeInvalidExpressionError(`Invalid operator given for lhs: ${ex2.lhs} ${ex2.rhs}`); + } + case 'number': { + return [ex2.lhs, ex2.rhs.toString()]; + } + case 'boolean': { + return [ex2.lhs, ex2.rhs ? 'true' : 'false']; + } + default: + break; + } + + throw new SerializeInvalidExpressionError(`Unknown type for rhs: ${ex2.lhs} ${ex2.operator} `); +}; + +export const toUrlSearchParams = (q: QueryAndGrouping) => ( + new URLSearchParams( + q.expressions.flatMap((ex) => ( + ex.expressions.map((ex2) => serializeExpression(ex2)) + )) as [string, string][] + ) +); diff --git a/packages/extenders/http/src/backend/core.ts b/packages/extenders/http/src/backend/core.ts index 1e51399..30d9708 100644 --- a/packages/extenders/http/src/backend/core.ts +++ b/packages/extenders/http/src/backend/core.ts @@ -170,14 +170,36 @@ class ServerInstance implements Server { const bodyDecoded = decoder(bodyBuffer, requestBodyParams); body = requestBodyMediaType.deserialize(bodyDecoded, requestBodyParams); } + + const isCqrs = false; + let hasSentResponse = false; const responseSpec = await implementation({ endpoint, body, params: endpointParams ?? {}, query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, dataSource: this.backend.dataSource, + language: language ?? fallbackLanguage, + setLocation: (location: string) => { + // TODO set base path + responseHeaders['Location'] = `${location}`; + if (!isCqrs) { + return; + } + + res.writeHead(statusCodes.HTTP_STATUS_SEE_OTHER, { + Location: location, + }); + + res.end(); + hasSentResponse = true; + }, }); + if (hasSentResponse) { + return; + } + if (typeof responseSpec === 'undefined') { res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, errorHeaders); res.end(); diff --git a/packages/extenders/http/src/client/core.ts b/packages/extenders/http/src/client/core.ts index b85ae4a..341e071 100644 --- a/packages/extenders/http/src/client/core.ts +++ b/packages/extenders/http/src/client/core.ts @@ -36,6 +36,8 @@ class ClientInstance implements Client { } async connect(params: ServiceParams): Promise { + // we should be explicit with connection params in order not to give a false impression to the user they are "connected" + // to the target server const connection = { host: params.host ?? DEFAULT_HOST, port: params.port ?? DEFAULT_PORT, diff --git a/packages/recipes/resource/src/core.ts b/packages/recipes/resource/src/core.ts index ae7251b..c983dd1 100644 --- a/packages/recipes/resource/src/core.ts +++ b/packages/recipes/resource/src/core.ts @@ -11,6 +11,7 @@ import * as deleteOperation from './implementation/delete'; interface AddResourceRecipeParams { endpointName: string; dataSource?: DataSource; + // TODO specify the available operations to implement } export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a) => { diff --git a/packages/recipes/resource/src/implementation/create.ts b/packages/recipes/resource/src/implementation/create.ts index ccaa47a..9efd9e8 100644 --- a/packages/recipes/resource/src/implementation/create.ts +++ b/packages/recipes/resource/src/implementation/create.ts @@ -1,5 +1,10 @@ -import {ImplementationFunction} from '@modal-sh/yasumi/backend'; -import {operation as defineOperation} from '@modal-sh/yasumi'; +import {DataSource, ImplementationFunction} from '@modal-sh/yasumi/backend'; +import {operation as defineOperation, validation as v} from '@modal-sh/yasumi'; +import { + DataSourceMethodNotImplementedResponseError, IncompleteEndpointMetadataResponseError, + ResourceCreatedResponse, + UnableToAssignIdFromDataSourceResponseError, UnableToCreateResourceResponseError, +} from '../response'; export const name = 'create' as const; @@ -11,5 +16,74 @@ export const operation = defineOperation({ }); export const implementation: ImplementationFunction = async (ctx) => { + const { setLocation, language, endpoint, dataSource, body } = ctx; + const effectiveDataSource: DataSource = endpoint.dataSource ?? dataSource ?? {} as DataSource; + const { newId, create, getTotalCount } = effectiveDataSource; + const idAttr = endpoint.metadata.get('idAttr'); // todo use metadata + if (typeof idAttr !== 'string') { + throw new IncompleteEndpointMetadataResponseError( + // TODO get the status message + ); + } + + if (typeof newId === 'undefined') { + throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); + } + + if (typeof create === 'undefined') { + throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); + } + + let resourceNewId; + let params: v.Output; + try { + resourceNewId = await newId(); + params = { ...body as Record }; + params[idAttr] = resourceNewId; + } catch (cause) { + throw new UnableToAssignIdFromDataSourceResponseError( + language.statusMessages.unableToAssignIdFromResourceDataSource, + { + cause, + } + ); + } + + setLocation(`/${endpoint.name}/${resourceNewId}`); + + let newObject; + let totalItemCount: number | undefined; + // TODO put this in ImplementationContext argument + const showTotalItemCountOnCreateItem = false; + try { + if ( + showTotalItemCountOnCreateItem + && typeof getTotalCount === 'function' + ) { + totalItemCount = await getTotalCount(); + totalItemCount += 1; + } + newObject = await create(params); + } catch (cause) { + throw new UnableToCreateResourceResponseError( + language.statusMessages.unableToCreateResource, + { + cause, + } + ); + } + + const headers: Record = {}; + + // no need to set location here + if (typeof totalItemCount !== 'undefined') { + headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); + } + + return new ResourceCreatedResponse({ + statusMessage: language.statusMessages.resourceCreated, + body: newObject, + headers, + }); }; diff --git a/packages/recipes/resource/src/implementation/fetch.ts b/packages/recipes/resource/src/implementation/fetch.ts index e955a3b..bc99f4e 100644 --- a/packages/recipes/resource/src/implementation/fetch.ts +++ b/packages/recipes/resource/src/implementation/fetch.ts @@ -1,8 +1,8 @@ import {ImplementationFunction, DataSource} from '@modal-sh/yasumi/backend'; -import {operation as defineOperation} from '@modal-sh/yasumi'; +import {operation as defineOperation, fromUrlSearchParams} from '@modal-sh/yasumi'; import { - DataSourceNotFoundResponseError, - ItemNotFoundReponseError, + DataSourceMethodNotImplementedResponseError, + ResourceNotFoundResponseError, ResourceCollectionFetchedResponse, ResourceItemFetchedResponse, } from '../response'; @@ -17,35 +17,42 @@ export const operation = defineOperation({ }); export const implementation: ImplementationFunction = async (ctx) => { - const dataSource: DataSource = ctx.endpoint.dataSource ?? ctx.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; + const { + params, + endpoint, + dataSource, + language, + query, + } = ctx; + // need to genericise the response here so that we don't depend on the HTTP responses. + const { resourceId } = params; + const effectiveDataSource: DataSource = endpoint.dataSource ?? dataSource ?? {} as DataSource; + const { getById, getMultiple } = effectiveDataSource; if (typeof resourceId === 'undefined') { if (typeof getMultiple === 'undefined') { - throw new DataSourceNotFoundResponseError(); + throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); } - // TODO add query here - const items = await getMultiple(); + const dataSourceQuery = typeof query !== 'undefined' ? fromUrlSearchParams(query) : undefined; + const items = await getMultiple(dataSourceQuery); return new ResourceCollectionFetchedResponse({ - statusMessage: 'Resource Collection Fetched', + statusMessage: language.statusMessages.resourceCollectionFetched, body: items, }); } if (typeof getById === 'undefined') { - throw new DataSourceNotFoundResponseError(); + throw new DataSourceMethodNotImplementedResponseError(language.statusMessages.dataSourceMethodNotImplemented); } const item = await getById(resourceId); if (!item) { - throw new ItemNotFoundReponseError(); + throw new ResourceNotFoundResponseError(language.statusMessages.resourceNotFound); } return new ResourceItemFetchedResponse({ - statusMessage: 'Resource Item Fetched', + statusMessage: language.statusMessages.resourceFetched, body: item, }); }; diff --git a/packages/recipes/resource/src/response.ts b/packages/recipes/resource/src/response.ts index d0c971f..f045907 100644 --- a/packages/recipes/resource/src/response.ts +++ b/packages/recipes/resource/src/response.ts @@ -4,6 +4,14 @@ export class ResourceItemFetchedResponse extends HttpResponse(statusCodes.HTTP_S export class ResourceCollectionFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} -export class DataSourceNotFoundResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} +export class DataSourceMethodNotImplementedResponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED) {} -export class ItemNotFoundReponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_FOUND) {} +export class ResourceNotFoundResponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_FOUND) {} + +export class ResourceCreatedResponse extends HttpResponse(statusCodes.HTTP_STATUS_CREATED) {} + +export class UnableToAssignIdFromDataSourceResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} + +export class UnableToCreateResourceResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} + +export class IncompleteEndpointMetadataResponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED) {}