@@ -19,6 +19,7 @@ const Piano = resource(v.object( | |||||
generationStrategy: autoIncrement, | generationStrategy: autoIncrement, | ||||
serialize: (id) => id?.toString() ?? '0', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | |||||
}) | }) | ||||
.canFetchItem() | .canFetchItem() | ||||
.canFetchCollection() | .canFetchCollection() | ||||
@@ -43,6 +44,7 @@ const User = resource(v.object( | |||||
generationStrategy: autoIncrement, | generationStrategy: autoIncrement, | ||||
serialize: (id) => id?.toString() ?? '0', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | |||||
}); | }); | ||||
const app = application({ | const app = application({ | ||||
@@ -2,7 +2,7 @@ import * as http from 'http'; | |||||
import * as https from 'https'; | import * as https from 'https'; | ||||
import { constants } from 'http2'; | import { constants } from 'http2'; | ||||
import { pluralize } from 'inflection'; | import { pluralize } from 'inflection'; | ||||
import { BaseSchema, ObjectSchema } from 'valibot'; | |||||
import {BaseSchema, ObjectSchema, Output} from 'valibot'; | |||||
import { SerializerPair } from './serializers'; | import { SerializerPair } from './serializers'; | ||||
import { | import { | ||||
handleCreateItem, handleDeleteItem, handleEmplaceItem, | handleCreateItem, handleDeleteItem, handleEmplaceItem, | ||||
@@ -18,18 +18,16 @@ import * as en from './languages/en'; | |||||
import * as utf8 from './encodings/utf-8'; | import * as utf8 from './encodings/utf-8'; | ||||
import * as applicationJson from './serializers/application/json'; | import * as applicationJson from './serializers/application/json'; | ||||
// TODO define ResourceState | |||||
// TODO separate frontend and backend factory methods | // TODO separate frontend and backend factory methods | ||||
// TODO complete content negotiation and default (fallback) messages collection | |||||
export interface DataSource<T = object> { | export interface DataSource<T = object> { | ||||
initialize(): Promise<unknown>; | initialize(): Promise<unknown>; | ||||
getTotalCount?(): Promise<number>; | getTotalCount?(): Promise<number>; | ||||
getMultiple(): Promise<T[]>; | getMultiple(): Promise<T[]>; | ||||
getSingle(id: string): Promise<T | null>; | getSingle(id: string): Promise<T | null>; | ||||
create(data: Partial<T>): Promise<T>; | |||||
create(data: T): Promise<T>; | |||||
delete(id: string): Promise<unknown>; | delete(id: string): Promise<unknown>; | ||||
emplace(id: string, data: Partial<T>): Promise<[T, boolean]>; | |||||
emplace(id: string, data: T): Promise<[T, boolean]>; | |||||
patch(id: string, data: Partial<T>): Promise<T | null>; | patch(id: string, data: Partial<T>): Promise<T | null>; | ||||
} | } | ||||
@@ -38,24 +36,26 @@ export interface ApplicationParams { | |||||
dataSource?: (resource: Resource) => DataSource; | dataSource?: (resource: Resource) => DataSource; | ||||
} | } | ||||
export interface Resource<T extends BaseSchema = any> { | |||||
interface ResourceState<T extends BaseSchema> { | |||||
idAttr: string; | |||||
itemName: string; | |||||
collectionName: string; | |||||
routeName: string; | |||||
idConfig: ResourceIdConfig<T>; | |||||
fullTextAttrs: Set<string>; | |||||
canCreate: boolean; | |||||
canFetchCollection: boolean; | |||||
canFetchItem: boolean; | |||||
canPatch: boolean; | |||||
canEmplace: boolean; | |||||
canDelete: boolean; | |||||
} | |||||
export interface Resource<T extends BaseSchema = any, IdSchema extends BaseSchema = any> { | |||||
newId(dataSource: DataSource): string | number | unknown; | newId(dataSource: DataSource): string | number | unknown; | ||||
schema: T; | schema: T; | ||||
state: { | |||||
idAttr: string; | |||||
itemName?: string; | |||||
collectionName?: string; | |||||
routeName?: string; | |||||
idSerializer: NonNullable<IdParams['serialize']>; | |||||
idDeserializer: NonNullable<IdParams['deserialize']>; | |||||
canCreate: boolean; | |||||
canFetchCollection: boolean; | |||||
canFetchItem: boolean; | |||||
canPatch: boolean; | |||||
canEmplace: boolean; | |||||
canDelete: boolean; | |||||
}; | |||||
id(newIdAttr: string, params: IdParams): this; | |||||
state: ResourceState<IdSchema>; | |||||
id(newIdAttr: string, params: ResourceIdConfig<IdSchema>): this; | |||||
fullText(fullTextAttr: string): this; | fullText(fullTextAttr: string): this; | ||||
name(n: string): this; | name(n: string): this; | ||||
collection(n: string): this; | collection(n: string): this; | ||||
@@ -76,10 +76,11 @@ interface GenerationStrategy { | |||||
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | (dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | ||||
} | } | ||||
interface IdParams { | |||||
interface ResourceIdConfig<T extends BaseSchema> { | |||||
generationStrategy: GenerationStrategy; | generationStrategy: GenerationStrategy; | ||||
serialize?: (id: unknown) => string; | |||||
deserialize?: (id: string) => unknown; | |||||
serialize: (id: unknown) => string; | |||||
deserialize: (id: string) => Output<T>; | |||||
schema: T; | |||||
} | } | ||||
const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { | const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { | ||||
@@ -110,24 +111,7 @@ const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { | |||||
return middlewares; | return middlewares; | ||||
}; | }; | ||||
interface ResourceState { | |||||
idAttr: string | |||||
itemName: string | |||||
collectionName: string | |||||
routeName: string | |||||
idGenerationStrategy: GenerationStrategy | |||||
idSerializer: IdParams['serialize'] | |||||
idDeserializer: IdParams['deserialize'] | |||||
fullTextAttrs: Set<string>; | |||||
canCreate: boolean; | |||||
canFetchCollection: boolean; | |||||
canFetchItem: boolean; | |||||
canPatch: boolean; | |||||
canEmplace: boolean; | |||||
canDelete: boolean; | |||||
} | |||||
export const resource = <T extends BaseSchema>(schema: T): Resource<T> => { | |||||
export const resource = <T extends BaseSchema, Id extends BaseSchema = any>(schema: T): Resource<T, Id> => { | |||||
const resourceState = { | const resourceState = { | ||||
fullTextAttrs: new Set<string>(), | fullTextAttrs: new Set<string>(), | ||||
canCreate: false, | canCreate: false, | ||||
@@ -136,13 +120,13 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => { | |||||
canPatch: false, | canPatch: false, | ||||
canEmplace: false, | canEmplace: false, | ||||
canDelete: false, | canDelete: false, | ||||
} as Partial<ResourceState>; | |||||
} as Partial<ResourceState<Id>>; | |||||
return { | return { | ||||
get state(): ResourceState { | |||||
get state(): ResourceState<Id> { | |||||
return Object.freeze({ | return Object.freeze({ | ||||
...resourceState | ...resourceState | ||||
}) as unknown as ResourceState; | |||||
}) as unknown as ResourceState<Id>; | |||||
}, | }, | ||||
canFetchCollection(b = true) { | canFetchCollection(b = true) { | ||||
resourceState.canFetchCollection = b; | resourceState.canFetchCollection = b; | ||||
@@ -168,15 +152,13 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => { | |||||
resourceState.canDelete = b; | resourceState.canDelete = b; | ||||
return this; | return this; | ||||
}, | }, | ||||
id(newIdAttr: string, params: IdParams) { | |||||
id(newIdAttr: string, params: ResourceIdConfig<Id>) { | |||||
resourceState.idAttr = newIdAttr; | resourceState.idAttr = newIdAttr; | ||||
resourceState.idGenerationStrategy = params.generationStrategy; | |||||
resourceState.idSerializer = params.serialize; | |||||
resourceState.idDeserializer = params.deserialize; | |||||
resourceState.idConfig = params; | |||||
return this; | return this; | ||||
}, | }, | ||||
newId(dataSource: DataSource) { | newId(dataSource: DataSource) { | ||||
return resourceState?.idGenerationStrategy?.(dataSource); | |||||
return resourceState?.idConfig?.generationStrategy?.(dataSource); | |||||
}, | }, | ||||
fullText(fullTextAttr: string) { | fullText(fullTextAttr: string) { | ||||
if ( | if ( | ||||
@@ -363,7 +345,7 @@ export const application = (appParams: ApplicationParams): Application => { | |||||
encodings: new Map<string, EncodingPair>(), | encodings: new Map<string, EncodingPair>(), | ||||
}; | }; | ||||
appState.languages.set(en.code, en.messages); | |||||
appState.languages.set(en.name, en.messages); | |||||
appState.encodings.set(utf8.name, utf8); | appState.encodings.set(utf8.name, utf8); | ||||
appState.serializers.set(applicationJson.name, applicationJson); | appState.serializers.set(applicationJson.name, applicationJson); | ||||
@@ -393,7 +375,7 @@ export const application = (appParams: ApplicationParams): Application => { | |||||
const clientState = { | const clientState = { | ||||
contentType: applicationJson.name, | contentType: applicationJson.name, | ||||
encoding: utf8.name, | encoding: utf8.name, | ||||
language: en.code | |||||
language: en.name | |||||
}; | }; | ||||
return { | return { | ||||
@@ -414,7 +396,7 @@ export const application = (appParams: ApplicationParams): Application => { | |||||
createBackend(): Backend { | createBackend(): Backend { | ||||
const backendState: BackendState = { | const backendState: BackendState = { | ||||
fallback: { | fallback: { | ||||
language: en.code, | |||||
language: en.name, | |||||
encoding: utf8.name, | encoding: utf8.name, | ||||
serializer: applicationJson.name | serializer: applicationJson.name | ||||
}, | }, | ||||
@@ -29,8 +29,9 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
return [...this.data]; | return [...this.data]; | ||||
} | } | ||||
async getSingle(id: string) { | |||||
const foundData = this.data.find((s) => this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id); | |||||
async getSingle(idSerialized: string) { | |||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const foundData = this.data.find((s) => s[this.resource.state.idAttr as string] === id); | |||||
if (foundData) { | if (foundData) { | ||||
return { | return { | ||||
@@ -41,13 +42,13 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
return null; | return null; | ||||
} | } | ||||
async create(data: Partial<T>) { | |||||
async create(data: T) { | |||||
const newData = { | const newData = { | ||||
...data | ...data | ||||
} as Record<string, unknown>; | } as Record<string, unknown>; | ||||
if (this.resource.state.idAttr in newData) { | if (this.resource.state.idAttr in newData) { | ||||
newData[this.resource.state.idAttr] = this.resource.state.idDeserializer(newData[this.resource.state.idAttr] as string); | |||||
newData[this.resource.state.idAttr] = this.resource.state.idConfig.deserialize(newData[this.resource.state.idAttr] as string); | |||||
} | } | ||||
const newCollection = [ | const newCollection = [ | ||||
@@ -60,26 +61,29 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
return data as T; | return data as T; | ||||
} | } | ||||
async delete(id: string) { | |||||
async delete(idSerialized: string) { | |||||
const oldDataLength = this.data.length; | const oldDataLength = this.data.length; | ||||
const newData = this.data.filter((s) => !(this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id)); | |||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const newData = this.data.filter((s) => !(s[this.resource.state.idAttr] === id)); | |||||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | ||||
return oldDataLength !== newData.length; | return oldDataLength !== newData.length; | ||||
} | } | ||||
async emplace(id: string, data: Partial<T>) { | |||||
const existing = await this.getSingle(id); | |||||
async emplace(idSerialized: string, dataWithId: T) { | |||||
const existing = await this.getSingle(idSerialized); | |||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const { [this.resource.state.idAttr]: idFromResource, ...data } = dataWithId; | |||||
const dataToEmplace = { | const dataToEmplace = { | ||||
...data, | ...data, | ||||
[this.resource.state.idAttr]: this.resource.state.idDeserializer(id), | |||||
}; | |||||
[this.resource.state.idAttr]: id, | |||||
} as T; | |||||
if (existing) { | if (existing) { | ||||
const newData = this.data.map((d) => { | const newData = this.data.map((d) => { | ||||
if (this.resource.state.idSerializer(d[this.resource.state.idAttr as string]) === id) { | |||||
if (d[this.resource.state.idAttr] === id) { | |||||
return dataToEmplace; | return dataToEmplace; | ||||
} | } | ||||
@@ -88,16 +92,15 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | ||||
return [data, false] as [T, boolean]; | |||||
return [dataToEmplace, false] as [T, boolean]; | |||||
} | } | ||||
const newData = await this.create(dataToEmplace); | const newData = await this.create(dataToEmplace); | ||||
return [newData, true] as [T, boolean]; | return [newData, true] as [T, boolean]; | ||||
} | } | ||||
async patch(id: string, data: Partial<T>) { | |||||
const existing = await this.getSingle(id); | |||||
async patch(idSerialized: string, data: Partial<T>) { | |||||
const existing = await this.getSingle(idSerialized); | |||||
if (!existing) { | if (!existing) { | ||||
return null; | return null; | ||||
} | } | ||||
@@ -107,8 +110,9 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
...data, | ...data, | ||||
} | } | ||||
const id = this.resource.state.idConfig.deserialize(idSerialized); | |||||
const newData = this.data.map((d) => { | const newData = this.data.map((d) => { | ||||
if (this.resource.state.idSerializer(d[this.resource.state.idAttr as string]) === id) { | |||||
if (d[this.resource.state.idAttr as string] === id) { | |||||
return newItem; | return newItem; | ||||
} | } | ||||
@@ -116,7 +120,6 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI | |||||
}); | }); | ||||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | ||||
return newItem as T; | return newItem as T; | ||||
} | } | ||||
} | } |
@@ -667,7 +667,7 @@ export const handleEmplaceItem: Middleware = ({ | |||||
v.object({ | v.object({ | ||||
[resource.state.idAttr]: v.transform( | [resource.state.idAttr]: v.transform( | ||||
v.any(), | v.any(), | ||||
input => resource.state.idSerializer(input), | |||||
input => resource.state.idConfig.serialize(input), | |||||
v.literal(resourceId) | v.literal(resourceId) | ||||
) | ) | ||||
}) | }) | ||||
@@ -723,6 +723,7 @@ export const handleEmplaceItem: Middleware = ({ | |||||
try { | try { | ||||
// TODO error handling for each process | // TODO error handling for each process | ||||
const params = bodyDeserialized as Record<string, unknown>; | const params = bodyDeserialized as Record<string, unknown>; | ||||
params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); | |||||
const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); | const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); | ||||
const serialized = responseBodySerializerPair.serialize(newObject); | const serialized = responseBodySerializerPair.serialize(newObject); | ||||
const theFormatted = encoding.encode(serialized); | const theFormatted = encoding.encode(serialized); | ||||
@@ -94,4 +94,4 @@ export const messages: MessageCollection = { | |||||
} | } | ||||
}; | }; | ||||
export const code = 'en'; | |||||
export const name = 'en'; |
@@ -83,6 +83,7 @@ describe('yasumi', () => { | |||||
generationStrategy: autoIncrement, | generationStrategy: autoIncrement, | ||||
serialize: (id) => id?.toString() ?? '0', | serialize: (id) => id?.toString() ?? '0', | ||||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | ||||
schema: v.number(), | |||||
}) | }) | ||||
}); | }); | ||||
@@ -300,7 +301,6 @@ describe('yasumi', () => { | |||||
Piano.canCreate(false); | Piano.canCreate(false); | ||||
}); | }); | ||||
// FIXME ID de/serialization problems | |||||
it('returns data', () => { | it('returns data', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
const req = request( | const req = request( | ||||
@@ -480,7 +480,6 @@ describe('yasumi', () => { | |||||
Piano.canEmplace(false); | Piano.canEmplace(false); | ||||
}); | }); | ||||
// FIXME IDs not properly being de/serialized | |||||
it('returns data for replacement', () => { | it('returns data for replacement', () => { | ||||
return new Promise<void>((resolve, reject) => { | return new Promise<void>((resolve, reject) => { | ||||
const req = request( | const req = request( | ||||