diff --git a/src/BaseSerializer.ts b/src/BaseSerializer.ts index 7263ba4..b34e5df 100644 --- a/src/BaseSerializer.ts +++ b/src/BaseSerializer.ts @@ -313,8 +313,8 @@ export abstract class BaseSerializer { * @param input * @returns */ - protected runThen (input: { - ensureJson: () => void + protected async runThen (input: { + ensureJson: () => void | Promise body: () => TBody rawResponse?: TRawResponse createServerResponse: (raw: TRawResponse, body: TBody) => TServerResponse @@ -324,7 +324,7 @@ export abstract class BaseSerializer { onrejected?: ((reason: any) => TResult2 | PromiseLike) | null }) { this.called.then = true - input.ensureJson() + await input.ensureJson() const initialBody = input.body() let response: TServerResponse | undefined diff --git a/src/GenericResource.ts b/src/GenericResource.ts index 54d65e2..8db6a38 100644 --- a/src/GenericResource.ts +++ b/src/GenericResource.ts @@ -23,7 +23,9 @@ import { getPaginationExtraKeys, isArkormLikeCollection, isArkormLikeModel, + isPromiseLike, normalizeSerializableData, + normalizeSerializableDataAsync, sanitizeConditionalAttributes, setRequestUrl, transformKeys, @@ -130,29 +132,40 @@ export class GenericResource< /** * Get the original resource data */ - data (_ctx?: unknown): R { + data(_ctx?: unknown): R { return this.resource } /** * Get the current serialized output body. */ - getBody (): GenericBody { + getBody(): GenericBody { this.json() return this.body } + /** + * Asynchronously get the current serialized output body. + * + * @returns + */ + async getBodyAsync(): Promise> { + await this.jsonAsync() + + return this.body + } + /** * Replace the current serialized output body. */ - protected setBody (body: GenericBody) { + protected setBody(body: GenericBody) { this.body = body return this } - private resolveCollectsConfig (): ResourceLevelConfig | undefined { + private resolveCollectsConfig(): ResourceLevelConfig | undefined { const collectedResource = this.collects as typeof Resource | undefined if (!collectedResource) { @@ -172,7 +185,7 @@ export class GenericResource< } } - private resolveResponseStructure () { + private resolveResponseStructure() { return this.resolveSerializerResponseStructure( this.constructor as typeof GenericResource, this.resolveCollectsConfig() @@ -184,7 +197,7 @@ export class GenericResource< * * @returns */ - protected resolveCurrentRootKey () { + protected resolveCurrentRootKey() { return this.resolveResponseStructure().rootKey } @@ -194,7 +207,7 @@ export class GenericResource< * @param meta * @param rootKey */ - protected applyMetaToBody (meta: MetaData, rootKey: string) { + protected applyMetaToBody(meta: MetaData, rootKey: string) { this.body = appendRootProperties(this.body, meta, rootKey) as GenericBody } @@ -203,15 +216,15 @@ export class GenericResource< * * @returns */ - protected getResourceForMeta () { + protected getResourceForMeta() { return this.resource } - protected getSerializerType () { + protected getSerializerType() { return 'generic' as const } - private getPayloadKey () { + private getPayloadKey() { const { wrap, rootKey, factory } = this.resolveResponseStructure() return factory || !wrap ? undefined : rootKey @@ -222,13 +235,19 @@ export class GenericResource< * * @returns */ - json () { + json() { if (!this.called.json) { this.called.json = true const ctx = this.resolveSerializationContext() const resource = this.data(ctx) + if (isPromiseLike(resource)) { + this.called.json = false + + return this + } + let data: any = normalizeSerializableData(resource) if (Array.isArray(data) && this.collects) { @@ -289,12 +308,78 @@ export class GenericResource< return this } + private async jsonAsync(): Promise { + if (!this.called.json) { + this.called.json = true + + const ctx = this.resolveSerializationContext() + const resource = await this.data(ctx) + + let data: any = await normalizeSerializableDataAsync(resource) + + if (Array.isArray(data) && this.collects) { + data = await Promise.all(data.map(async item => new this.collects!(item).data(ctx))) + data = await normalizeSerializableDataAsync(data) + } + + if (!Array.isArray(data) && data && typeof data.data !== 'undefined') { + data = data.data + } + + data = sanitizeConditionalAttributes(data) + + const paginationExtras = buildPaginationExtras(this.resource) + const { metaKey } = getPaginationExtraKeys() + const configuredMeta = metaKey ? paginationExtras[metaKey] : undefined + if (metaKey) { + delete paginationExtras[metaKey] + } + + const caseStyle = this.resolveSerializerCaseStyle( + this.constructor as typeof GenericResource, + this.resolveCollectsConfig() + ) + if (caseStyle) { + const transformer = getCaseTransformer(caseStyle) + data = transformKeys(data, transformer) + } + + const customMeta = this.resolveMergedMeta(GenericResource.prototype.with) + + const { wrap, rootKey, factory } = this.resolveResponseStructure() + this.body = buildResponseEnvelope({ + payload: data, + meta: configuredMeta, + metaKey, + wrap, + rootKey, + factory, + context: { + type: 'generic', + resource: this.resource, + }, + }) as GenericBody + + this.body = appendRootProperties( + this.body, + { + ...paginationExtras, + ...(customMeta || {}), + }, + rootKey + ) as GenericBody + this.body = this.applySerializePlugins(this.body) as GenericBody + } + + return undefined + } + /** * Convert resource to object format (for collections). * * @returns */ - toObject () { + toObject() { this.called.toObject = true this.json() @@ -314,7 +399,7 @@ export class GenericResource< * @alias toArray * @since 0.2.9 */ - toArray () { + toArray() { this.called.toArray = true return this.toObject() @@ -326,7 +411,7 @@ export class GenericResource< * @param extra Additional properties to merge into the response body * @returns */ - additional> (extra: X) { + additional>(extra: X) { this.called.additional = true this.json() @@ -353,21 +438,22 @@ export class GenericResource< /** * Build a response object, optionally accepting a raw response to write to directly. */ - response (): ServerResponse> + response(): ServerResponse> /** * Build a response object, writing to the provided raw response if possible. * * @param res */ - response (res: H3Event['res']): ServerResponse> - response (res: Response): ServerResponse> + response(res: H3Event['res']): ServerResponse> + response(res: Response): ServerResponse> + /** * Build a response object, writing to the provided raw response if possible. * * @param res * @returns */ - response (res?: Response | H3Event['res']): ServerResponse> { + response(res?: Response | H3Event['res']): ServerResponse> { const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] return this.runResponse({ @@ -394,7 +480,7 @@ export class GenericResource< * * Override in custom classes to mutate headers/status/body. */ - withResponse ( + withResponse( _response?: ServerResponse>, _rawResponse?: Response | H3Event['res'] ): any { @@ -408,12 +494,14 @@ export class GenericResource< * @param onrejected Callback to handle the rejected state of the promise, receiving the error reason * @returns A promise that resolves to the result of the onfulfilled or onrejected callback */ - then, TResult2 = never> ( + then, TResult2 = never>( onfulfilled?: ((value: GenericBody) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'], createServerResponse: (raw, body) => { @@ -442,11 +530,13 @@ export class GenericResource< * @param onrejected * @returns */ - catch ( + catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null, ): Promise | TResult> { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'], createServerResponse: (raw, body) => { @@ -474,9 +564,11 @@ export class GenericResource< * @param onfinally * @returns */ - finally (onfinally?: (() => void) | null) { + finally(onfinally?: (() => void) | null) { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'], createServerResponse: (raw, body) => { diff --git a/src/Resource.ts b/src/Resource.ts index 00eb8ed..038ea02 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -19,7 +19,9 @@ import { extractResponseFromCtx, getCaseTransformer, isArkormLikeModel, + isPromiseLike, normalizeSerializableData, + normalizeSerializableDataAsync, sanitizeConditionalAttributes, setRequestUrl, transformKeys, @@ -111,36 +113,47 @@ export class Resource ex static collection< C extends ResourceData[] | Collectible | CollectionLike | PaginatorLike = ResourceData[], T extends ResourceData = any - > (data: C) { + >(data: C) { return new ResourceCollection(data).setCollects(this) } /** * Get the original resource data */ - data (_ctx?: unknown) { + data(_ctx?: unknown) { return this.toObject() } /** * Get the current serialized output body. */ - getBody (): ResourceBody { + getBody(): ResourceBody { this.json() return this.body } + /** + * Asynchronously get the current serialized output body. + * + * @returns + */ + async getBodyAsync(): Promise> { + await this.jsonAsync() + + return this.body + } + /** * Replace the current serialized output body. */ - protected setBody (body: ResourceBody) { + protected setBody(body: ResourceBody) { this.body = body return this } - private resolveResponseStructure () { + private resolveResponseStructure() { return this.resolveSerializerResponseStructure(this.constructor as typeof Resource) } @@ -149,23 +162,23 @@ export class Resource ex * * @returns */ - protected resolveCurrentRootKey () { + protected resolveCurrentRootKey() { return this.resolveResponseStructure().rootKey } - protected applyMetaToBody (meta: MetaData, rootKey: string) { + protected applyMetaToBody(meta: MetaData, rootKey: string) { this.body = appendRootProperties(this.body, meta, rootKey) as ResourceBody } - protected getResourceForMeta () { + protected getResourceForMeta() { return this.resource } - protected getSerializerType () { + protected getSerializerType() { return 'resource' as const } - private getPayloadKey () { + private getPayloadKey() { const { wrap, rootKey, factory } = this.resolveResponseStructure() return factory || !wrap ? undefined : rootKey @@ -176,13 +189,19 @@ export class Resource ex * * @returns */ - json () { + json() { if (!this.called.json) { this.called.json = true const ctx = this.resolveSerializationContext() const resource = this.data(ctx) + if (isPromiseLike(resource)) { + this.called.json = false + + return this + } + let data: any = normalizeSerializableData(resource) if (!Array.isArray(data) && data && typeof data.data !== 'undefined') { @@ -219,12 +238,60 @@ export class Resource ex return this } + + /** + * Asynchronously convert resource to JSON response format + * + * @returns + */ + private async jsonAsync(): Promise { + if (!this.called.json) { + this.called.json = true + + const ctx = this.resolveSerializationContext() + const resource = await this.data(ctx) + + let data: any = await normalizeSerializableDataAsync(resource) + + if (!Array.isArray(data) && data && typeof data.data !== 'undefined') { + data = data.data + } + + data = sanitizeConditionalAttributes(data) + + const caseStyle = this.resolveSerializerCaseStyle(this.constructor as typeof Resource) + if (caseStyle) { + const transformer = getCaseTransformer(caseStyle) + data = transformKeys(data, transformer) + } + + const customMeta = this.resolveMergedMeta(Resource.prototype.with) + + const { wrap, rootKey, factory } = this.resolveResponseStructure() + this.body = buildResponseEnvelope({ + payload: data, + wrap, + rootKey, + factory, + context: { + type: 'resource', + resource: this.resource, + }, + }) as ResourceBody + + this.body = appendRootProperties(this.body, customMeta, rootKey) as ResourceBody + this.body = this.applySerializePlugins(this.body) as ResourceBody + } + + return undefined + } + /** * Convert resource to object format (for collections) or return original data for single resources. * * @returns */ - toObject (): R extends NonCollectible ? R['data'] : R { + toObject(): R extends NonCollectible ? R['data'] : R { this.called.toObject = true this.json() @@ -244,7 +311,7 @@ export class Resource ex * @alias toArray * @since 0.2.9 */ - toArray (): R extends NonCollectible ? R['data'] : R { + toArray(): R extends NonCollectible ? R['data'] : R { this.called.toArray = true return this.toObject() @@ -256,7 +323,7 @@ export class Resource ex * @param extra Additional properties to merge into the response body * @returns */ - additional> (extra: X) { + additional>(extra: X) { this.called.additional = true this.json() @@ -279,20 +346,20 @@ export class Resource ex /** * Build a response object, optionally accepting a raw response to mutate in withResponse. */ - response (): ServerResponse> + response(): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * @param res Optional raw response object (e.g. Express Response or H3Event res) */ - response (res: H3Event['res']): ServerResponse> - response (res: Response): ServerResponse> + response(res: H3Event['res']): ServerResponse> + response(res: Response): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * * @param res Optional raw response object (e.g. Express Response or H3Event res) * @returns */ - response (res?: H3Event['res'] | Response): ServerResponse> { + response(res?: H3Event['res'] | Response): ServerResponse> { const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response return this.runResponse({ @@ -319,7 +386,7 @@ export class Resource ex * * Override in custom classes to mutate headers/status/body. */ - withResponse ( + withResponse( _response?: ServerResponse>, _rawResponse?: Response | H3Event['res'] ): any { @@ -333,12 +400,14 @@ export class Resource ex * @param onrejected Callback to handle the rejected state of the promise, receiving the error reason * @returns A promise that resolves to the result of the onfulfilled or onrejected callback */ - then, TResult2 = never> ( + then, TResult2 = never>( onfulfilled?: ((value: ResourceBody) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, createServerResponse: (raw, body) => { @@ -367,11 +436,13 @@ export class Resource ex * @param onrejected * @returns */ - catch ( + catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null, ): Promise | TResult> { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, createServerResponse: (raw, body) => { @@ -399,9 +470,11 @@ export class Resource ex * @param onfinally * @returns */ - finally (onfinally?: (() => void) | null) { + finally(onfinally?: (() => void) | null) { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, createServerResponse: (raw, body) => { diff --git a/src/ResourceCollection.ts b/src/ResourceCollection.ts index 4ae4fab..ef74125 100644 --- a/src/ResourceCollection.ts +++ b/src/ResourceCollection.ts @@ -21,7 +21,9 @@ import { getCaseTransformer, getPaginationExtraKeys, isArkormLikeCollection, + isPromiseLike, normalizeSerializableData, + normalizeSerializableDataAsync, sanitizeConditionalAttributes, setRequestUrl, transformKeys, @@ -55,7 +57,7 @@ export class ResourceCollection< * @param value The value to check. * @returns True if the value is a Collectible with pagination information, false otherwise. */ - isPaginatedCollectible (value: unknown): value is Collectible { + isPaginatedCollectible(value: unknown): value is Collectible { if (!value || typeof value !== 'object') { return false } @@ -94,7 +96,7 @@ export class ResourceCollection< } } - private getSourceData (): ( + private getSourceData(): ( R extends Collectible ? R['data'][number] : R extends PaginatorLike @@ -114,7 +116,7 @@ export class ResourceCollection< ) as never } - private resolveObjectData (ctx?: unknown) { + private resolveObjectData(ctx?: unknown) { let data = this.getSourceData() as ResourceData[] if (this.collects) { @@ -137,29 +139,35 @@ export class ResourceCollection< /** * Get the original resource data */ - data (_ctx?: unknown) { + data(_ctx?: unknown) { return this.getSourceData() } /** * Get the current serialized output body. */ - getBody (): CollectionBody { + getBody(): CollectionBody { this.json() return this.body } + async getBodyAsync(): Promise> { + await this.jsonAsync() + + return this.body + } + /** * Replace the current serialized output body. */ - protected setBody (body: CollectionBody) { + protected setBody(body: CollectionBody) { this.body = body return this } - private resolveCollectsConfig (): ResourceLevelConfig | undefined { + private resolveCollectsConfig(): ResourceLevelConfig | undefined { const collectedResource = this.collects as typeof Resource | undefined if (!collectedResource) { @@ -179,7 +187,7 @@ export class ResourceCollection< } } - private resolveResponseStructure () { + private resolveResponseStructure() { return this.resolveSerializerResponseStructure( this.constructor as typeof ResourceCollection, this.resolveCollectsConfig() @@ -191,7 +199,7 @@ export class ResourceCollection< * * @returns */ - protected resolveCurrentRootKey () { + protected resolveCurrentRootKey() { return this.resolveResponseStructure().rootKey } @@ -202,7 +210,7 @@ export class ResourceCollection< * @param meta * @param rootKey */ - protected applyMetaToBody (meta: MetaData, rootKey: string) { + protected applyMetaToBody(meta: MetaData, rootKey: string) { this.body = appendRootProperties(this.body, meta, rootKey) as CollectionBody } @@ -212,11 +220,11 @@ export class ResourceCollection< * * @returns */ - protected getResourceForMeta () { + protected getResourceForMeta() { return this.resource } - protected getSerializerType () { + protected getSerializerType() { return 'collection' as const } @@ -226,7 +234,7 @@ export class ResourceCollection< * * @returns The key to use for the response payload, or undefined if no key is needed. */ - private getPayloadKey () { + private getPayloadKey() { const { wrap, rootKey, factory } = this.resolveResponseStructure() return factory || !wrap ? undefined : rootKey @@ -237,13 +245,19 @@ export class ResourceCollection< * * @returns */ - json () { + json() { if (!this.called.json) { this.called.json = true const ctx = this.resolveSerializationContext() let data: ResourceData[] = this.data(ctx) as never + if (isPromiseLike(data)) { + this.called.json = false + + return this + } + if (this.collects && this.data === ResourceCollection.prototype.data) { data = data.map((item: any) => new this.collects!(item).data(ctx)) } @@ -302,12 +316,81 @@ export class ResourceCollection< return this } + /** + * Asynchronously convert resource to JSON response format + * + * @returns + */ + private async jsonAsync(): Promise { + if (!this.called.json) { + this.called.json = true + + const ctx = this.resolveSerializationContext() + let data: ResourceData[] = await this.data(ctx) as never + + if (this.collects && this.data === ResourceCollection.prototype.data) { + data = await Promise.all(data.map(async (item: any) => new this.collects!(item).data(ctx))) as ResourceData[] + } + + data = await normalizeSerializableDataAsync(data) as ResourceData[] + + data = sanitizeConditionalAttributes(data) as ResourceData[] + + const paginationExtras = !Array.isArray(this.resource) + ? buildPaginationExtras(this.resource) + : {} + + const { metaKey } = getPaginationExtraKeys() + const configuredMeta = metaKey ? paginationExtras[metaKey] : undefined + if (metaKey) { + delete paginationExtras[metaKey] + } + + const caseStyle = this.resolveSerializerCaseStyle( + this.constructor as typeof ResourceCollection, + this.resolveCollectsConfig() + ) + if (caseStyle) { + const transformer = getCaseTransformer(caseStyle) + data = transformKeys(data, transformer) as CollectionBody['data'] + } + + const customMeta = this.resolveMergedMeta(ResourceCollection.prototype.with) + + const { wrap, rootKey, factory } = this.resolveResponseStructure() + this.body = buildResponseEnvelope({ + payload: data, + meta: configuredMeta, + metaKey, + wrap, + rootKey, + factory, + context: { + type: 'collection', + resource: this.resource, + }, + }) as CollectionBody + + this.body = appendRootProperties( + this.body, + { + ...paginationExtras, + ...(customMeta || {}), + }, + rootKey + ) as CollectionBody + this.body = this.applySerializePlugins(this.body) as CollectionBody + } + + return undefined + } + /** * Convert resource to object format and return original data. * * @returns */ - toObject (): ( + toObject(): ( R extends Collectible ? R['data'][number] : R extends PaginatorLike @@ -323,6 +406,34 @@ export class ResourceCollection< return this.resolveObjectData(this.resolveSerializationContext()) as never } + /** + * Asynchronously convert resource to object format and return original data. + * + * @returns + */ + async toObjectAsync(): Promise<( + R extends Collectible + ? R['data'][number] + : R extends PaginatorLike + ? TPaginatorData + : R extends CollectionLike + ? TCollectionData + : R extends ResourceData[] + ? R[number] + : never + )[]> { + this.called.toObject = true + + const ctx = this.resolveSerializationContext() + let data = this.getSourceData() as ResourceData[] + + if (this.collects) { + data = await Promise.all(data.map(async (item: any) => new this.collects!(item).data(ctx))) as ResourceData[] + } + + return await normalizeSerializableDataAsync(data) as never + } + /** * Convert resource to object format and return original data. * @@ -330,7 +441,7 @@ export class ResourceCollection< * @alias toArray * @since 0.2.9 */ - toArray (): ( + toArray(): ( R extends Collectible ? R['data'][number] : R extends PaginatorLike @@ -352,7 +463,7 @@ export class ResourceCollection< * @param extra Additional properties to merge into the response body * @returns */ - additional> (extra: X) { + additional>(extra: X) { this.called.additional = true this.json() @@ -376,20 +487,20 @@ export class ResourceCollection< /** * Build a response object, optionally accepting a raw response to mutate in withResponse. */ - response (): ServerResponse> + response(): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * @param res Optional raw response object (e.g. Express Response or H3Event res) */ - response (res: H3Event['res']): ServerResponse> - response (res: Response): ServerResponse> + response(res: H3Event['res']): ServerResponse> + response(res: Response): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * * @param res Optional raw response object (e.g. Express Response or H3Event res) * @returns */ - response (res?: H3Event['res'] | Response): ServerResponse> { + response(res?: H3Event['res'] | Response): ServerResponse> { const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response return this.runResponse({ @@ -416,14 +527,14 @@ export class ResourceCollection< * * Override in custom classes to mutate headers/status/body. */ - withResponse ( + withResponse( _response?: ServerResponse>, _rawResponse?: Response | H3Event['res'] ): any { return this } - setCollects (collects: typeof Resource) { + setCollects(collects: typeof Resource) { this.collects = collects return this @@ -436,12 +547,14 @@ export class ResourceCollection< * @param onrejected Callback to handle the rejected state of the promise * @returns A promise that resolves to the result of the onfulfilled or onrejected callback */ - then, TResult2 = never> ( + then, TResult2 = never>( onfulfilled?: ((value: CollectionBody) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, createServerResponse: (raw, body) => { @@ -470,11 +583,13 @@ export class ResourceCollection< * @param onrejected * @returns */ - catch ( + catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null, ): Promise | TResult> { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, createServerResponse: (raw, body) => { @@ -502,9 +617,11 @@ export class ResourceCollection< * @param onfinally * @returns */ - finally (onfinally?: (() => void) | null) { + finally(onfinally?: (() => void) | null) { return this.runThen({ - ensureJson: () => this.json(), + ensureJson: async () => { + await this.jsonAsync() + }, body: () => this.body, rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, createServerResponse: (raw, body) => { diff --git a/src/utilities/arkorm.ts b/src/utilities/arkorm.ts index c328fec..bad7d8d 100644 --- a/src/utilities/arkorm.ts +++ b/src/utilities/arkorm.ts @@ -13,11 +13,34 @@ type ArkormLikeCollection = { type ResoraCollectionLike = { toObject: () => unknown + toObjectAsync?: () => Promise getBody: () => unknown + getBodyAsync?: () => Promise json: () => unknown setCollects: (...args: unknown[]) => unknown } +type ResoraSerializerLike = { + getBody: () => unknown + getBodyAsync?: () => Promise + json: () => unknown + toObject: () => unknown +} + +export const isPromiseLike = (value: unknown): value is PromiseLike => { + return !!value + && (typeof value === 'object' || typeof value === 'function') + && typeof (value as PromiseLike).then === 'function' +} + +const unwrapNestedSerializerBody = (body: unknown) => { + if (isPlainObject(body) && 'data' in body) { + return body.data + } + + return body +} + /** * Type guard to check if a value is an Arkorm-like model, which is defined as an object * that has a toObject method and optionally getRawAttributes, getAttribute, and @@ -72,6 +95,18 @@ export const isResoraCollectionLike = (value: unknown): value is ResoraCollectio && typeof candidate.setCollects === 'function' } +export const isResoraSerializerLike = (value: unknown): value is ResoraSerializerLike => { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Partial + + return typeof candidate.toObject === 'function' + && typeof candidate.getBody === 'function' + && typeof candidate.json === 'function' +} + /** * Normalize a value for serialization by recursively converting Arkorm-like models and * collections to plain objects, while preserving the structure of arrays and plain objects. @@ -88,6 +123,10 @@ export const normalizeSerializableData = (value: unknown): unknown => { return normalizeSerializableData(value.toObject()) } + if (isResoraSerializerLike(value)) { + return normalizeSerializableData(unwrapNestedSerializerBody(value.getBody())) + } + if (isArkormLikeModel(value)) { return normalizeSerializableData(value.toObject()) } @@ -112,3 +151,50 @@ export const normalizeSerializableData = (value: unknown): unknown => { return value } + +export const normalizeSerializableDataAsync = async (value: unknown): Promise => { + if (isResoraCollectionLike(value)) { + const object = typeof value.toObjectAsync === 'function' + ? await value.toObjectAsync() + : value.toObject() + + return normalizeSerializableDataAsync(object) + } + + if (isResoraSerializerLike(value)) { + const body = typeof value.getBodyAsync === 'function' + ? await value.getBodyAsync() + : value.getBody() + + return normalizeSerializableDataAsync(unwrapNestedSerializerBody(body)) + } + + const awaitedValue = isPromiseLike(value) + ? await value + : value + + if (Array.isArray(awaitedValue)) { + return Promise.all(awaitedValue.map(item => normalizeSerializableDataAsync(item))) + } + + if (isArkormLikeModel(awaitedValue)) { + return normalizeSerializableDataAsync(awaitedValue.toObject()) + } + + if (isArkormLikeCollection(awaitedValue)) { + return normalizeSerializableDataAsync(awaitedValue.all()) + } + + if (isPlainObject(awaitedValue)) { + const entries = await Promise.all( + Object.entries(awaitedValue).map(async ([key, nestedValue]) => [ + key, + await normalizeSerializableDataAsync(nestedValue), + ]) + ) + + return Object.fromEntries(entries) + } + + return awaitedValue +} diff --git a/tests/express.spec.ts b/tests/express.spec.ts index f3bdff3..1458f98 100644 --- a/tests/express.spec.ts +++ b/tests/express.spec.ts @@ -186,7 +186,7 @@ describe('Connect-style Requests (Express)', () => { it('should serialize nested collection instances in express responses', async () => { class FamilyMemberResource extends Resource { - data () { + data() { return { id: this.id, fullName: `${this.firstName} ${this.lastName}`, @@ -197,13 +197,13 @@ describe('Connect-style Requests (Express)', () => { class FamilyMemberCollection extends ResourceCollection { collects = FamilyMemberResource - data () { + data() { return this.toObject() } } class FamilyOverviewResource extends Resource { - data () { + data() { return { id: this.id, familyName: this.familyName, @@ -239,7 +239,7 @@ describe('Connect-style Requests (Express)', () => { it('should serialize nested collection toObject output in express responses', async () => { class FamilyMemberResource extends Resource { - data () { + data() { return { id: this.id, fullName: `${this.firstName} ${this.lastName}`, @@ -250,13 +250,13 @@ describe('Connect-style Requests (Express)', () => { class FamilyMemberCollection extends ResourceCollection { collects = FamilyMemberResource - data () { + data() { return this.toObject() } } class FamilyOverviewResource extends Resource { - data () { + data() { return { id: this.id, familyName: this.familyName, @@ -290,9 +290,52 @@ describe('Connect-style Requests (Express)', () => { }) }) + it('should support async resource data in express responses', async () => { + class ProfileResource extends Resource { + data() { + return { + id: this.id, + displayName: this.displayName, + } + } + } + + class UserResource extends Resource { + async data() { + return { + id: this.id, + name: this.name, + profile: new ProfileResource(this.profile), + } + } + } + + app.get('/test', async (_, res) => { + return await new UserResource({ + id: 1, + name: 'Jane', + profile: { + id: 10, + displayName: 'Jane Doe', + }, + }, res) + }) + + await request(app).get('/test').expect({ + data: { + id: 1, + name: 'Jane', + profile: { + id: 10, + displayName: 'Jane Doe', + }, + }, + }) + }) + it('should allow class-level withResponse hook to customize headers/status/body', async () => { class CustomResource extends Resource { - withResponse (response: ServerResponse) { + withResponse(response: ServerResponse) { response .header('X-From-Hook', '1') .setStatusCode(202) @@ -326,7 +369,7 @@ describe('Connect-style Requests (Express)', () => { data: { id: number; name: string }[] pagination?: { currentPage: number; total: number } }> { - withResponse () { + withResponse() { this.withResponseContext?.response.header('X-Collection-Hook', '1') const body = this.getBody()