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