typestack / class-transformer

Decorator-based transformation, serialization, and deserialization between objects and classes.
MIT License
6.66k stars 487 forks source link

fix: plainToClass cannot handle URL values #1648

Closed littlefyr closed 1 month ago

littlefyr commented 8 months ago

Description

I have some classes that are defined to have URL values. When I attempt to use plainToClass in this situation, TypeError('Invalid URL') gets thrown. This is true even if I use @Transform to return a proper URL object.

Minimal code-snippet showcasing the problem

import { Transform, plainToClass } from 'class-transformer';

class TestClass {
    id: number;
    image: URL;
}

class TransformClass {
    id: number;
    @Transform(({ value }) => {
        return value instanceof URL ? value : new URL(value);
    })
    image: URL;
}
const inputObject = {
    id: 5,
    image: new URL('http://example.com/example.png'),
};
describe('Given: an arbitrary object', () => {
    describe('When: plainToClass is called', () => {
        it('Then: A new instance of TestClass should be created', () => {
            // Arrange

            // Act & Assert
            expect(() => {
                const result = plainToClass(TestClass, inputObject);

                // Assert that the result is an instance of TestClass
                expect(result).toBeInstanceOf(TestClass);

                // Assert that the 'image' property is an instance of URL
                expect(result.image).toBeInstanceOf(URL);
            }).not.toThrowError();
        });
        it('Then: A new instance of TransformClass should be created', () => {
            // Arrange
            // Act & Assert
            expect(() => {
                const result = plainToClass(TransformClass, inputObject);

                // Assert that the result is an instance of TestClass
                expect(result).toBeInstanceOf(TransformClass);

                // Assert that the 'image' property is an instance of URL
                expect(result.image).toBeInstanceOf(URL);
            }).not.toThrowError();
        });
    });
});

Expected behavior

The expected behaviour is a passing test.

Actual behavior

I think the test output speaks for itself:

> npx jest plainToClass
 FAIL  src/lib/plainToClassUrl.test.ts
  Given: an arbitrary object
    When: plainToClass is called
      ✕ Then: A new instance of TestClass should be created (74 ms)
      ✕ Then: A new instance of TransformClass should be created (7 ms)

  ● Given: an arbitrary object › When: plainToClass is called › Then: A new instance of TestClass should be created

    expect(received).not.toThrowError()

    Error name:    "TypeError"
    Error message: "Invalid URL"

          24 |             // Act & Assert
          25 |             expect(() => {
        > 26 |                 const result = plainToClass(TestClass, inputObject);
             |                                            ^
          27 |
          28 |                 // Assert that the result is an instance of TestClass
          29 |                 expect(result).toBeInstanceOf(TestClass);

          at TransformOperationExecutor.transform (node_modules/src/TransformOperationExecutor.ts:160:22)
          at TransformOperationExecutor.transform (node_modules/src/TransformOperationExecutor.ts:333:33)
          at ClassTransformer.plainToInstance (node_modules/src/ClassTransformer.ts:77:21)
          at plainToClass (node_modules/src/index.ts:71:27)
          at src/lib/plainToClassUrl.test.ts:26:44
          at Object.<anonymous> (node_modules/expect/build/toThrowMatchers.js:74:11)
          at Object.throwingMatcher [as toThrowError] (node_modules/expect/build/index.js:312:21)
          at Object.<anonymous> (src/lib/plainToClassUrl.test.ts:33:20)

      31 |                 // Assert that the 'image' property is an instance of URL
      32 |                 expect(result.image).toBeInstanceOf(URL);
    > 33 |             }).not.toThrowError();
         |                    ^
      34 |         });
      35 |         it('Then: A new instance of TransformClass should be created', () => {
      36 |             // Arrange

      at Object.<anonymous> (src/lib/plainToClassUrl.test.ts:33:20)

  ● Given: an arbitrary object › When: plainToClass is called › Then: A new instance of TransformClass should be created

    expect(received).not.toThrowError()

    Error name:    "TypeError"
    Error message: "Invalid URL"

          37 |             // Act & Assert
          38 |             expect(() => {
        > 39 |                 const result = plainToClass(TransformClass, inputObject);
             |                                            ^
          40 |
          41 |                 // Assert that the result is an instance of TestClass
          42 |                 expect(result).toBeInstanceOf(TransformClass);

          at TransformOperationExecutor.transform (node_modules/src/TransformOperationExecutor.ts:160:22)
          at TransformOperationExecutor.transform (node_modules/src/TransformOperationExecutor.ts:333:33)
          at ClassTransformer.plainToInstance (node_modules/src/ClassTransformer.ts:77:21)
          at plainToClass (node_modules/src/index.ts:71:27)
          at src/lib/plainToClassUrl.test.ts:39:44
          at Object.<anonymous> (node_modules/expect/build/toThrowMatchers.js:74:11)
          at Object.throwingMatcher [as toThrowError] (node_modules/expect/build/index.js:312:21)
          at Object.<anonymous> (src/lib/plainToClassUrl.test.ts:46:20)

      44 |                 // Assert that the 'image' property is an instance of URL
      45 |                 expect(result.image).toBeInstanceOf(URL);
    > 46 |             }).not.toThrowError();
         |                    ^
      47 |         });
      48 |     });
      49 | });

      at Object.<anonymous> (src/lib/plainToClassUrl.test.ts:46:20)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 total
Snapshots:   0 total
Time:        0.615 s, estimated 2 s
Ran all test suites matching /plainToClass/i.

The stack trace tells the problem, it is trying to do new URL() which isn't allowed for URLs.

I suspect this problem will be true of any Class that does not allow an empty constructor.

littlefyr commented 8 months ago

This is with version 0.5.1

littlefyr commented 8 months ago

I should add that I've also tested with @Type(() => URL) and get the same results

littlefyr commented 8 months ago

I just realized that this is related to the question in #1624

littlefyr commented 8 months ago

Its worth noting that in this specific case, the following does work:

class TypeTransformClass {
    id: number;

    @Type(() => String)
    @Transform(({ value }) => {
        return value instanceof URL ? value : new URL(value);
    })
    image: URL;
}

But its not entirely obvious, or generic, approach

diffy0712 commented 3 months ago

Hi,

In your inputObject you pass in a URL instance and class-transformer will call 'new Url()' on it because it infers the class from the plain object you passed in, but this happens before @Transform is called but after the @Type is called so that is why your code does not work correctly without @Type applied.

I think this might not be a correct behavior, because it should not create a new instance from the type it received from the plainObject, but copy it. (or give some way to control this behavior).

Currently I think there are two workarounds:

  1. Apply the @Type(() => String) before the custom transformer
  2. Do not pass the URL instance in the plainObject (or create some helper function around plainToClass/plainToInstance to call toString on URL instances in the plain object)
diffy0712 commented 1 month ago

Closing in favor of https://github.com/typestack/class-transformer/issues/1443

github-actions[bot] commented 3 weeks ago

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.