DTOs and Builders
Schemas define the shape of our API resources, and can validate responses from the server.
Typically, a same or similar shape is also used for requests which create or update resources
via POST
or PUT
requests. In our case, the product
shape is the same wether we are
retrieving a product, or creating/updating a new one.
DTOs are objects which we send to the server. We can take advantage of the types we
Infer
red from our schemas.
We can automatically create Builder classes implementing the Builder Pattern
using the package @autometa/dto-builder
. Since we have already defined our schemas and interfaces,
we do not need to redeclare the properties on the class if we extends from the DTO
function, typed
with our interface:
Create a new file for DTOs, which will implement the interfaces generated by our schema
// src/products/product.dto.ts
import { Product } from "./product.schema";
import { DTO } from "@autometa/dto-builder";
export class ProductDTO extends DTO<Product> {}
Because the products
schema only applies to response payloads, we do not need to make a DTO for it.
If we wanted to define default values that are immediately available in the new DTO, we can
use the DTO.*
decorators. When applied, the builder will automatically add a property to the output
object containing the defined value. The value can be a raw primitive or object, a factory function which will
be evaluated lazily, or a child DTO which will also be instantiated with all default values;
// src/products/product.dto.ts
import { Product } from "./product.schema";
import { DTO } from "@autometa/dto-builder";
export class ProductDTO extends DTO<Product> {
@DTO.value("Foo Phone")
name: string;
@DTO.factory(() => "A phone that is foo")
description: string;
}
Now make a builder class file
// src/products/product.builder.ts
import { ProductDTO } from "./product.dto";
import { Builder } from "@autometa/dto-builder";
export class ProductBuilder extends Builder(ProductDTO) {}
Using a builder is simple. It has setter methods for each property decorated with @Property
,
which accept a value of the type of that propert on the underlying dto.
First we want to consider what our Gherkin might look like. We will want to create a new Product DTO using a builder, so we can use Cucumber Expressions to capture a name for the product, and create a new builder with that as the name.
# test/features/products.feature
Feature: Products
Scenario: Create a new product
Given I have a product named "Foo Phone"
We can create a step representing this to prepare our product
// test/steps/foo
import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";
Given("I have a product named {string}", (name, { world }) => {
world.productBuilder = new ProductBuilder().name(name);
});
Let's also declare this property on our world:
// test/world.ts
import { ProductBuilder, ProductResponse } from "../src/products";
export interface World {
productResponse: ProductResponse;
productBuilder: ProductBuilder;
}
We can also apply our builder pattern in ther Gherkin itself. We can use the *
step
type to add values to our builder.
# test/features/products.feature
Feature: Products
Scenario: Create a new product
Given I have a product named "Foo Phone"
* I have a product with description "A phone that is foo"
* I have a product with price 100
* I have a product with quantity 10
Since the *
follow a Given
we will define them as Given
:
// test/steps/products.steps.ts
import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";
Given("I set the product description to {string}", (description, { world }) => {
world.productBuilder = world.productBuilder.withDescription(description);
});
Given("I have a product with price {int}", (price, { world }) => {
world.productBuilder = world.productBuilder.withPrice(price);
});
Given("I set the product quantity to {int}", (quantity, { world }) => {
world.productBuilder = world.productBuilder.withQuantity(quantity);
});
This is fine but it creates a lot of steps. This doesn't cover all of our properties yet.
Since there is a direct reference to a property on our Product in the gherkin string, we can
use that to reduce the number of steps we need to write. We can also take advantage of the
built in primitive
type which will parse a value into a string, number, boolean, date, undefined,
or null. The builder has an additonal assign
method which accepts a string to map a value to it's
intended property
import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";
Given("I have a product named {string}", (name, { world }) => {
world.productBuilder = new ProductBuilder().withName(name);
});
Given(
"I set the product {string} to {primitive}",
(property, value, { world }) => {
world.productBuilder = world.productBuilder.assign(property, value);
}
);
We could further define the property key as it's own Cucumber Expression, so that it maps
to keyof Product
, which we'll look at in the next section.
Interfaces - Anonymous Object Builders
It might not be desirable to build your object as a class. When not used
to extend a class, the DTO
function will return an anonymous object builder,
with the same interface as the class builder.
import { Builder } from "@autometa/dto-builder";
interface IUser {
id: number;
name: string;
age: number;
}
const UserBuilder = Builder<IUser>();
Deriving a builder and default values
Since anonymous objects cannot be decorated, they cannot accept default values or factories which might change between instantiations.
To work around this, an anonymous builder is derivable
. Any values
assigned to the builder will stay until the builder is built. However
when the derive
method is called, a new builder will be created,
copying the values from the original. If those values are set agin
in the derived builder, they will not affect the original.
const bobBuilder = new UserBuilder().id(1).name("bob").age(23);
const olderBobBuilder = bobBuilder.derive().age(24);