Resources

1. What is a resource?

First we must define what a Resource means: A resource is a class which extends the following class:

export declare class Resource<T> {
  public id?: number;
  public save(): Promise<T>;
  public remove(): Promise<T>;
  public static one<T>(id: number | string, queryParams?: QueryParams): Promise<T>;
  public static findOne<T>(queryParams: QueryParams): Promise<T | void>;
  public static list<T>(queryParams?: QueryParams): Promise<T[]>;
  public static page<T>(queryParams?: QueryParams): Promise<Page<T>>;
}

To be able to use resources, we have to npm install lodash.merge first.

Lets say we want to define a Pokemon resource, we would go about it in the following way:

import { makeResource, Page } from '@42.nl/spring-connect';

export default class Pokemon extends makeResource<Pokemon>('/api/pokemon') {
  /* 
    These are the properties of the pokemon which it has when it is
    fetched from the back-end. Note that `!`, TypeScript needs this
    to accept that the properties will be there once the resource
    is loaded.

    When creating a `new Pokemon` all properties will actually be
    empty. But this is a nice trade-off to prevent the programmer
    from having to do excessive null checks.
  */

  id!: number;
  name!: string;
  types!: string[];
}

The argument to makeResource is the baseUrl used to fetch the Resource’ data from the REST API.

2. Adding a custom mapper

By default every JSON which is received by the one, findOne, page and list is mapped by this function:

function defaultMapper<T>(json: JSON, Class: { new (): T }): T {
  return makeInstance(Class, json);
}

It simply makes an instance of the class by calling the makeInstance util function with the retrieved JSON.

Sometimes you want to override the default mapper, in the example below we create a Pokeball which contains a pokemon. What we want is to make the pokemon an actual instance of Pokemon. We also want to store the time of retrieval from the back-end.

class Pokeball extends makeResource<Pokeball>({
  baseUrl: '/api/pokeball',
  mapper: pokeballMapper,
}) {
  public id?: number;

  /*
    In the actual JSON response pokemon is simply an object.
    But our custom mapper makes sure it will also get mapped.
  */
  public pokemon!: Pokemon;

  /* 
    Does not really exist on the back-end but is filled by the
    custom mapper.
  */
  public retrievedAt!: Date;
}

function pokeballMapper(json: any, Class: { new (): Pokeball }): Pokeball {
  const pokeball = makeInstance(Class, json);
  /* Add a completely new field */
  pokeball.retrievedAt = new Date();

  /* Make the fetched pokemon an actual instance of Pokemon */
  pokeball.pokemon = makeInstance(Pokemon, pokeball.pokemon);

  return pokeball;
}

A custom mapper is useful for when the mapping for one, findOne, page and list is exactly the same. If they differ you should instead create custom methods instead, as explained below.

3. Adding custom methods on Pokemon

For most situations the default Resource will work just fine, but sometimes you do want to extend and/or customize the available methods from makeResource.

The trick here is that this library exposes the same building blocks that makeResource uses under the hood. You can use these building blocks to easily create your own custom methods.

See the Utils section for the helper functions. It is recommended that you use these functions to help you customize your Resource.

3.1 Adding instance methods

Say you want to add method which retrieves all the evolutions of a Pokémon, this is how you do it:

import { get, makeInstance, makeResource } from '@42.nl/spring-connect';

const baseUrl = '/api/pokemon';

class Pokemon extends makeResource<Pokemon>(baseUrl) {
  /* shortend the definition of the pokemon class. */

  async evolutions(): Promise<Pokemon[]> {
    if (this.id) {
      /* `get` does a GET request  */
      const list = await get(`${baseUrl}/${this.id}/evolutions`);
      return list.map((properties: JSON) => {
        /* Convert to Pokemon instances */
        return makeInstance(Pokemon, properties);
      });
    }

    return Promise.resolve([]);
  }
}

Now you can use it in the following way:

const bulbasaur = await pokemon.one(1);
const evolutions = await pokemon.evolutions();

3.2 Adding static methods

You could also solve this problem with a static method:

import { get, makeInstance, makeResource } from '@42.nl/spring-connect';

const baseUrl = '/api/pokemon';

class Pokemon extends makeResource<Pokemon>(baseUrl) {
  /* shortend the definition of the pokemon class. */

  static async evolutions(id: number): Promise<Pokemon[]> {
    /* `get` does a GET request */
    const list = await get(`${baseUrl}/${id}/evolutions`);
    return list.map((properties: JSON) => {
      /* Convert to Pokemon instances */
      return makeInstance(Pokemon, properties);
    });
  }
}

Which you could use like this:

const evolutions = await Pokemon.evolutions(1);

4. Overriding methods on Pokemon

Sometimes you will find that the default implementations does not match your domain. For example there might be a difference between an Entity in a List / Page or when it is retrieved alone.

4.1 Overriding instance methods

You can override save and remove by simply defining them.

This example defines its own custom save implementation:

import { makeResource, post, put, Page } from '@42.nl/spring-connect';
import { merge } from 'lodash';

const baseUrl = '/api/pokemon';

export default class Pokemon extends makeResource<Pokemon>(baseUrl) {
  id!: number;
  trainer!: number;
  name!: string;
  types!: string[];
  weakness!: string[];

  /*
    Here we provide a custom implementation, which always creates
    a new pokemon, and never updates one.
  */
  save(): Promise<Pokemon> {
    return post(baseUrl, this).then((json: any) => {
      return merge(this, json);
    });
  }
}

4.2 Overriding static methods

You can override one, findOne, list and page by simply defining them.

This example defines its own custom page implementation:

import { makeResource, get, makeInstance, Page, QueryParams } from '@42.nl/spring-connect';

/* When a pokemon is retrieved in a page it has less info. */
export type PagePokemon = {
  id: number;
  name: string;
}

const baseUrl = '/api/pokemon';

export default class Pokemon extends makeResource<Pokemon>(baseUrl) {
  id!: number;
 
  name!: string;
  types!: string[];
  weakness!: string[];

  /*
    Here we provide a custom implementation, which returns a PagePokemon
    instead of a Pokemon.
  */
  static page<PagePokemon>(queryParams?: QueryParams): Promise<Page<PagePokemon>> {
    return get(baseUrl, queryParams);
  }
}

5. Changing the type of type id field.

You can change the type of the id field by supplying a second generic parameter to makeResource:


const baseUrl = '/api/pokemon';

class Pokemon extends makeResource<Pokemon, string>(baseUrl) {
  public id?: string;
  public name!: string;
  public types!: string[];
}

const pokemon = new Pokemon();

// This should now work because the type of ID is now a string.
pokemon.id = 'a-unique-uu-id-for-example';
pokemon.name = 'bulbasaur';
pokemon.types = ['poison', 'grass'];

await pokemon.save();

6 Retrieving a single Pokemon

The one method is used to retrieve a single Pokémon by its ID:

/* GET api/pokemon/1 */
const bulbasaur = await Pokemon.one(1);

Optionally you can add a second parameter to define the query parameters:

/* GET api/pokemon/1?active=true */
const bulbasaur = await Pokemon.one(42, { active: true });

7 Searching for one Pokemon

The findOne method is used to retrieve a single Pokémon based on query params:

/* GET api/pokemon?name=bulbasaur */
const bulbasaur = await Pokemon.findOne({ name: 'bulbasaur' });

Use when you want to find entities which do not have an ID, rr when you only want to find a single entity by a custom predicate.

8 Retrieving a page of Pokemon

The page method is used to retrieve a Page of Pokémon:

/* GET api/pokemon?page=1 */
const page = await Pokemon.page({ page: 1 });

9 Retrieving a list of Pokemon

We recommended using the page method whenever possible as it gives you more flexibility and is more memory efficient.

The list method is used to retrieve a list of Pokémon:

/* GET api/pokemon?limit=10 */
const pokemons = await Pokemon.list({ limit: 10 });

Useful when retrieving a fixed set of data with limited items.

10 Page vs List

Prefer page over list when possible because it is more memory efficient to load only a single slice of the data at a time.

It must also be noted that it is very unlikely that a REST endpoint supports both page and list at the same time. So use the variant which your back-end exposes.

11 A CRUD scenario

Once you have a Pokemon instance, either retrieved via one, list or page, or simply instantiated. You can save that Pokemon by calling save.

It will then either creates a new Pokemon by performing a POST when the id is empty, or updates an existing resource via a PUT request when the id exists.

/* 
  First create a pokemon by creating a new instance. 
  Or alternatively fetch the pokemon using `one`.
*/
const pokemon = new Pokemon();
pokemon.name = 'bulbasaur';
pokemon.type = ['grass', 'poison'];

/* This POST to api/pokemon, with all the properties of Pokemon as the body. */
pokemon.save().then(() => {
  /* 
    The pokemon instance will now have an ID.
    Because every property from the back-end response
    is merged into the pokemon instance automatically.
    This MUTATES the pokemon instance!
  */

  console.log(pokemon.id); // Prints "1";

  /* This PUT to api/pokemon/1, with all the properties of Pokemon as the body. */
  pokemon.save();

  /* This will DELETE to api/pokemon/1. */
  pokemon.remove().then(() => {
    /* 
      The pokemon instance will no longer have an id because
      `remove` will delete the `id` MUTATING the pokemon.
    */

    console.log(pokemon.id); /* Prints "undefined"; */
  });
});

12 Uploading

When uploading files it is important to know that post, put and patch support FormData as the payload argument.

Often it is easiest when uploading to simply create a static method called save which can take a form to upload.

Here is an example of a form, it contains sprites which are either Files or urls as a string:

export type PokemonFormData = {
  id?: number;
  name: string;

  spriteFront: string | File;
  spriteBack: string | File;
}

Heres a static save method on the Pokemon resource that creates a multipart/form-data; request.

import { makeResource, post, put } from '@42.nl/spring-connect';

const baseUrl = '/api/pokemon';

export default class Pokemon extends makeResource<Pokemon>(baseUrl) {
  id!: number;
  name!: string;

  spriteFront!: string;
  spriteBack!: string;

  /* This save either POST's a new pokemon, or PUTS to an existing one. */
  static save(pokemonForm: PokemonFormData): Promise<Pokemon> {
    /* The formData will contain the pokemon and optionally the two sprites. */
    const formData = new FormData();

    /* If there is a front sprite File add it to the formData. */
    if (pokemonForm.spriteFront instanceof File) {
      formData.append('front', new Blob([pokemonForm.spriteFront]));
    }

    /* If there is a back sprite File add it to the formData. */
    if (pokemonForm.spriteBack instanceof File) {
      formData.append('back', new Blob([pokemonForm.spriteBack]));
    }

    /* Now remove the sprites */
    delete pokemonForm.spriteFront;
    delete pokemonForm.spriteBack;

    /* Append the pokemon blob. */
    const pokemon = new Blob([JSON.stringify(pokemonForm)], {
      type: 'application/json',
    });
    formData.append('pokemon', pokemon);

    /* POST on create, PUT on edit. */
    const method = pokemonForm.id ? put : post;

    const url = pokemonForm.id ? `${baseUrl}/${pokemonForm.id}` : baseUrl;

    /* 
      Finally send the `multipart/form-data;` which has three entries:
      the front sprite, back sprite, and the pokemon data.
    */
    return method(url, formData);
  }
}