diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 227d069..942e9d6 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -1,5 +1,12 @@ import {BaseSchema} from 'valibot'; -import {ApplicationState, ContentNegotiation, Language, LanguageStatusMessageMap, Resource} from '../common'; +import { + ApplicationState, + BaseResourceType, + ContentNegotiation, + Language, + LanguageStatusMessageMap, + Resource, +} from '../common'; import {DataSource} from './data-source'; export interface BackendState { @@ -45,8 +52,8 @@ export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | ' export interface AllowedMiddlewareSpecification { method: Method; middleware: Middleware; - constructBodySchema?: (resource: Resource, resourceId?: string) => BaseSchema; - allowed: (resource: Resource) => boolean; + constructBodySchema?: (resource: Resource, resourceId?: string) => BaseSchema; + allowed: (resource: Resource) => boolean; } export interface Response { diff --git a/packages/core/src/backend/servers/http/core.ts b/packages/core/src/backend/servers/http/core.ts index 1509784..b7c506a 100644 --- a/packages/core/src/backend/servers/http/core.ts +++ b/packages/core/src/backend/servers/http/core.ts @@ -9,7 +9,14 @@ import { RequestContext, RequestDecorator, Response, } from '../../common'; -import {CanPatchSpec, DELTA_SCHEMA, PATCH_CONTENT_MAP_TYPE, PatchContentType, Resource} from '../../../common'; +import { + BaseResourceType, + CanPatchSpec, + DELTA_SCHEMA, + PATCH_CONTENT_MAP_TYPE, + PatchContentType, + Resource, +} from '../../../common'; import { handleGetRoot, handleOptions, } from './handlers/default'; @@ -49,11 +56,11 @@ declare module '../../common' { } } -const constructPostSchema = (resource: Resource) => { +const constructPostSchema = (resource: Resource) => { return resource.schema; }; -const constructPutSchema = (resource: Resource, mainResourceId?: string) => { +const constructPutSchema = (resource: Resource, mainResourceId?: string) => { if (typeof mainResourceId === 'undefined') { return resource.schema; } @@ -77,7 +84,7 @@ const constructPutSchema = (resource: Resource, mainR ); }; -const constructPatchSchema = (resource: Resource) => { +const constructPatchSchema = (resource: Resource) => { const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; if (resource.schema.type !== 'object') { diff --git a/packages/core/src/common/app.ts b/packages/core/src/common/app.ts index beecd69..94beaf1 100644 --- a/packages/core/src/common/app.ts +++ b/packages/core/src/common/app.ts @@ -1,4 +1,4 @@ -import {Resource} from './resource'; +import {BaseResourceType, Resource} from './resource'; import {FALLBACK_LANGUAGE, Language} from './language'; import {FALLBACK_MEDIA_TYPE, MediaType, PATCH_CONTENT_TYPES} from './media-type'; import {Charset, FALLBACK_CHARSET} from './charset'; @@ -35,12 +35,8 @@ export interface Application< charset(charset: Charset): Application< Resources, MediaTypes, [...Charsets, Charset], Languages >; - resource< - Schema extends v.BaseSchema, - CurrentItemName extends string = string, - CurrentRouteName extends string = string - >(resRaw: Resource): Application< - [...Resources, Resource], MediaTypes, Charsets, Languages + resource(resRaw: Resource): Application< + [...Resources, Resource], MediaTypes, Charsets, Languages >; createBackend(params: Omit): Backend; createClient(params: Omit): ClientBuilder; @@ -84,7 +80,7 @@ export const application = (appParams: ApplicationParams): Application => { appState.languages.set(language.name, language); return this; }, - resource(resRaw: Resource) { + resource(resRaw: Resource) { appState.resources.add(resRaw); return this; }, diff --git a/packages/core/src/common/resource.ts b/packages/core/src/common/resource.ts index ddceabe..e5b4fe5 100644 --- a/packages/core/src/common/resource.ts +++ b/packages/core/src/common/resource.ts @@ -31,36 +31,38 @@ export interface ResourceState< type CanPatch = boolean | Partial | CanPatchSpec[]; -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 -> { - schema: Schema; - state: ResourceState; - name(n: NewName): Resource; - route(n: NewRouteName): Resource; +export interface BaseResourceType { + schema: v.BaseSchema; + name: string; + routeName: string; + idAttr: string; + idSchema: v.BaseSchema; + createdAtAttr: string; + updatedAtAttr: string; +} + +export interface Resource { + schema: ResourceType['schema']; + state: ResourceState; + name(n: NewName): Resource; + route(n: NewRouteName): Resource; canFetchCollection(b?: boolean): this; canFetchItem(b?: boolean): this; canCreate(b?: boolean): this; canPatch(b?: CanPatch): this; canEmplace(b?: boolean): this; canDelete(b?: boolean): this; - relatedTo(resource: Resource): this; + relatedTo(resource: Resource): this; dataSource?: DataSource; - id( + id( newIdAttr: NewIdAttr, params: ResourceIdConfig - ): Resource; + ): Resource; + createdAt(n: NewCreatedAtAttr): Resource; + updatedAt(n: NewUpdatedAtAttr): Resource; } -export const resource = < - Schema extends v.BaseSchema, - CurrentName extends string = string, - CurrentRouteName extends string = string ->(schema: Schema): Resource => { +export const resource = (schema: ResourceType['schema']): Resource => { const resourceState = { shared: new Map(), relationships: new Set(), @@ -73,13 +75,11 @@ export const resource = < }, canEmplace: false, canDelete: false, - } as ResourceState; + } as ResourceState; return { - get state(): ResourceState { - return Object.freeze({ - ...resourceState - }) as unknown as ResourceState; + get state(): ResourceState { + return Object.freeze(resourceState); }, canFetchCollection(b = true) { resourceState.canFetchCollection = b; @@ -135,13 +135,13 @@ export const resource = < resourceState.shared.set('fullText', fullTextAttrs); return this; }, - name(n: NewName) { + name(n: NewName) { resourceState.itemName = n; - return this as Resource; + return this; }, - route(n: NewRouteName) { + route(n: NewRouteName) { resourceState.routeName = n; - return this as Resource; + return this; }, get itemName() { return resourceState.itemName; @@ -152,11 +152,19 @@ export const resource = < get schema() { return schema; }, - relatedTo(resource: Resource) { + relatedTo(resource: Resource) { resourceState.relationships.add(resource); return this; }, - } as Resource; + createdAt(n: NewCreatedAtAttr) { + resourceState.shared.set('createdAtAttr', n); + return this; + }, + updatedAt(n: NewUpdatedAtAttr) { + resourceState.shared.set('updatedAtAttr', n); + return this; + }, + } as Resource; }; export type ResourceType = v.Output; diff --git a/packages/data-sources/file-jsonl/src/index.ts b/packages/data-sources/file-jsonl/src/index.ts index eefadd4..66973ad 100644 --- a/packages/data-sources/file-jsonl/src/index.ts +++ b/packages/data-sources/file-jsonl/src/index.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; -import { Resource, validation as v } from '@modal-sh/yasumi'; +import { Resource, validation as v, BaseResourceType } from '@modal-sh/yasumi'; import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend'; import assert from 'assert'; @@ -16,7 +16,11 @@ export class JsonLinesDataSource< > implements DataSource { private path?: string; - private resource?: Resource; + private resource?: Resource; data: Data[] = []; @@ -24,15 +28,25 @@ export class JsonLinesDataSource< // noop } - prepareResource(resource: Resource) { + prepareResource(resource: Resource) { this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); resource.dataSource = resource.dataSource ?? this; const originalResourceId = resource.id; - resource.id = (newIdAttr: NewIdAttr, params: ResourceIdConfig) => { + resource.id = (newIdAttr: NewIdAttr, params: ResourceIdConfig) => { originalResourceId(newIdAttr, params); - return resource as Resource; + return resource as Resource; }; - this.resource = resource; + this.resource = resource as any; } async initialize() { @@ -94,12 +108,28 @@ export class JsonLinesDataSource< const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig | undefined; assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); + let theId: any; + const { [idAttr]: dataId, ...etcData } = data as Record; + if (typeof dataId !== 'undefined') { + theId = idConfig.deserialize((data as Record)[idAttr]); + } else { + const newId = await this.newId(); + theId = idConfig.deserialize(newId); + } const newData = { - ...data + [idAttr]: theId, + ...etcData } as Record; - if (idAttr in newData) { - newData[idAttr] = idConfig.deserialize(newData[idAttr] as string); + const now = Date.now(); // TODO how to serialize dates + const createdAt = this.resource.state.shared.get('createdAtAttr'); + if (typeof createdAt === 'string') { + newData[createdAt] = now; + } + + const updatedAt = this.resource.state.shared.get('updatedAtAttr'); + if (typeof updatedAt === 'string') { + newData[updatedAt] = now; } const newCollection = [ @@ -109,7 +139,7 @@ export class JsonLinesDataSource< await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n')); - return data as Data; + return newData as Data; } async delete(idSerialized: string) { @@ -148,9 +178,20 @@ export class JsonLinesDataSource< const dataToEmplace = { [idAttr]: id, ...data, - } as Data; + } as Record; if (existing) { + const createdAt = this.resource.state.shared.get('createdAtAttr'); + if (typeof createdAt === 'string') { + dataToEmplace[createdAt] = (existing as Record)[createdAt]; + } + + const now = Date.now(); // TODO how to serialize dates + const updatedAt = this.resource.state.shared.get('updatedAtAttr'); + if (typeof updatedAt === 'string') { + dataToEmplace[updatedAt] = now; + } + const newData = this.data.map((d) => { if ((d as any)[idAttr] === id) { return dataToEmplace; @@ -164,7 +205,7 @@ export class JsonLinesDataSource< return [dataToEmplace, false] as [Data, boolean]; } - const newData = await this.create(dataToEmplace); + const newData = await this.create(dataToEmplace as Data); return [newData, true] as [Data, boolean]; } @@ -186,6 +227,17 @@ export class JsonLinesDataSource< const newItem = { ...existing, ...data, + } as Record; + + const createdAt = this.resource.state.shared.get('createdAtAttr'); + if (typeof createdAt === 'string') { + newItem[createdAt] = (existing as Record)[createdAt]; + } + + const now = Date.now(); // TODO how to serialize dates + const updatedAt = this.resource.state.shared.get('updatedAtAttr'); + if (typeof updatedAt === 'string') { + newItem[updatedAt] = now; } const id = idConfig.deserialize(idSerialized); diff --git a/packages/data-sources/file-jsonl/test/index.test.ts b/packages/data-sources/file-jsonl/test/index.test.ts index 1b3c126..1b861d6 100644 --- a/packages/data-sources/file-jsonl/test/index.test.ts +++ b/packages/data-sources/file-jsonl/test/index.test.ts @@ -1,7 +1,7 @@ import {describe, it, expect, vi, Mock, beforeAll, beforeEach} from 'vitest'; import { readFile, writeFile } from 'fs/promises'; import { JsonLinesDataSource } from '../src'; -import { resource, validation as v } from '@modal-sh/yasumi'; +import { resource, validation as v, BaseResourceType } from '@modal-sh/yasumi'; import {DataSource} from '@modal-sh/yasumi/dist/types/backend'; vi.mock('fs/promises'); @@ -51,7 +51,7 @@ describe('methods', () => { generationStrategy: mockGenerationStrategy, schema: v.any(), serialize: (id) => id.toString(), - deserialize: (id) => Number(id.toString()), + deserialize: (id) => Number(id?.toString() ?? 0), }); ds = new JsonLinesDataSource(); ds.prepareResource(r); @@ -133,7 +133,10 @@ describe('methods', () => { expect.any(String), toJsonl([ ...dummyItems, - data + { + id: 0, + ...data, + } ]) ); expect(newItem).toEqual(data); diff --git a/packages/examples/cms-web-api/bruno/Create Post with ID.bru b/packages/examples/cms-web-api/bruno/Create Post with ID.bru new file mode 100644 index 0000000..9c5c5b8 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Create Post with ID.bru @@ -0,0 +1,19 @@ +meta { + name: Create Post with ID + type: http + seq: 7 +} + +put { + url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + body: json + auth: none +} + +body:json { + { + "title": "Emplaced Post", + "content": "Created post at ID", + "id": "5fac64d6-d261-42bb-a67b-bc7e1955a7e2" + } +} diff --git a/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru b/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru index 7856344..09415ec 100644 --- a/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru +++ b/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru @@ -5,7 +5,7 @@ meta { } patch { - url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc body: json auth: none } diff --git a/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru b/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru index 8e68d99..9ca2876 100644 --- a/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru +++ b/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru @@ -5,7 +5,7 @@ meta { } patch { - url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc body: json auth: none } diff --git a/packages/examples/cms-web-api/bruno/Replace Post.bru b/packages/examples/cms-web-api/bruno/Replace Post.bru index 8cc6909..aef447e 100644 --- a/packages/examples/cms-web-api/bruno/Replace Post.bru +++ b/packages/examples/cms-web-api/bruno/Replace Post.bru @@ -1,11 +1,11 @@ meta { name: Replace Post type: http - seq: 7 + seq: 6 } put { - url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc body: json auth: none } @@ -14,6 +14,6 @@ body:json { { "title": "Replaced Post", "content": "The old content is gone.", - "id": "5fac64d6-d261-42bb-a67b-bc7e1955a7e2" + "id": "9ba60691-0cd3-4e8a-9f44-e92b19fcacbc" } } diff --git a/packages/examples/cms-web-api/src/index.ts b/packages/examples/cms-web-api/src/index.ts index 9d5ba44..759a79c 100644 --- a/packages/examples/cms-web-api/src/index.ts +++ b/packages/examples/cms-web-api/src/index.ts @@ -25,14 +25,10 @@ const User = resource( .canPatch() .canDelete(); -const now = () => new Date(); - const Post = resource( v.object({ title: v.string(), content: v.string(), - createdAt: v.optional(v.datelike(), now), - updatedAt: v.optional(v.datelike(), now), }) ) .name('Post') @@ -43,6 +39,8 @@ const Post = resource( serialize: (v: unknown) => v?.toString() ?? '', deserialize: (v: string) => v, }) + .createdAt('createdAt') + .updatedAt('updatedAt') .canFetchItem() .canFetchCollection() .canCreate() @@ -58,7 +56,8 @@ const app = application({ const backend = app.createBackend({ dataSource: new JsonLinesDataSource(), -}); +}) + .throwsErrorOnDeletingNotFound(); const server = backend.createHttpServer({ basePath: '/api',