Dynamic attributes for metadata are included for createdAt and updatedAt.master
@@ -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<Schema extends BaseSchema = BaseSchema> { | |||
method: Method; | |||
middleware: Middleware; | |||
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => BaseSchema; | |||
allowed: (resource: Resource<Schema>) => boolean; | |||
constructBodySchema?: (resource: Resource<BaseResourceType & { schema: Schema }>, resourceId?: string) => BaseSchema; | |||
allowed: (resource: Resource<BaseResourceType & { schema: Schema }>) => boolean; | |||
} | |||
export interface Response { | |||
@@ -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 = <T extends v.BaseSchema>(resource: Resource<T>) => { | |||
const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => { | |||
return resource.schema; | |||
}; | |||
const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId?: string) => { | |||
const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>, mainResourceId?: string) => { | |||
if (typeof mainResourceId === 'undefined') { | |||
return resource.schema; | |||
} | |||
@@ -77,7 +84,7 @@ const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<T>, mainR | |||
); | |||
}; | |||
const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | |||
const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => { | |||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | |||
if (resource.schema.type !== 'object') { | |||
@@ -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<CharsetName extends string = string>(charset: Charset<CharsetName>): Application< | |||
Resources, MediaTypes, [...Charsets, Charset<CharsetName>], Languages | |||
>; | |||
resource< | |||
Schema extends v.BaseSchema, | |||
CurrentItemName extends string = string, | |||
CurrentRouteName extends string = string | |||
>(resRaw: Resource<Schema, CurrentItemName, CurrentRouteName>): Application< | |||
[...Resources, Resource<Schema, CurrentItemName, CurrentRouteName>], MediaTypes, Charsets, Languages | |||
resource<ResourceType extends BaseResourceType = BaseResourceType>(resRaw: Resource<ResourceType>): Application< | |||
[...Resources, Resource<ResourceType>], MediaTypes, Charsets, Languages | |||
>; | |||
createBackend(params: Omit<CreateBackendParams, 'app'>): Backend; | |||
createClient(params: Omit<CreateClientParams, 'app'>): ClientBuilder; | |||
@@ -84,7 +80,7 @@ export const application = (appParams: ApplicationParams): Application => { | |||
appState.languages.set(language.name, language); | |||
return this; | |||
}, | |||
resource<T extends v.BaseSchema>(resRaw: Resource<T>) { | |||
resource<T extends v.BaseSchema>(resRaw: Resource<BaseResourceType & { schema: T }>) { | |||
appState.resources.add(resRaw); | |||
return this; | |||
}, | |||
@@ -31,36 +31,38 @@ export interface ResourceState< | |||
type CanPatch = boolean | Partial<CanPatchObject> | 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<CurrentName, CurrentRouteName>; | |||
name<NewName extends CurrentName>(n: NewName): Resource<Schema, NewName, CurrentRouteName, CurrentIdAttr, IdSchema>; | |||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName): Resource<Schema, CurrentName, NewRouteName, CurrentIdAttr, IdSchema>; | |||
export interface BaseResourceType { | |||
schema: v.BaseSchema; | |||
name: string; | |||
routeName: string; | |||
idAttr: string; | |||
idSchema: v.BaseSchema; | |||
createdAtAttr: string; | |||
updatedAtAttr: string; | |||
} | |||
export interface Resource<ResourceType extends BaseResourceType = BaseResourceType> { | |||
schema: ResourceType['schema']; | |||
state: ResourceState<ResourceType['name'], ResourceType['routeName']>; | |||
name<NewName extends ResourceType['name']>(n: NewName): Resource<ResourceType & { name: NewName }>; | |||
route<NewRouteName extends ResourceType['routeName']>(n: NewRouteName): Resource<ResourceType & { routeName: NewRouteName }>; | |||
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<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): this; | |||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<ResourceType & { schema: RelatedSchema }>): this; | |||
dataSource?: DataSource; | |||
id<NewIdAttr extends CurrentIdAttr, TheIdSchema extends IdSchema>( | |||
id<NewIdAttr extends ResourceType['idAttr'], TheIdSchema extends ResourceType['idSchema']>( | |||
newIdAttr: NewIdAttr, | |||
params: ResourceIdConfig<TheIdSchema> | |||
): Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, TheIdSchema>; | |||
): Resource<ResourceType & { idAttr: NewIdAttr, idSchema: TheIdSchema }>; | |||
createdAt<NewCreatedAtAttr extends ResourceType['createdAtAttr']>(n: NewCreatedAtAttr): Resource<ResourceType & { createdAtAttr: NewCreatedAtAttr }>; | |||
updatedAt<NewUpdatedAtAttr extends ResourceType['updatedAtAttr']>(n: NewUpdatedAtAttr): Resource<ResourceType & { updatedAtAttr: NewUpdatedAtAttr }>; | |||
} | |||
export const resource = < | |||
Schema extends v.BaseSchema, | |||
CurrentName extends string = string, | |||
CurrentRouteName extends string = string | |||
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => { | |||
export const resource = <ResourceType extends BaseResourceType = BaseResourceType>(schema: ResourceType['schema']): Resource<ResourceType> => { | |||
const resourceState = { | |||
shared: new Map(), | |||
relationships: new Set<Resource>(), | |||
@@ -73,13 +75,11 @@ export const resource = < | |||
}, | |||
canEmplace: false, | |||
canDelete: false, | |||
} as ResourceState<CurrentName, CurrentRouteName>; | |||
} as ResourceState<ResourceType['name'], ResourceType['routeName']>; | |||
return { | |||
get state(): ResourceState<CurrentName, CurrentRouteName> { | |||
return Object.freeze({ | |||
...resourceState | |||
}) as unknown as ResourceState<CurrentName, CurrentRouteName>; | |||
get state(): ResourceState<ResourceType['name'], ResourceType['routeName']> { | |||
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<NewName extends CurrentName>(n: NewName) { | |||
name<NewName extends ResourceType['name']>(n: NewName) { | |||
resourceState.itemName = n; | |||
return this as Resource<Schema, NewName, CurrentRouteName>; | |||
return this; | |||
}, | |||
route<NewRouteName extends CurrentRouteName>(n: NewRouteName) { | |||
route<NewRouteName extends ResourceType['routeName']>(n: NewRouteName) { | |||
resourceState.routeName = n; | |||
return this as Resource<Schema, CurrentName, NewRouteName>; | |||
return this; | |||
}, | |||
get itemName() { | |||
return resourceState.itemName; | |||
@@ -152,11 +152,19 @@ export const resource = < | |||
get schema() { | |||
return schema; | |||
}, | |||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>) { | |||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<ResourceType & { schema: RelatedSchema }>) { | |||
resourceState.relationships.add(resource); | |||
return this; | |||
}, | |||
} as Resource<Schema, CurrentName, CurrentRouteName>; | |||
createdAt<NewCreatedAtAttr extends ResourceType['createdAtAttr']>(n: NewCreatedAtAttr) { | |||
resourceState.shared.set('createdAtAttr', n); | |||
return this; | |||
}, | |||
updatedAt<NewUpdatedAtAttr extends ResourceType['updatedAtAttr']>(n: NewUpdatedAtAttr) { | |||
resourceState.shared.set('updatedAtAttr', n); | |||
return this; | |||
}, | |||
} as Resource<ResourceType>; | |||
}; | |||
export type ResourceType<R extends Resource> = v.Output<R['schema']>; |
@@ -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<Data> { | |||
private path?: string; | |||
private resource?: Resource<Schema, CurrentName, CurrentRouteName>; | |||
private resource?: Resource<BaseResourceType & { | |||
schema: Schema, | |||
name: CurrentName, | |||
routeName: CurrentRouteName, | |||
}>; | |||
data: Data[] = []; | |||
@@ -24,15 +28,25 @@ export class JsonLinesDataSource< | |||
// noop | |||
} | |||
prepareResource(resource: Resource<Schema, CurrentName, CurrentRouteName>) { | |||
prepareResource<Schema extends v.BaseSchema>(resource: Resource<BaseResourceType & { | |||
schema: Schema, | |||
name: CurrentName, | |||
routeName: 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>) => { | |||
resource.id = <NewIdAttr extends BaseResourceType['idAttr'], NewIdSchema extends BaseResourceType['idSchema']>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => { | |||
originalResourceId(newIdAttr, params); | |||
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||
return resource as Resource<BaseResourceType & { | |||
name: CurrentName, | |||
routeName: CurrentRouteName, | |||
schema: Schema, | |||
idAttr: NewIdAttr, | |||
idSchema: NewIdSchema, | |||
}>; | |||
}; | |||
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<any> | undefined; | |||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||
let theId: any; | |||
const { [idAttr]: dataId, ...etcData } = data as Record<string, unknown>; | |||
if (typeof dataId !== 'undefined') { | |||
theId = idConfig.deserialize((data as Record<string, string>)[idAttr]); | |||
} else { | |||
const newId = await this.newId(); | |||
theId = idConfig.deserialize(newId); | |||
} | |||
const newData = { | |||
...data | |||
[idAttr]: theId, | |||
...etcData | |||
} as Record<string, unknown>; | |||
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<string, unknown>; | |||
if (existing) { | |||
const createdAt = this.resource.state.shared.get('createdAtAttr'); | |||
if (typeof createdAt === 'string') { | |||
dataToEmplace[createdAt] = (existing as Record<string, unknown>)[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<string, unknown>; | |||
const createdAt = this.resource.state.shared.get('createdAtAttr'); | |||
if (typeof createdAt === 'string') { | |||
newItem[createdAt] = (existing as Record<string, unknown>)[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); | |||
@@ -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<typeof schema>(); | |||
ds.prepareResource(r); | |||
@@ -133,7 +133,10 @@ describe('methods', () => { | |||
expect.any(String), | |||
toJsonl([ | |||
...dummyItems, | |||
data | |||
{ | |||
id: 0, | |||
...data, | |||
} | |||
]) | |||
); | |||
expect(newItem).toEqual(data); | |||
@@ -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" | |||
} | |||
} |
@@ -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 | |||
} | |||
@@ -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 | |||
} | |||
@@ -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" | |||
} | |||
} |
@@ -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', | |||