Browse Source

Implement create operation

Get create operation logic from old arch.
refactor/new-arch
TheoryOfNekomata 6 months ago
parent
commit
51348cf438
11 changed files with 415 additions and 48 deletions
  1. +10
    -1
      packages/core/src/backend/common.ts
  2. +9
    -1
      packages/core/src/common/endpoint.ts
  3. +28
    -28
      packages/core/src/common/language.ts
  4. +1
    -0
      packages/core/src/common/queries/index.ts
  5. +235
    -0
      packages/core/src/common/queries/parsing.ts
  6. +22
    -0
      packages/extenders/http/src/backend/core.ts
  7. +2
    -0
      packages/extenders/http/src/client/core.ts
  8. +1
    -0
      packages/recipes/resource/src/core.ts
  9. +76
    -2
      packages/recipes/resource/src/implementation/create.ts
  10. +21
    -14
      packages/recipes/resource/src/implementation/fetch.ts
  11. +10
    -2
      packages/recipes/resource/src/response.ts

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

@@ -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<App extends BaseApp> {
@@ -12,6 +19,8 @@ export interface ImplementationContext {
params: Record<string, unknown>;
query?: URLSearchParams;
dataSource?: DataSource;
language: Language;
setLocation(location: string): void;
}

export type ImplementationFunction = (params: ImplementationContext) => Promise<Response | void>;


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

@@ -92,6 +92,7 @@ export interface Endpoint<
schema: Schema;
params: Set<string>;
operations: Set<string>;
metadata: Map<string, unknown>;
can<OpName extends string = string, OpValue extends OpValueType = OpValueType>(
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<IdAttr extends string = string>(id: IdAttr): this;
}

export type GetEndpointParams<T extends Endpoint> = T extends Endpoint<any, infer R> ? (
@@ -132,6 +134,7 @@ class EndpointInstance<
readonly operations: Set<string>;
readonly params: Set<string>;
readonly schema: Params['schema'];
readonly metadata: Map<string, unknown>;

constructor(params: Params) {
this.name = params.name;
@@ -139,6 +142,7 @@ class EndpointInstance<
this.operations = new Set<string>();
this.params = new Set<string>();
this.dataSource = params.dataSource;
this.metadata = new Map<string, unknown>();
}

can<OpName extends string = string, OpValue extends OpValueType = OpValueType>(
@@ -159,7 +163,11 @@ class EndpointInstance<
name: ParamName
) {
this.params.add(name);
return this;
}

id<IdAttr extends string = string>(id: IdAttr) {
this.metadata.set('idAttr', id);
return this;
}
}


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

@@ -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.',


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

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

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

@@ -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<string, ProcessEntry>) => {
const defaultCoerceValues = {
type: 'string'
} as ProcessEntry;
const coerceValues = processEntriesMap?.[lhs] ?? defaultCoerceValues;

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

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

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

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

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

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

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

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

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

return false;
};

export const 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} <rhs>`);
}

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

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

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

export const toUrlSearchParams = (q: QueryAndGrouping) => (
new URLSearchParams(
q.expressions.flatMap((ex) => (
ex.expressions.map((ex2) => serializeExpression(ex2))
)) as [string, string][]
)
);

+ 22
- 0
packages/extenders/http/src/backend/core.ts View File

@@ -170,14 +170,36 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
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();


+ 2
- 0
packages/extenders/http/src/client/core.ts View File

@@ -36,6 +36,8 @@ class ClientInstance<App extends BaseApp> implements Client<App> {
}

async connect(params: ServiceParams): Promise<ClientConnection> {
// 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,


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

@@ -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) => {


+ 76
- 2
packages/recipes/resource/src/implementation/create.ts View File

@@ -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<typeof endpoint.schema>;
try {
resourceNewId = await newId();
params = { ...body as Record<string, unknown> };
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<string, string> = {};

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

+ 21
- 14
packages/recipes/resource/src/implementation/fetch.ts View File

@@ -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,
});
};

+ 10
- 2
packages/recipes/resource/src/response.ts View File

@@ -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) {}

Loading…
Cancel
Save