Loopback 4

 

Building an API with TypeScript

@joeytwiddle

~joey

JavaScript developer

Enjoys writing userscripts for Greasemonkey,

shellscripts for Linux,

and dweets for Dwitter.

Favourite computer games: Homeworld, Unreal Tournament, Wipeout, Deus Ex

Dream: Save the environment

Options

  • Express
  • Restify
  • Koa
  • Sails.js
  • Hapi
  • Flatiron
  • Meteor
  • Lineman
  • Nest
  • Fastify
  • ...

What is Loopback?

  • DB-agnostic
  • Exposes API in Swagger format (openapi.json)
  • Automatically validates requests
  • Opinionated structure
  • Bootstrapping
  • Dependency injection (decoupling)
  • CLI for generating new files (REST)
  • Can integrate with GraphQL
  • TypeScript
  • Decorators
  • Prettier (optional)
  • lerna

"A platform for building APIs and microservices in Node.js"

lb4

$ npm install -g @loopback/cli

$ ls -l "$(which lb4)"

$ lb4
? Project name: my-new-project

Structure

  • Datasources (DBs, often only one)
  • Models
  • Repositories (combines one model and one datasource)
  • Controllers (maybe one per repository)
  • application.ts
  • sequence.ts

Structure

package.json


  // Default scripts

    "build": "lb-tsc",

    "build:watch": "lb-tsc --watch",

    "test": "lb-mocha --allow-console-logs \"dist/__tests__\"",


  // Empty the dist folder (removes any files from other branches, or renamed files)

    "build:watch": "npm run clean && lb-tsc --watch",


  // I want the app to auto-restart when the source code changes

    "start:watch": "nodemon -e '*' -w dist -d 2 -x 'node .'",


  // I want the tests and linters to run when the source code changes

    "test": "NODE_ENV=testing lb-mocha --allow-console-logs -b -t 16000 dist/__tests__",

    "test:watch-lite": "nice -n 12 nodemon -e '*' -w dist -d 2
                        -x 'npm run test && npm run lint'",

nodemon


  nodemon          \  # Watch for changes

      -e '*'       \  # to a file with any extension

      -w dist      \  # under the dist folder

      -d 2         \  # wait two seconds

      -x 'node .'     # then execute/restart node

Datasource

$ lb4 datasource

? Datasource name: db1
? Select the connector for db1: MongoDB (supported by StrongLoop)
? Connection String url to override other settings (eg: mongodb://username:password@hostname:port/database):
? host: localhost
? port: 27017
? user:
? password: [hidden]
? database: MyNewProject
   create src/datasources/db-1.datasource.json
   create src/datasources/db-1.datasource.ts

Do this once for each DB you have

Model

$ lb4 model

? Model class name: Student
? Please select the model base class Entity (A persisted model with an ID)
? Allow additional (free-form) properties? No

Let's add a property to Student
? Enter the property name: id
? Property type: string
? Is name the ID property? Yes
? Is it required?: Yes
? Default value [leave blank for none]:

Let's add a property to Student
? Enter the property name: name
? Property type: string
? Is name the ID property? No
? Is it required?: Yes
? Default value [leave blank for none]:

Let's add another property to Student
? Enter the property name: dateOfBirth

...

   create src/models/student.model.ts
   update src/models/index.ts

Model Student was created in src/models/

Model

import {
  Entity, model, property
} from '@loopback/repository';

@model({ settings: {} })
export class Student extends Entity {
  @property({
    type: 'string',
    id: true,
    required: true,
  })
  id: string;

  @property({
    type: 'string',
    required: true,
  })
  name: string;

  @property({
    type: 'date',
  })
  dateOfBirth?: Date;

  constructor(data?: Partial<Student>) {
    super(data);
  }
}

export class Student extends Entity {
  id: string;

  name: string;

  dateOfBirth?: Date;
}

Model

  @property({
    type: 'date',              // or number, boolean, string, object, buffer, ...
    required: false,           // must be passed on creation? (default false)
    default: () => new Date(), // can be a runtime function or a static value
  })
  dateOfBirth: Date;

// Will this property always be set, or not?  Recommend doing one of these:
//
//   required: true,
//
//     or
//
//   default: ...,         // not required for creation, but always set
//
//     or
//
//   dateOfBirth?: Date;   // mark the property as optional, with a ?
//

TypeScript will warn you if you try to use an optional field.
That is helpful!  It can prevent 'undefined' errors.

Repository

$ lb4 repository

? Please select the datasource Db_1Datasource
? Select the model(s) you want to generate a repository Student
? Please enter the name of the ID property for Student: id
   create src/repositories/student.repository.ts
   update src/repositories/index.ts

Repository Student was created in src/repositories/

Tells loopback where it can find a model (which datasource)

Do this once for each model

Controller

$ lb4 controller

? Controller class name: Student
? What kind of controller would you like to generate? REST Controller with CRUD functions
? What is the name of the model to use with this CRUD repository? Student
? What is the name of your CRUD repository? StudentRepository
? What is the type of your ID? string
? What is the base HTTP path name of the CRUD operations? /students
   create src/controllers/student.controller.ts
   update src/controllers/index.ts

Controller Student was created in src/controllers/

Generates standard REST routes for the given model

Do this once for each model you want to expose

.prettierrc

{
  "bracketSpacing": true,
  "singleQuote": true,
  "printWidth": 120,       <---
  "trailingComma": "all"
}

Fit more on one line

Other personal preferences

Controller


export class StudentController {

  // ...

  @post('/students', {
    responses: {
      '200': {
        description: 'Student model instance',
        content: {'application/json': {schema: {'x-ts-type': Student}}},
      },
    },
  })
  async create(@requestBody() student: Student): Promise<Student> {
    return await this.studentRepository.create(student);
  }

  // ...

}

Repository (DB) functions

studentRepo.create(studentObj)   // required fields must be provided

// Filter by age, sort, paginate, select fields
studentRepo.find({
  where: {dateOfBirth: {gt: new Date(2000, 0, 1)}},
  order: ['name ASC'],
  skip: 30,
  limit: 10,
  fields: {name: true, grade: true},
});

studentRepo.findOne(filter)     // returns null if not found

studentRepo.findById(id)        // throws Error if not found

studentRepo.updateById(id, data)

studentRepo.updateAll(data, where, opts)

studentRepo.deleteById(id)

studentRepo.deleteAll(where, opts)

TypeScript comfortably null


const student = await this.studentRepository.findOne({
  where: {
    name: { regexp: /^Aaron /i }
  }
});

// TS2531: Object is possibly 'null'.
console.log('student.name:', student.name);

// But this works
console.log('student.name:', student && student.name);

// Less safe, but if you are 100% sure
console.log('student.name:', student!.name);

// Guard
if (!student) {
  throw new HttpErrors.NotFound('Aaron is absent'); // or just return
}

// After the guard, TS knows student is defined
console.log('student.name:', student.name);

API Explorer

http://localhost:3000/explorer

API Explorer

GraphQL

$ npm i --save oasgraph-cli

$ npx oasgraph --cors http://localhost:3000/openapi.json
...
GraphQL accessible at: http://localhost:3001/graphql

# With CORS you can point GraphQL Playground at that URL

sequence.ts

export class MySequence implements SequenceHandler {
  constructor(
    @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
    @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
    @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
    @inject(SequenceActions.SEND) public send: Send,
    @inject(SequenceActions.REJECT) public reject: Reject,
  ) {}

  async handle(context: RequestContext) {
    try {
      const {request, response} = context;
      // Authentication will go here
      const route = this.findRoute(request);
      const args = await this.parseParams(request, route);
      const result = await this.invoke(route, args);
      this.send(response, result);
    } catch (err) {
      // We added some conditional logging here
      this.reject(context, err);
    }
  }
}

Authentication

export class WhoAmIController {
  constructor(
    // If you want access to this.user (provided by the auth provider) then you must inject it
    @inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile,
    // or to allow some unauthenticated routes in this controller
    @inject(AuthenticationBindings.CURRENT_USER, {optional: true}) private user: User,
  ) {}

  @authenticate('basic')    // Add this to every relevant route
  @get('/whoami')
  whoAmI(): string {
    return this.user.id;
  }
}

https://loopback.io/doc/en/lb4/Decorators_authenticate.html

https://loopback.io/doc/en/lb4/Loopback-component-authentication.html

https://github.com/strongloop/loopback-next/blob/master/packages/authentication

  1. Add two lines to `sequence.ts` (`inject` and `authenticateRequest`)
  2. Create an authentication strategy, e.g. `basic.strategy.ts`
  3. Add it to `application.ts` with two lines (`component` and `register`)
  4. Employ it in controllers using a decorator

Our approach (OAuth2):

  • Initial login: POST to /token with Basic header (BasicStrategy)
  • Auth each request: Each request passes Bearer header (BearerStrategy)

Dependency injection

export class MyController {


  // 1. Instead of this common pattern

  private userRepository: UserRepository;

  constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }



  // 2. We can just write this

  constructor(private userRepository: UserRepository) {
  }

  // That 'private' modifier in the arguments creates a this.userRepository


}

A TypeScript trick:

private / protected / public modifiers can be used to accept instance properties in the constructor

Dependency injection

Loopback keeps a Context registry of all repos, controllers, services, ...

Use `@repository()` or `@inject()` in the constructor

It will load an instance of the other class from the app context

So one controller can call functions in another

// Automatic dependency injection
export class UserController {
  constructor(
    @inject('controllers.PasswordController') protected passwordController: PasswordController,

    @repository(UserRepository) public userRepository: UserRepository,
    // does the same as
    @inject('repositories.UserRepository') public userRepository: UserRepository,
  ) {}

  // this.passwordController.clearPassword();
}



// Manually add our own service to the context (do this in the app's constructor)
app.bind('services.OldDataCleanupService')
  .toClass(OldDataCleanupService)
  .inScope(BindingScope.SINGLETON);

// Manually get it out later when we want to use it
const service = app.get<OldDataCleanupService>('services.OldDataCleanupService')

application.ts

export class MyNewProjectApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options: ApplicationConfig = {}) {
    super(options);

    // Set up the custom sequence
    this.sequence(MySequence);
    // === Add authentication code here === //

    // Set up default home page
    this.static('/', path.join(__dirname, '../../public'));

    // Customize @loopback/rest-explorer configuration here
    this.bind(RestExplorerBindings.CONFIG).to({
      path: '/explorer',
    });
    this.component(RestExplorerComponent);

    this.projectRoot = __dirname;
    // Customize @loopback/boot Booter Conventions here
    this.bootOptions = {
      controllers: {
        // Customize ControllerBooter Conventions here
        dirs: ['controllers'],
        extensions: ['.controller.js'],
        nested: true,
      },
    };
  }
  // When start() runs, the Booting happens
}

Tip: Background services

You want some code which runs always in the background?

Make a class, use @inject as much as you like,

then bind it to the app with this pattern:

export class MyTestApplication extends BootMixin(ServiceMixin(RepositoryMixin(RestApplication))) {
  constructor(options: ApplicationConfig = {}) {
    super(options);

    // ...

    // Add our own service into the registry
    this.bind('services.MyCleanupService')
      .toClass(MyCleanupService)
      .inScope(BindingScope.SINGLETON);
  }

  async start() {
    await super.start();
    const myCleanupService = await this.get<MyCleanupService>('services.MyCleanupService');
    await myCleanupService.startCleaning();
  }

  async stop() {
    await super.stop();
    const myCleanupService = await this.get<MyCleanupService>('services.MyCleanupService');
    await myCleanupService.destroy();
  }
}

Gotcha: Circular Dependencies

¯\_(ツ)_/¯

Sometimes you can make a third, separate controller for the common code, and both of the original troublemakers inject that one.

Gotcha: Calling functions without a server?

What if you want to call a controller from an external script?

Controllers with authentication explode if you try to use them outside of a RestServer!


But you can create a class with no rest dependencies, and add it to the server with `app.bind()`.


That class can be used without starting the server.

tsconfig.json

  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "allowJs": true,            // enable JS files
    "declaration": false,       // needed for allowJs
  },

Beware: Untyped JS code will silently leak 'any' values into your app!
(That's bad for safety, we should lose confidence in the type checking.)

 

Goal: Convert JS to TS when adding it to the project,

or write a .d.ts definition file for each .js file

To use JavaScript files in the project

tsconfig.json

  "compilerOptions": {
    // Don't stop compilation, let my IDE warn me
    "noUnusedLocals": false,
    // Easier to start with, but not recommended
    "noImplicitAny": false,
  },

Being less strict, for easy introduction

  "compilerOptions": {
    // This still lets you use 'any', just not by accident!
    "noImplicitAny": true,

    // Some other recommendations I have seen
    "strict": true
    "noImplicitReturns": true,
    "forceConsistentCasingInFileNames": true,
  },

Being more strict, the goal

tslint.json

  "linterOptions": {
    "exclude": ["src/lib"],
    "strictNullChecks": true
  },
  "rules": {
    "await-promise": true,
    "no-floating-promises": true,
    "no-unused": false
  }

Catches some common accidents with promises

No longer relevant.  The community is upgrading to typescript-eslint.

And no-floating-promises is now enabled by default anyway.

.eslintrc.js

module.exports = {
  extends: '@loopback/eslint-config',
  rules: {
    // Less strict, don't block hot fixes, clean up later
    "@typescript-eslint/no-unused-vars": 'warn',

    // More strict (recommended)
    // Prevents/discourages you from using 'any'
    // You can disable the rule when really needed
    "@typescript-eslint/no-explicit-any": 'error',
  }
};

Quick fix (when you really want to use 'any'):

Use // tslint:disable-next-line:no-any

Use // eslint-disable-next-line @typescript-eslint/no-explicit-any

then come back and fix the types later.

(In other situations, // @ts-ignore can work similarly for the next line.)
Or instead of 'any', use 'unknown' and type guards. (E.g. error logging.)

These markers make loss of type safety explicit, clearly visible in code review.

Tests

describe('PingController', () => {
  let app: FooApplication;
  let client: Client;

  before('setupApplication', async () => {
    ({app, client} = await setupApplication());
  });

  after(async () => {
    await app.stop();
  });

  it('invokes GET /ping', async () => {
    const res = await client.get('/ping?msg=world').expect(200);
    expect(res.body).to.containEql({greeting: 'Hello from LoopBack'});
  });
});

Debug logging

# Log everything

DEBUG="*" npm run test
# Example output (more colourful on console)

  loopback:build my-project/tsconfig.json found +4ms
  loopback:build Spawn node my-project/node_modules/typescript/lib/tsc.js -p tsconfig.json
      --outDir dist --target es2017 +0ms
  loopback:build my-project/node_modules/mocha/bin/mocha -b -t 16000 dist/test +0ms
  loopback:build Spawn node my-project/node_modules/mocha/bin/mocha -b -t 16000 dist/test +7ms
  mocha:suite bail true +1ms
  loopback:metadata:decorator BindElementProvider.constructor[0]:
      {"":[{"methodDescriptorOrParameterIndex":0,"bindingKey":
      {"key":"rest.http.request.context"},"metadata":{"decorator":"@inject"}}]} +0ms
  loopback:metadata:decorator FindRouteProvider.constructor[1]:
      {"":[null,{"methodDescriptorOrParameterIndex":1,"bindingKey":
      {"key":"rest.handler"},"metadata":{"decorator":"@inject"}}]} +4ms
  loopback:connector:mongodb all User
      { where: { id: 'd186da4eb' }, limit: 1, offset: 0, skip: 0 }
# Log only some channels

DEBUG="loopback:connector:*" node .

Data migration

// migrate.ts

await app.migrateSchema({existingSchema});



/**
 * Update or recreate the database schema for all repositories.
 *
 * **WARNING**: By default, `migrateSchema()` will attempt to preserve data
 * while updating the schema in your target database, but this is not
 * guaranteed to be safe.
 *
 * Please check the documentation for your specific connector(s) for
 * a detailed breakdown of behaviors for automigrate!
 *
 * @param options Migration options, e.g. whether to update tables
 * preserving data or rebuild everything from scratch.
 */
migrateSchema(options?: SchemaMigrationOptions): Promise<void>;

Everything else

You've seen all the generated code

 

Everything else is in the Loopback framework

 

If you are curious to see that code, you can find it here:

 

https://github.com/strongloop/loopback-next

 

I learned a lot from reading their code,

especially inside __tests__

Loopback: Summary

  • It gave us a tidy framework to work in
  • The type checking helps us code and gives us confidence
  • Sometimes it was a bit difficult!
    (You can ask me if you get stuck)
  • It was a nice way to learn TypeScript
    (before we started using it in React)

Typescript: the good


studentRepo.find({
  where: {dateOfBirth: {gt: new Date(1990, 0, 1)}},
  order: ['name ASC'],
  limit: 100,
  fields: {name: true, grade: true},
});
  • Autocompletion is really accurate (WebStorm, VSCode)
  • It immediately stops us making simple mistakes
  • We can see exactly what type(s) a library function returns
  • We can see exactly what arguments a library function requires (e.g. complicated options object)

Typescript: the bad

  • You often have to give things a type (extra work, JSDoc)
  • It will slow you down when you first start
  • You need to learn the workarounds for edge cases
  • You need to learn about generics (Array<number>)
  • Dictionaries? {apples: 3, oranges: 2}: Record<string, number>
  • https://www.typescriptlang.org/docs/
  • Google the tslint error codes
    (50% StackOverflow, 50% GitHub issues)

Typescript: the bad

Problem:

    If you have a variable/property of type `any`

    then TypeScript silently stops checking things!

 

But it's not clear when this is happening. False confidence.

 

Because you can import an `any` from another module.

 

Solution:

    Put `strict: true` in your tsconfig.json

    and `no-any` in your tslint.json

    Pray that the types you import from libraries

    do not use `any`.

Typescript: the bad

// A function with an options argument
function showThing(opts: { size?: number, enabled?: boolean }) {
  grow(opts.size);
}

// With destructuring, we have to write them again!
function showThing({ size, enabled }: { size?: number, enabled?: boolean }) {
  grow(size);
}

// An interface clears up the arguments, but at what cost?
interface ShowThingOptions {
  size?: number;
  enabled?: boolean;
}
function showThing({ size, enabled }: ShowThingOptions) {
  grow(size);
}

When deconstructing an object argument, we have to list the property names twice!

No remedy. Just live with it.

Typescript: tips

Problem: You know a variable/property is defined, but TypeScript might not believe so.

 

Solution: Use `variable!`

Solution: Sometimes a guard can work, sometimes not

// findOne might return null, if no matching record was found

const child = await this.userRepo.findOne({ parent: myid });


// If you are 100% sure that findOne will return a record, then use !

console.log('Age:', child!.age);

// Or use a guard (only works when child is an argument?)

if (!child) {
  throw Error('Expected to find a child!');
}
console.log('Age:', child.age);

Typescript: tips

Problem: You know a variable is of type A.

But TypeScript still thinks it is type `A | B | C`

Solution: Use `foo as A`

// Users can buy books or pens from the store
export class Purchase extends Entity {
  user: string;
  productType: 'book' | 'pen';             // enum in the decorator
  metaData: BookMetadata | PenMetadata;
}
export interface BookMetadata { title: string; hardCover: boolean; }
export interface PenMetadata { color: string; }

// Runtime.  We know this user purchased a pen:
const purchase = (
  await this.purchaseRepo.findOne({ user: userId, productType: 'pen' });
)!;

// TypeScript will complain that .color is not guaranteed inside metaData
const penColor = purchase.metaData.color;

// Make TypeScript happy by "casting"
const penColor = (<PenMetadata>purchase.metaData).color;

// Alternative syntax with `as` (recommended)
const penColor = (purchase.metaData as PenMetadata).color;

Typescript: the ugly

Problem: You know a variable is of type A.

But TypeScript thinks it is type F

Solution: Use `foo as unknown as A`

interface QuestionnaireAnswers {
  coding: number;
  javascript: number;
  typescript: number;
  restify: number;
  loopback: number;
}

function getRating(answers: QuestionnaireAnswers, category: string): number {

  // TypeScript says this lookup is not legal: answers does not offer free lookup
  return answers[category];

  // Temporarily change the type of answers, so the lookup can work
  return (answers as unknown as Record<string, number>)[category];
}

Typescript: the ugly

export const UnstyledButton = styled.button<React.HTMLProps<HTMLButtonElement>>`
  background-color: transparent;
  border-radius: 0;
  // ...
`;

// We made a module to reduce headaches

export type CommonHTMLProps =
  React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;

export type CommonDivProps =
  React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;

export type CommonInputProps =
  React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;

export type CommonButtonProps =
  React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;

export type CommonImageProps =
  React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>;

React? You may need to learn some crazy React types!

Typescript: tips

// If we don't specify the fields then
// setState expects us to pass all of them!

this.setState({ [key]: value });

// We can tell it which field we are passing

this.setState<'count'>({ count: value });

// Solution: tell it we are guaranteeing nothing

this.setState<never>({ [key]: value });

setState wants you to tell it what fields you will pass

The end

Slides:

https://slides.com/joeytwiddle/loopback4

 

Contact:

joeytwiddle @ Twitter, Dwitter, GitHub, GMail, ...

Loopback 4 - Building an API with TypeScript

By Joey Clark

Loopback 4 - Building an API with TypeScript

Demonstration of setting up a project with Loopback 4

  • 1,505