diff --git a/src/base_transformer.ts b/src/base_transformer.ts index 7ba7180..1793ee9 100644 --- a/src/base_transformer.ts +++ b/src/base_transformer.ts @@ -152,19 +152,24 @@ export abstract class BaseTransformer { } if (Array.isArray(unwrappedValue)) { - return new Collection([unwrappedValue, rest], this, 1, 'toObject', new RuntimeException()) + return new Collection([unwrappedValue, rest], this, 1, 'toObject') } if (unwrappedValue === null) { return null } + /** + * Capturing a stack trace is expensive, so the debugging error is only + * created when the item cannot be resolved. Creating it here keeps the + * stack pointing to the transformer call site. + */ return new Item, 1, 'toObject'>( [unwrappedValue, rest], this, 1, 'toObject', - new RuntimeException() + unwrappedValue === undefined ? new RuntimeException() : undefined ) } @@ -194,10 +199,7 @@ export abstract class BaseTransformer { metaData: Record, ...rest: ExtractTransformerRestTypes> ): Paginator, 1, 'toObject'> { - return new Paginator( - new Collection([data, rest], this, 1, 'toObject', new RuntimeException()), - metaData - ) + return new Paginator(new Collection([data, rest], this, 1, 'toObject'), metaData) } /** diff --git a/src/resource/collection.ts b/src/resource/collection.ts index b99eb08..721a707 100644 --- a/src/resource/collection.ts +++ b/src/resource/collection.ts @@ -46,7 +46,7 @@ export class Collection< /** * Private debugging error instance for troubleshooting */ - #debuggingError: RuntimeException + #debuggingError?: RuntimeException /** * Type identifier for the collection resource @@ -68,7 +68,7 @@ export class Collection< public transformer: { new (...args: any[]): Transformer }, public maxDepth: MaxDepth, public variant: Variant, - debuggingError: RuntimeException + debuggingError?: RuntimeException ) { this.#debuggingError = debuggingError } diff --git a/src/resource/item.ts b/src/resource/item.ts index 87e6e1d..1dedaf5 100644 --- a/src/resource/item.ts +++ b/src/resource/item.ts @@ -8,7 +8,7 @@ */ import type { ContainerResolver } from '@adonisjs/fold' -import type { RuntimeException } from '@poppinss/exception' +import { RuntimeException } from '@poppinss/exception' import { transformAndResolve } from '../helpers.ts' import type { ExtractTransformerVariants, Next, UnpackAsTopLevelItem } from '../types.ts' @@ -40,7 +40,7 @@ export class Item< /** * Private debugging error instance for troubleshooting */ - #debuggingError: RuntimeException + #debuggingError?: RuntimeException /** * Type identifier for the item resource @@ -55,14 +55,15 @@ export class Item< * @param transformer - Constructor for the transformer class * @param maxDepth - Maximum depth for nested transformations * @param variant - Variant method name to use for transformation - * @param debuggingError - Runtime exception for debugging purposes + * @param debuggingError - Error thrown when the resource is undefined. Create it at the + * transformer call site to get a useful stack trace */ constructor( protected transformerData: [resource: any, ...rest: any[]], public transformer: { new (...args: any[]): Transformer }, public maxDepth: MaxDepth, public variant: Variant, - debuggingError: RuntimeException + debuggingError?: RuntimeException ) { this.#debuggingError = debuggingError } @@ -153,6 +154,7 @@ export class Item< * If its undefined after unpacking, then throw an error */ if (resource === undefined) { + this.#debuggingError ??= new RuntimeException() this.#debuggingError.message = 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' throw this.#debuggingError diff --git a/tests/debugging_error.spec.ts b/tests/debugging_error.spec.ts new file mode 100644 index 0000000..dcbee5f --- /dev/null +++ b/tests/debugging_error.spec.ts @@ -0,0 +1,460 @@ +/* + * @adonisjs/http-transformers + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { RuntimeException } from '@poppinss/exception' +import { Item } from '../src/resource/item.ts' +import { BaseTransformer } from '../src/base_transformer.ts' +import { apiSerializer, container } from './helpers.ts' + +test.group('Transformer | undefined value stack trace', () => { + test('point to the caller of a top-level transform call', async ({ assert }) => { + assert.plan(3) + + class User { + declare id: number + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + } + } + } + + try { + await apiSerializer.serialize( + UserTransformer.transform(undefined as any), + container.createResolver() + ) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.instanceOf(error, RuntimeException) + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at UserTransformer\.transform /) + } + }) + + test('point to the transformer of a nested item', async ({ assert }) => { + assert.plan(3) + + class Profile { + declare bio: string + } + + class User { + declare id: number + declare profile: Profile + } + + class ProfileTransformer extends BaseTransformer { + toObject() { + return { + bio: this.resource.bio, + } + } + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + profile: ProfileTransformer.transform(this.resource.profile), + } + } + } + + const user = new User() + user.id = 1 + + try { + await apiSerializer.serialize(UserTransformer.transform(user), container.createResolver()) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at ProfileTransformer\.transform /) + assert.match(frames[2], /^\s+at UserTransformer\.toObject /) + } + }) + + test('point to the transformer of a nested item inside a collection', async ({ assert }) => { + assert.plan(3) + + class Profile { + declare bio: string + } + + class User { + declare id: number + declare profile: Profile + } + + class ProfileTransformer extends BaseTransformer { + toObject() { + return { + bio: this.resource.bio, + } + } + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + profile: ProfileTransformer.transform(this.resource.profile), + } + } + } + + const profile = new Profile() + profile.bio = 'Hello world' + + const user = new User() + user.id = 1 + user.profile = profile + + const userWithoutProfile = new User() + userWithoutProfile.id = 2 + + try { + await apiSerializer.serialize( + UserTransformer.transform([user, userWithoutProfile]), + container.createResolver() + ) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at ProfileTransformer\.transform /) + assert.match(frames[2], /^\s+at UserTransformer\.toObject /) + } + }) + + test('point to the transformer of a nested item inside a paginator', async ({ assert }) => { + assert.plan(3) + + class Profile { + declare bio: string + } + + class User { + declare id: number + declare profile: Profile + } + + class ProfileTransformer extends BaseTransformer { + toObject() { + return { + bio: this.resource.bio, + } + } + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + profile: ProfileTransformer.transform(this.resource.profile), + } + } + } + + const profile = new Profile() + profile.bio = 'Hello world' + + const user = new User() + user.id = 1 + user.profile = profile + + const userWithoutProfile = new User() + userWithoutProfile.id = 2 + + try { + await apiSerializer.serialize( + UserTransformer.paginate([user, userWithoutProfile], { total: 2, currentPage: 1 }), + container.createResolver() + ) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at ProfileTransformer\.transform /) + assert.match(frames[2], /^\s+at UserTransformer\.toObject /) + } + }) + + test('point to the transformer of an item inside a nested collection', async ({ assert }) => { + assert.plan(3) + + class User { + declare id: number + } + + class Comment { + declare id: number + declare author: User + } + + class Post { + declare id: number + declare comments: Comment[] + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + } + } + } + + class CommentTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + author: UserTransformer.transform(this.resource.author), + } + } + } + + class PostTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + comments: CommentTransformer.transform(this.resource.comments).depth(2), + } + } + } + + const comment = new Comment() + comment.id = 1 + + const post = new Post() + post.id = 1 + post.comments = [comment] + + try { + await apiSerializer.serialize(PostTransformer.transform(post), container.createResolver()) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at UserTransformer\.transform /) + assert.match(frames[2], /^\s+at CommentTransformer\.toObject /) + } + }) + + test('point to the transformer of a deeply nested item', async ({ assert }) => { + assert.plan(3) + + class Profile { + declare bio: string + } + + class User { + declare id: number + declare profile: Profile + declare posts: Post[] + } + + class Comment { + declare id: number + declare author: User + } + + class Post { + declare id: number + declare comments: Comment[] + } + + class ProfileTransformer extends BaseTransformer { + toObject() { + return { + bio: this.resource.bio, + } + } + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + profile: ProfileTransformer.transform(this.resource.profile), + posts: PostTransformer.transform(this.resource.posts).depth(4), + } + } + } + + class CommentTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + author: UserTransformer.transform(this.resource.author), + } + } + } + + class PostTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + comments: CommentTransformer.transform(this.resource.comments), + } + } + } + + const profile = new Profile() + profile.bio = 'Hello world' + + const authorWithoutProfile = new User() + authorWithoutProfile.id = 2 + authorWithoutProfile.posts = [] + + const comment = new Comment() + comment.id = 1 + comment.author = authorWithoutProfile + + const post = new Post() + post.id = 1 + post.comments = [comment] + + const user = new User() + user.id = 1 + user.profile = profile + user.posts = [post] + + try { + await apiSerializer.serialize(UserTransformer.transform(user), container.createResolver()) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at ProfileTransformer\.transform /) + assert.match(frames[2], /^\s+at UserTransformer\.toObject /) + } + }) + + test('point to the variant that transforms a nested item', async ({ assert }) => { + assert.plan(3) + + class Profile { + declare bio: string + } + + class User { + declare id: number + declare profile: Profile + } + + class ProfileTransformer extends BaseTransformer { + toObject() { + return { + bio: this.resource.bio, + } + } + + summary() { + return { + bio: this.resource.bio.slice(0, 10), + } + } + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + } + } + + withProfileSummary() { + return { + id: this.resource.id, + profile: ProfileTransformer.transform(this.resource.profile) + .useVariant('summary') + .depth(2), + } + } + } + + const user = new User() + user.id = 1 + + try { + await apiSerializer.serialize( + UserTransformer.transform(user).useVariant('withProfileSummary'), + container.createResolver() + ) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at ProfileTransformer\.transform /) + assert.match(frames[2], /^\s+at UserTransformer\.withProfileSummary /) + } + }) + + test('throw runtime exception for an item created without a debugging error', async ({ + assert, + }) => { + assert.plan(3) + + class User { + declare id: number + } + + class UserTransformer extends BaseTransformer { + toObject() { + return { + id: this.resource.id, + } + } + } + + try { + await apiSerializer.serialize( + new Item([undefined, []], UserTransformer, 1, 'toObject'), + container.createResolver() + ) + } catch (error: any) { + const frames = error.stack.split('\n') + + assert.instanceOf(error, RuntimeException) + assert.equal( + error.message, + 'Cannot transform undefined value. Use "this.whenLoaded(value)" to allow undefined values' + ) + assert.match(frames[1], /^\s+at Item\.resolve /) + } + }) +})