Browse Source

Add createdAt and updatedAt

Dynamic attributes for metadata are included for createdAt and updatedAt.
master
TheoryOfNekomata 8 months ago
parent
commit
6e6fb96924
11 changed files with 161 additions and 70 deletions
  1. +10
    -3
      packages/core/src/backend/common.ts
  2. +11
    -4
      packages/core/src/backend/servers/http/core.ts
  3. +4
    -8
      packages/core/src/common/app.ts
  4. +38
    -30
      packages/core/src/common/resource.ts
  5. +64
    -12
      packages/data-sources/file-jsonl/src/index.ts
  6. +6
    -3
      packages/data-sources/file-jsonl/test/index.test.ts
  7. +19
    -0
      packages/examples/cms-web-api/bruno/Create Post with ID.bru
  8. +1
    -1
      packages/examples/cms-web-api/bruno/Modify Post (Delta).bru
  9. +1
    -1
      packages/examples/cms-web-api/bruno/Modify Post (Merge).bru
  10. +3
    -3
      packages/examples/cms-web-api/bruno/Replace Post.bru
  11. +4
    -5
      packages/examples/cms-web-api/src/index.ts

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

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


+ 11
- 4
packages/core/src/backend/servers/http/core.ts View File

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


+ 4
- 8
packages/core/src/common/app.ts View File

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


+ 38
- 30
packages/core/src/common/resource.ts View File

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

+ 64
- 12
packages/data-sources/file-jsonl/src/index.ts View File

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


+ 6
- 3
packages/data-sources/file-jsonl/test/index.test.ts View File

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


+ 19
- 0
packages/examples/cms-web-api/bruno/Create Post with ID.bru View File

@@ -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"
}
}

+ 1
- 1
packages/examples/cms-web-api/bruno/Modify Post (Delta).bru View File

@@ -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
- 1
packages/examples/cms-web-api/bruno/Modify Post (Merge).bru View File

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


+ 3
- 3
packages/examples/cms-web-api/bruno/Replace Post.bru View File

@@ -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"
}
}

+ 4
- 5
packages/examples/cms-web-api/src/index.ts View File

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


Loading…
Cancel
Save