From 395a34e32e00c1cc36e9c820f5f1caa422b8f6ea Mon Sep 17 00:00:00 2001 From: Kevin Delisle Date: Tue, 16 Jan 2018 11:29:59 -0500 Subject: [PATCH] feat(example-getting-started): migrate into monorepo The loopback4-example-getting-started repository is now being moved into the monorepo --- packages/cli/generators/example/index.js | 2 + packages/cli/test/example.test.js | 6 +- packages/example-getting-started/.npmrc | 1 + packages/example-getting-started/LICENSE | 25 ++ packages/example-getting-started/README.md | 416 ++++++++++++++++++ .../config/datasources.json | 5 + packages/example-getting-started/data/db.json | 17 + packages/example-getting-started/index.d.ts | 6 + packages/example-getting-started/index.js | 14 + packages/example-getting-started/index.ts | 6 + packages/example-getting-started/package.json | 53 +++ .../src/application.ts | 62 +++ .../src/controllers/index.ts | 6 + .../src/controllers/todo.controller.ts | 56 +++ .../src/datasources/db.datasource.ts | 28 ++ packages/example-getting-started/src/index.ts | 19 + .../src/models/index.ts | 6 + .../src/models/todo.model.ts | 57 +++ .../src/repositories/index.ts | 6 + .../src/repositories/todo.repository.ts | 17 + .../test/acceptance/application.test.ts | 127 ++++++ .../example-getting-started/test/helpers.ts | 36 ++ .../unit/controllers/todo.controller.test.ts | 151 +++++++ .../tsconfig.build.json | 8 + 24 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 packages/example-getting-started/.npmrc create mode 100644 packages/example-getting-started/LICENSE create mode 100644 packages/example-getting-started/README.md create mode 100644 packages/example-getting-started/config/datasources.json create mode 100644 packages/example-getting-started/data/db.json create mode 100644 packages/example-getting-started/index.d.ts create mode 100644 packages/example-getting-started/index.js create mode 100644 packages/example-getting-started/index.ts create mode 100644 packages/example-getting-started/package.json create mode 100644 packages/example-getting-started/src/application.ts create mode 100644 packages/example-getting-started/src/controllers/index.ts create mode 100644 packages/example-getting-started/src/controllers/todo.controller.ts create mode 100644 packages/example-getting-started/src/datasources/db.datasource.ts create mode 100644 packages/example-getting-started/src/index.ts create mode 100644 packages/example-getting-started/src/models/index.ts create mode 100644 packages/example-getting-started/src/models/todo.model.ts create mode 100644 packages/example-getting-started/src/repositories/index.ts create mode 100644 packages/example-getting-started/src/repositories/todo.repository.ts create mode 100644 packages/example-getting-started/test/acceptance/application.test.ts create mode 100644 packages/example-getting-started/test/helpers.ts create mode 100644 packages/example-getting-started/test/unit/controllers/todo.controller.test.ts create mode 100644 packages/example-getting-started/tsconfig.build.json diff --git a/packages/cli/generators/example/index.js b/packages/cli/generators/example/index.js index 5ba3ab184106..75102579cad2 100644 --- a/packages/cli/generators/example/index.js +++ b/packages/cli/generators/example/index.js @@ -13,6 +13,8 @@ const utils = require('../../lib/utils'); const EXAMPLES = { codehub: 'A GitHub-like application we used to use to model LB4 API.', + 'getting-started': + 'An application and tutorial on how to build with LoopBack 4.', }; Object.freeze(EXAMPLES); diff --git a/packages/cli/test/example.test.js b/packages/cli/test/example.test.js index 27d8dca5a36e..91ef288ccf6d 100644 --- a/packages/cli/test/example.test.js +++ b/packages/cli/test/example.test.js @@ -34,9 +34,9 @@ describe('lb4 example', function() { it('lists all example names in help', () => { const helpText = getHelpText(); - expect(helpText).to.match( - new RegExp(Object.keys(ALL_EXAMPLES).join('.*')) - ); + for (const key of Object.keys(ALL_EXAMPLES)) { + expect(helpText).to.match(new RegExp(`${key}: (.*?)`)); + } }); function getHelpText() { diff --git a/packages/example-getting-started/.npmrc b/packages/example-getting-started/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/example-getting-started/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/example-getting-started/LICENSE b/packages/example-getting-started/LICENSE new file mode 100644 index 000000000000..f078f3676325 --- /dev/null +++ b/packages/example-getting-started/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017,2018. All Rights Reserved. +Node module: @loopback/example-getting-started +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/example-getting-started/README.md b/packages/example-getting-started/README.md new file mode 100644 index 000000000000..558b6360b212 --- /dev/null +++ b/packages/example-getting-started/README.md @@ -0,0 +1,416 @@ +# @loopback/example-getting-started + +This is the basic tutorial for getting started with Loopback 4! + +**NOTICE**: This tutorial is currently under construction! This notice will be +removed when it is ready for use! + +## Prerequisites + +Before we can begin, you'll need to make sure you have some things installed: +- [Node.js](https://nodejs.org/en/) at v6.x or greater + +Additionally, this tutorial assumes that you are comfortable with +certain technologies, languages and concepts. +- JavaScript (ES6) +- [npm](https://www.npmjs.com/) +- [REST](https://en.wikipedia.org/wiki/Representational_state_transfer) + +## Setup +1. Install the new loopback CLI toolkit. +``` +npm i -g @loopback/cli +``` +2. Download the "getting-started" application. +``` +lb4 example getting-started +``` + +3. Switch to the directory and install dependencies. +``` +cd loopback4-example-getting-started && npm i +``` + +4. Start the app! +``` +npm start +``` + +## Tutorial + +Here's a step-by-step guide of how to build this repository! + +### Create your app scaffolding +Install the `@loopback/cli` package. This will give you the command-line +toolkit that can generate a basic REST app for you. +`npm i -g @loopback/cli` + +Next, navigate to whichever directory you'd like to create your new project +and run `lb4`. Follow the prompts to generate your application. For this +tutorial, when prompted with the options for selecting things like whether or +not to enable certain project features (loopback's build, tslint, mocha, etc.), +leave them all enabled. + + + +### Adding Legacy Juggler Capabilities +Jump into the directory for your new application. You'll see a folder structure +similar to this: +``` +dist\ +node_modules\ +src\ + controllers\ + ping.controller.ts + README.md + repositories\ + README.md + application.ts + index.ts +test\ + mocha.opts + ping.controller.test.ts + README.md +index.js +index.d.ts +index.ts +``` + +The application template comes with a controller, and some default wireup in +`src/application.ts` that handles the basic configuration for your application. +For this tutorial, we won't need `ping.controller.ts` or its corresponding test, +but you can leave them in for now. + +Now that you have your setup, it's time to modify it to add in +`@loopback/repository`. Install this dependency by running +`npm i --save @loopback/repository`. + +Next, modify `src/application.ts` to change the base class of your app to use +the `RepositoryMixin`: + +#### src/application.ts +```ts +import {Application, ApplicationConfig} from '@loopback/core'; +import {RestComponent} from '@loopback/rest'; +import {PingController} from './controllers/ping-controller'; +import {Class, Repository, RepositoryMixin} from '@loopback/repository'; + +export class TodoApplication extends RepositoryMixin(Application) { + constructor(options?: ApplicationConfig) { + // Allow options to replace the defined components array, if desired. + options = Object.assign( + {}, + { + components: [RestComponent], + }, + options, + ); + super(options); + this.setupControllers(); + } + + setupControllers() { + this.controller(PingController); + } +} +``` + +### Building the Todo model +The Todo model will be the object we use both as a Data Transfer Object (DTO) on +the controller, and as a LoopBack model for the Legacy Juggler implementation. + +Create another folder in `src` called `repositories` and inside of that folder, +create two files: +- `index.ts` +- `todo.repository.ts` + +>**NOTE:** +The `index.ts` file is an export helper file; this pattern is a huge time-saver +as the number of models in your project grows, because it allows you to point +to the _directory_ when attempting to import types from a file within the target +folder. We will use this concept throughout the tutorial! +```ts +// in src/models/index.ts +export * from './foo.model'; +export * from './bar.model'; +export * from './baz.model'; + +// elsewhere... + +// with index.ts +import {Foo, Bar, Baz} from './models'; +// ...and without index.ts +import {Foo} from './models/foo.model'; +import {Bar} from './models/bar.model'; +import {Baz} from './models/baz.model'; +``` + +In our Todo model, we'll create a basic representation of what would go in +a Todo list. Our model will include: +- a unique id +- a title +- a description that details what the todo is all about +- a boolean flag for whether or not we've completed the task. + +For the Legacy Juggler to understand how to work with our model class, it +will need to extend the `Entity` type, as well as provide an override for +the `getId` function, so that it can retrieve a Todo model's ID as needed. + +Additionally, we'll define a `SchemaObject` that represents our Todo model +as an [OpenAPI Schema Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schema-object). +This will give the OpenAPI spec builder the information it needs to describe the +Todo model on your app's OpenAPI endpoints. + +#### src/models/todo.model.ts +```ts +import {Entity, property, model} from '@loopback/repository'; +import {SchemaObject} from '@loopback/openapi-spec'; + +@model() +export class Todo extends Entity { + @property({ + type: 'number', + id: true + }) + id?: number; + + @property({ + type: 'string', + required: true + }) + title: string; + + @property({ + type: 'string' + }) + desc?: string; + + @property({ + type: 'boolean' + }) + isComplete: boolean; + + getId() { + return this.id; + } +} + +export const TodoSchema: SchemaObject = { + title: 'todoItem', + properties: { + id: { + type: 'number', + description: 'ID number of the Todo entry.' + }, + title: { + type: 'string', + description: 'Title of the Todo entry.' + }, + desc: { + type: 'number', + description: 'ID number of the Todo entry.' + }, + isComplete: { + type: 'boolean', + description: 'Whether or not the Todo entry is complete.' + } + }, + required: ['title'], +}; +``` + +### Building a Datasource +Before we can begin constructing controllers and repositories for our +application, we need to define our datasource. + +Create a new folder in the root directory of the project called `config`, +and then inside that folder, create a `datasources.json` file. For now, we'll +be using the memory connector provided with the Juggler. + +#### config/datasources.json +```json +{ + "name": "ds", + "connector": "memory" +} +``` + +Create another folder called `datasources` in the `src` directory, and inside +that folder, create a new file called `db.datasource.ts`. + +#### src/datasources/db.datasource.ts + +```ts +import * as path from 'path'; +import * as fs from 'fs'; +import { DataSourceConstructor, juggler } from '@loopback/repository'; + +const dsConfigPath = path.resolve('config', 'datasources.json'); +const config = require(dsConfigPath); +export const db = new DataSourceConstructor(config); +``` + +This will give us a strongly-typed datasource export that we can work with to +construct our TodoRepository definition. + +### Create your repository +Create another folder in `src` called `repositories` and inside of that folder, +create two files: +- `index.ts` (our export helper) +- `todo.repository.ts` + +Our TodoRepository will contain a small base class that uses the +`DefaultCrudRepository` class from `@loopback/repository` and will define the +model type we're working with, as well as its ID type. We'll also inject our +datasource so that this repository can connect to it when executing data +operations. + +#### src/repositories/todo.repository.ts +```ts +import { DefaultCrudRepository, DataSourceType } from '@loopback/repository'; +import { Todo } from '../models'; +import { inject } from '@loopback/core'; + +export class TodoRepository extends DefaultCrudRepository< + Todo, + typeof Todo.prototype.id +> { + constructor(@inject('datasource') protected datasource: DataSourceType) { + super(Todo, datasource); + } +} +``` + + +### Create your controller +Now, we'll create a controller to handle our Todo routes. Create the +`src/controllers` directory and two files inside: +- `index.ts` (export helper) +- `todo.controller.ts` + +In addition to creating the CRUD methods themselves, we'll also be adding +decorators that setup the routing as well as the expected parameters of +incoming requests. + +#### src/controllers/todo.controller.ts +```ts +import {post, param, get, put, patch, del, HttpErrors} from '@loopback/rest'; +import {TodoSchema, Todo} from '../models'; +import {repository} from '@loopback/repository'; +import {TodoRepository} from '../repositories/index'; + +export class TodoController { + constructor( + @repository(TodoRepository.name) protected todoRepo: TodoRepository, + ) {} + @post('/todo') + @param.body('todo', TodoSchema) + async createTodo(todo: Todo) { + if (!todo.title) { + return Promise.reject(new HttpErrors.BadRequest('title is required')); + } + return await this.todoRepo.create(todo); + } + + @get('/todo/{id}') + @param.path.number('id') + @param.query.boolean('items') + async findTodoById(id: number, items?: boolean): Promise { + return await this.todoRepo.findById(id); + } + + @get('/todo') + async findTodos(): Promise { + return await this.todoRepo.find(); + } + + @put('/todo/{id}') + @param.path.number('id') + @param.body('todo', TodoSchema) + async replaceTodo(id: number, todo: Todo): Promise { + return await this.todoRepo.replaceById(id, todo); + } + + @patch('/todo/{id}') + @param.path.number('id') + @param.body('todo', TodoSchema) + async updateTodo(id: number, todo: Todo): Promise { + return await this.todoRepo.updateById(id, todo); + } + + @del('/todo/{id}') + @param.path.number('id') + async deleteTodo(id: number): Promise { + return await this.todoRepo.deleteById(id); + } +} +``` + +### Putting it all together + +Now that we've got all of our artifacts made, let's set them up in our +application! + +We'll define a new helper function for setting up the repositories, as well +as adding in our new controller binding. + +#### src/application.ts +```ts +import {Application, ApplicationConfig} from '@loopback/core'; +import {RestComponent} from '@loopback/rest'; +import {TodoController, PingController} from './controllers'; +import { + Class, + Repository, + RepositoryMixin, + DataSourceConstructor, +} from '@loopback/repository'; +import {db} from './datasources/db.datasource'; +import {TodoRepository} from './repositories'; + +export class TodoApplication extends RepositoryMixin(Application) { + constructor(options?: ApplicationConfig) { + // Allow options to replace the defined components array, if desired. + options = Object.assign( + {}, + { + components: [RestComponent], + }, + options, + ); + super(options); + this.setupControllers(); + this.setupRepositories(); + } + + setupControllers() { + this.controller(TodoController); + this.controller(PingController); + } + + setupRepositories() { + // This will allow you to test your application without needing to + // use the "real" datasource! + const datasource = + this.options && this.options.datasource + ? new DataSourceConstructor(this.options.datasource) + : db; + this.bind('datasource').to(datasource); + this.repository(TodoRepository); + } +} +``` + +### Try it out +Now that your app is ready to go, try it out with your favourite REST client! +Start the app (`npm start`) and then make some REST requests: +- `POST /todo` with a body of `{ "title": "get the milk" }` +- `GET /todo/1` and see if you get your Todo object back. +- `PATCH /todo/1` with a body of `{ "desc": "need milk for cereal" }` + +### Stuck? +Check out our [Gitter channel](https://gitter.im/strongloop/loopback) and ask +for help with this tutorial! + +### Bugs/Feedback +Open an issue in this repository **OR** on [loopback-next](https://github.com/strongloop/loopback-next) and we'll take a look! diff --git a/packages/example-getting-started/config/datasources.json b/packages/example-getting-started/config/datasources.json new file mode 100644 index 000000000000..2737944870a4 --- /dev/null +++ b/packages/example-getting-started/config/datasources.json @@ -0,0 +1,5 @@ +{ + "name": "ds", + "connector": "memory", + "file": "./data/db.json" +} diff --git a/packages/example-getting-started/data/db.json b/packages/example-getting-started/data/db.json new file mode 100644 index 000000000000..2cfe444d1590 --- /dev/null +++ b/packages/example-getting-started/data/db.json @@ -0,0 +1,17 @@ +{ + "ids": { + "Todo": 2, + "TodoItem": 5 + }, + "models": { + "Todo": { + "1": "{\"title\":\"Take over the galaxy\",\"desc\":\"MWAHAHAHAHAHAHAHAHAHAHAHAHAMWAHAHAHAHAHAHAHAHAHAHAHAHA\",\"id\":1}" + }, + "TodoItem": { + "1": "{\"title\":\"build death star\",\"todoId\":1,\"id\":1,\"checklist\":[{\"title\":\"create death star\"},{\"title\":\"destroy alderaan\"},{\"title\":\"terrorize senate\"}]}", + "2": "{\"title\":\"destroy alderaan\",\"todoId\":1,\"id\":2,\"checklist\":[{\"title\":\"create death star\"},{\"title\":\"destroy alderaan\"},{\"title\":\"terrorize senate\"}]}", + "3": "{\"title\":\"terrorize senate\",\"todoId\":1,\"id\":3,\"checklist\":[{\"title\":\"create death star\"},{\"title\":\"destroy alderaan\"},{\"title\":\"terrorize senate\"}]}", + "4": "{\"title\":\"crush rebel scum\",\"todoId\":1,\"id\":4}" + } + } +} \ No newline at end of file diff --git a/packages/example-getting-started/index.d.ts b/packages/example-getting-started/index.d.ts new file mode 100644 index 000000000000..13ed083fde3a --- /dev/null +++ b/packages/example-getting-started/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/example-getting-started/index.js b/packages/example-getting-started/index.js new file mode 100644 index 000000000000..20cb263c2a1f --- /dev/null +++ b/packages/example-getting-started/index.js @@ -0,0 +1,14 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +const dist = nodeMajorVersion >= 7 ? './dist' : './dist6'; + +const application = (module.exports = require(dist)); + +if (require.main === module) { + // Run the application + application.main(); +} diff --git a/packages/example-getting-started/index.ts b/packages/example-getting-started/index.ts new file mode 100644 index 000000000000..d1c9515a73ed --- /dev/null +++ b/packages/example-getting-started/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './src'; diff --git a/packages/example-getting-started/package.json b/packages/example-getting-started/package.json new file mode 100644 index 000000000000..65dbae5e218f --- /dev/null +++ b/packages/example-getting-started/package.json @@ -0,0 +1,53 @@ +{ + "name": "@loopback/example-getting-started", + "version": "1.0.0", + "description": "An application and tutorial on how to build with LoopBack 4.", + "private": true, + "main": "index.js", + "engines": { + "node": ">=8" + }, + "scripts": { + "acceptance": "lb-dist mocha --opts node_modules/@loopback/build/mocha.opts 'DIST/test/acceptance/**/*.js'", + "build": "npm run build:dist && npm run build:dist6", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-getting-started*.tgz dist dist6 package api-docs", + "prepare": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "test": "lb-dist mocha --opts node_modules/@loopback/build/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/acceptance/**/*.js'", + "unit": "lb-dist mocha --opts node_modules/@loopback/build/mocha.opts 'DIST/test/unit/**/*.js'", + "verify": "npm pack && tar xf loopback-getting-started*.tgz && tree package && npm run clean", + "start": "npm run build && node ." + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + }, + "license": "MIT", + "dependencies": { + "@loopback/context": "^4.0.0-alpha.25", + "@loopback/core": "^4.0.0-alpha.27", + "@loopback/openapi-v2": "^4.0.0-alpha.3", + "@loopback/repository": "^4.0.0-alpha.23", + "@loopback/rest": "^4.0.0-alpha.18", + "@types/sinon": "^2.3.6", + "sinon": "^4.1.5" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.8", + "@loopback/testlab": "^4.0.0-alpha.18", + "@types/mocha": "^2.2.46", + "@types/node": "^8.5.8", + "mocha": "^4.1.0", + "typescript": "^2.5.2" + }, + "keywords": [ + "loopback", + "LoopBack", + "example", + "tutorial" + ] +} diff --git a/packages/example-getting-started/src/application.ts b/packages/example-getting-started/src/application.ts new file mode 100644 index 000000000000..b1e37700aa69 --- /dev/null +++ b/packages/example-getting-started/src/application.ts @@ -0,0 +1,62 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application, ApplicationConfig} from '@loopback/core'; +import {RestComponent} from '@loopback/rest'; +import {TodoController} from './controllers'; +import {TodoRepository} from './repositories'; +import {db} from './datasources/db.datasource'; +/* tslint:disable:no-unused-imports */ +// Class and Repository imports required to infer types in consuming code! +// Do not remove them! +import { + Class, + Repository, + DataSourceConstructor, + RepositoryMixin, +} from '@loopback/repository'; +/* tslint:enable:no-unused-imports */ +export class TodoApplication extends RepositoryMixin(Application) { + constructor(options?: ApplicationConfig) { + // TODO(bajtos) The comment below does not make sense to me. + // Consumers of TodoApplication object should not be changing the shape + // of the app (what components are mounted, etc.) The config object should + // be used only to configure what ports the app is listening on, + // which database to connect to, etc. + // See https://github.com/strongloop/loopback-next/issues/742 + + // Allow options to replace the defined components array, if desired. + options = Object.assign( + {}, + { + components: [RestComponent], + }, + options, + ); + super(options); + this.setupRepositories(); + this.setupControllers(); + } + + // Helper functions (just to keep things organized) + setupRepositories() { + // TODO(bajtos) Automate datasource and repo registration via @loopback/boot + // See https://github.com/strongloop/loopback-next/issues/441 + const datasource = + this.options && this.options.datasource + ? new DataSourceConstructor(this.options.datasource) + : db; + // TODO(bajtos) use app.dataSource() from @loopback/repository mixin + // (app.dataSource() is not implemented there yet) + // See https://github.com/strongloop/loopback-next/issues/743 + this.bind('datasource').to(datasource); + this.repository(TodoRepository); + } + + setupControllers() { + // TODO(bajtos) Automate controller registration via @loopback/boot + this.controller(TodoController); + } +} diff --git a/packages/example-getting-started/src/controllers/index.ts b/packages/example-getting-started/src/controllers/index.ts new file mode 100644 index 000000000000..10f6afeed69b --- /dev/null +++ b/packages/example-getting-started/src/controllers/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './todo.controller'; diff --git a/packages/example-getting-started/src/controllers/todo.controller.ts b/packages/example-getting-started/src/controllers/todo.controller.ts new file mode 100644 index 000000000000..8f88213654ed --- /dev/null +++ b/packages/example-getting-started/src/controllers/todo.controller.ts @@ -0,0 +1,56 @@ +import {post, param, get, put, patch, del} from '@loopback/openapi-v2'; +import {HttpErrors} from '@loopback/rest'; +import {TodoSchema, Todo} from '../models'; +import {repository} from '@loopback/repository'; +import {TodoRepository} from '../repositories/index'; + +export class TodoController { + // TODO(bajtos) Fix documentation (and argument names?) of @repository() + // to allow the usage below. + // See https://github.com/strongloop/loopback-next/issues/744 + constructor( + @repository(TodoRepository.name) protected todoRepo: TodoRepository, + ) {} + @post('/todo') + @param.body('todo', TodoSchema) + async createTodo(todo: Todo) { + // TODO(bajtos) This should be handled by the framework + // See https://github.com/strongloop/loopback-next/issues/118 + if (!todo.title) { + return Promise.reject(new HttpErrors.BadRequest('title is required')); + } + return await this.todoRepo.create(todo); + } + + @get('/todo/{id}') + @param.path.number('id') + @param.query.boolean('items') + async findTodoById(id: number, items?: boolean): Promise { + return await this.todoRepo.findById(id); + } + + @get('/todo') + async findTodos(): Promise { + return await this.todoRepo.find(); + } + + @put('/todo/{id}') + @param.path.number('id') + @param.body('todo', TodoSchema) + async replaceTodo(id: number, todo: Todo): Promise { + return await this.todoRepo.replaceById(id, todo); + } + + @patch('/todo/{id}') + @param.path.number('id') + @param.body('todo', TodoSchema) + async updateTodo(id: number, todo: Todo): Promise { + return await this.todoRepo.updateById(id, todo); + } + + @del('/todo/{id}') + @param.path.number('id') + async deleteTodo(id: number): Promise { + return await this.todoRepo.deleteById(id); + } +} diff --git a/packages/example-getting-started/src/datasources/db.datasource.ts b/packages/example-getting-started/src/datasources/db.datasource.ts new file mode 100644 index 000000000000..de60ca796c71 --- /dev/null +++ b/packages/example-getting-started/src/datasources/db.datasource.ts @@ -0,0 +1,28 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as path from 'path'; +// The juggler reference must exist for consuming code to correctly infer +// type info used in the "db" export (contained in DataSourceConstructor). +/* tslint:disable-next-line:no-unused-imports */ +import {juggler, DataSourceConstructor} from '@loopback/repository'; + +const dsConfigPath = path.resolve( + __dirname, + '..', + '..', + '..', + 'config', + 'datasources.json', +); +const config = require(dsConfigPath); + +// TODO(bajtos) Ideally, datasources should be created by @loopback/boot +// and registered with the app for dependency injection. +// However, we need to investigate how to access these datasources from +// integration tests where we don't have access to the full app object. +// For example, @loopback/boot can provide a helper function for +// performing a partial boot that creates datasources only. +export const db = new DataSourceConstructor(config); diff --git a/packages/example-getting-started/src/index.ts b/packages/example-getting-started/src/index.ts new file mode 100644 index 000000000000..9d23ea75a801 --- /dev/null +++ b/packages/example-getting-started/src/index.ts @@ -0,0 +1,19 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {TodoApplication} from './application'; +import {RestServer} from '@loopback/rest'; + +export async function main() { + const app = new TodoApplication(); + try { + await app.start(); + } catch (err) { + console.error(`Unable to start application: ${err}`); + } + const server = await app.getServer(RestServer); + console.log(`Server is running on port ${await server.get('rest.port')}`); + return app; +} diff --git a/packages/example-getting-started/src/models/index.ts b/packages/example-getting-started/src/models/index.ts new file mode 100644 index 000000000000..d7c59a40ea98 --- /dev/null +++ b/packages/example-getting-started/src/models/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './todo.model'; diff --git a/packages/example-getting-started/src/models/todo.model.ts b/packages/example-getting-started/src/models/todo.model.ts new file mode 100644 index 000000000000..43a092bd481a --- /dev/null +++ b/packages/example-getting-started/src/models/todo.model.ts @@ -0,0 +1,57 @@ +import {Entity, property, model} from '@loopback/repository'; +import {SchemaObject} from '@loopback/openapi-spec'; + +@model() +export class Todo extends Entity { + @property({ + type: 'number', + id: true, + }) + id?: number; + + @property({ + type: 'string', + required: true, + }) + title: string; + + @property({ + type: 'string', + }) + desc?: string; + + @property({ + type: 'boolean', + }) + isComplete: boolean; + + getId() { + return this.id; + } +} + +// TODO(bajtos) The schema should be generated from model definition +// See https://github.com/strongloop/loopback-next/issues/700 +// export const TodoSchema = createSchemaFromModel(Todo); +export const TodoSchema: SchemaObject = { + title: 'todoItem', + properties: { + id: { + type: 'number', + description: 'ID number of the Todo entry.', + }, + title: { + type: 'string', + description: 'Title of the Todo entry.', + }, + desc: { + type: 'number', + description: 'ID number of the Todo entry.', + }, + isComplete: { + type: 'boolean', + description: 'Whether or not the Todo entry is complete.', + }, + }, + required: ['title'], +}; diff --git a/packages/example-getting-started/src/repositories/index.ts b/packages/example-getting-started/src/repositories/index.ts new file mode 100644 index 000000000000..148a9fba5b74 --- /dev/null +++ b/packages/example-getting-started/src/repositories/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './todo.repository'; diff --git a/packages/example-getting-started/src/repositories/todo.repository.ts b/packages/example-getting-started/src/repositories/todo.repository.ts new file mode 100644 index 000000000000..d8b2f0f8c995 --- /dev/null +++ b/packages/example-getting-started/src/repositories/todo.repository.ts @@ -0,0 +1,17 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {DefaultCrudRepository, DataSourceType} from '@loopback/repository'; +import {Todo} from '../models'; +import {inject} from '@loopback/core'; + +export class TodoRepository extends DefaultCrudRepository< + Todo, + typeof Todo.prototype.id +> { + constructor(@inject('datasource') protected datasource: DataSourceType) { + super(Todo, datasource); + } +} diff --git a/packages/example-getting-started/test/acceptance/application.test.ts b/packages/example-getting-started/test/acceptance/application.test.ts new file mode 100644 index 000000000000..30fb5ba4418f --- /dev/null +++ b/packages/example-getting-started/test/acceptance/application.test.ts @@ -0,0 +1,127 @@ +import {createClientForHandler, expect, supertest} from '@loopback/testlab'; +import {RestServer} from '@loopback/rest'; +import {TodoApplication} from '../../src/application'; +import {TodoRepository} from '../../src/repositories/index'; +import {givenTodo} from '../helpers'; +import {Todo} from '../../src/models/index'; + +describe('Application', () => { + let app: TodoApplication; + let server: RestServer; + let client: supertest.SuperTest; + let todoRepo: TodoRepository; + + before(givenAnApplication); + before(givenARestServer); + before(givenTodoRepository); + before(async () => { + await app.start(); + }); + before(() => { + client = createClientForHandler(server.handleHttp); + }); + after(async () => { + await app.stop(); + }); + + it('creates a todo', async () => { + const todo = givenTodo(); + const response = await client + .post('/todo') + .send(todo) + .expect(200); + expect(response.body).to.containEql(todo); + const result = await todoRepo.findById(response.body.id); + expect(result).to.containEql(todo); + }); + + it('gets a todo by ID', async () => { + const todo = await givenTodoInstance(); + await client + .get(`/todo/${todo.id}`) + .send() + .expect(200, todo); + }); + + it('replaces the todo by ID', async () => { + const todo = await givenTodoInstance(); + const updatedTodo = givenTodo({ + title: 'DO SOMETHING AWESOME', + desc: 'It has to be something ridiculous', + isComplete: true, + }); + await client + .put(`/todo/${todo.id}`) + .send(updatedTodo) + .expect(200); + const result = await todoRepo.findById(todo.id); + expect(result).to.containEql(updatedTodo); + }); + + it('updates the todo by ID ', async () => { + const todo = await givenTodoInstance(); + const updatedTodo = givenTodo({ + title: 'DO SOMETHING AWESOME', + isComplete: true, + }); + await client + .patch(`/todo/${todo.id}`) + .send(updatedTodo) + .expect(200); + const result = await todoRepo.findById(todo.id); + expect(result).to.containEql(updatedTodo); + }); + + it('deletes the todo', async () => { + const todo = await givenTodoInstance(); + await client + .del(`/todo/${todo.id}`) + .send() + .expect(200); + try { + await todoRepo.findById(todo.id); + } catch (err) { + expect(err).to.match(/No Todo found with id/); + return; + } + throw new Error('No error was thrown!'); + }); + + /* + ============================================================================ + TEST HELPERS + These functions help simplify setup of your test fixtures so that your tests + can: + - operate on a "clean" environment each time (a fresh in-memory database) + - avoid polluting the test with large quantities of setup logic to keep + them clear and easy to read + - keep them DRY (who wants to write the same stuff over and over?) + ============================================================================ + */ + function givenAnApplication() { + app = new TodoApplication({ + rest: { + port: 0, + }, + datasource: { + connector: 'memory', + }, + }); + } + + async function givenARestServer() { + server = await app.getServer(RestServer); + } + + async function givenTodoRepository() { + // TODO(bajtos) enhance RepositoryMixin to provide repository getter + // Example usage: + // todoRepo = await app.getRepository(TodoRepository.name) + // See https://github.com/strongloop/loopback-next/issues/745 + todoRepo = (await app.get('repositories.TodoRepository')) as TodoRepository; + } + + async function givenTodoInstance(todo?: Partial) { + return await todoRepo.create(givenTodo(todo)); + } +}); diff --git a/packages/example-getting-started/test/helpers.ts b/packages/example-getting-started/test/helpers.ts new file mode 100644 index 000000000000..a14e478f01f8 --- /dev/null +++ b/packages/example-getting-started/test/helpers.ts @@ -0,0 +1,36 @@ +import {Todo} from '../src/models/index'; + +/* + ============================================================================== + HELPER FUNCTIONS + If you find yourself creating the same helper functions across different + test files, then extracting those functions into helper modules is an easy + way to reduce duplication. + + Other tips: + + - Using the super awesome Partial type in conjunction with Object.assign + means you can: + * customize the object you get back based only on what's important + to you during a particular test + * avoid writing test logic that is brittle with respect to the properties + of your object + - Making the input itself optional means you don't need to do anything special + for tests where the particular details of the input don't matter. + ============================================================================== + * + +/** + * Generate a complete Todo object for use with tests. + * @param todo A partial (or complete) Todo object. + */ +export function givenTodo(todo?: Partial) { + return Object.assign( + new Todo({ + title: 'do a thing', + desc: 'There are some things that need doing', + isComplete: false, + }), + todo, + ); +} diff --git a/packages/example-getting-started/test/unit/controllers/todo.controller.test.ts b/packages/example-getting-started/test/unit/controllers/todo.controller.test.ts new file mode 100644 index 000000000000..860c2ba8c12b --- /dev/null +++ b/packages/example-getting-started/test/unit/controllers/todo.controller.test.ts @@ -0,0 +1,151 @@ +import {expect} from '@loopback/testlab'; +import {TodoController} from '../../../src/controllers'; +import {TodoRepository} from '../../../src/repositories'; +import * as sinon from 'sinon'; +import {Todo} from '../../../src/models/index'; +import {givenTodo} from '../../helpers'; + +describe('TodoController', () => { + let todoRepo: TodoRepository; + + /* + ============================================================================= + METHOD STUBS + These handles give us a quick way to fake the response of our repository + without needing to wrangle fake repository objects or manage real ones + in our tests themselves. + ============================================================================= + */ + let create: sinon.SinonStub; + let findById: sinon.SinonStub; + let find: sinon.SinonStub; + let replaceById: sinon.SinonStub; + let updateById: sinon.SinonStub; + let deleteById: sinon.SinonStub; + + /* + ============================================================================= + TEST VARIABLES + Combining top-level objects with our resetRepositories method means we don't + need to duplicate several variable assignments (and generation statements) + in all of our test logic. + + NOTE: If you wanted to parallelize your test runs, you should avoid this + pattern since each of these tests is sharing references. + ============================================================================= + */ + let controller: TodoController; + let aTodo: Todo; + let aTodoWithId: Todo; + let aChangedTodo: Todo; + let aTodoList: Todo[]; + + const noError = 'No error was thrown!'; + + beforeEach(resetRepositories); + describe('createTodo', () => { + it('creates a Todo', async () => { + create.resolves(aTodoWithId); + const result = await controller.createTodo(aTodo); + expect(result).to.eql(aTodoWithId); + sinon.assert.calledWith(create, aTodo); + }); + + it('throws if the payload is missing a title', async () => { + const todo = givenTodo(); + delete todo.title; + try { + await controller.createTodo(todo); + } catch (err) { + expect(err).to.match(/title is required/); + sinon.assert.notCalled(create); + return; + } + // Repository stub should not have been called! + throw new Error(noError); + }); + }); + + describe('findTodoById', () => { + it('returns a todo if it exists', async () => { + findById.resolves(aTodoWithId); + expect(await controller.findTodoById(aTodoWithId.id as number)).to.eql( + aTodoWithId, + ); + sinon.assert.calledWith(findById, aTodoWithId.id); + }); + }); + + describe('findTodos', () => { + it('returns multiple todos if they exist', async () => { + find.resolves(aTodoList); + expect(await controller.findTodos()).to.eql(aTodoList); + sinon.assert.called(find); + }); + + it('returns empty list if no todos exist', async () => { + const expected: Todo[] = []; + find.resolves(expected); + expect(await controller.findTodos()).to.eql(expected); + sinon.assert.called(find); + }); + }); + + describe('replaceTodo', () => { + it('successfully replaces existing items', async () => { + replaceById.resolves(true); + expect( + await controller.replaceTodo(aTodoWithId.id as number, aChangedTodo), + ).to.eql(true); + sinon.assert.calledWith(replaceById, aTodoWithId.id, aChangedTodo); + }); + }); + + describe('updateTodo', () => { + it('successfully updates existing items', async () => { + updateById.resolves(true); + expect( + await controller.updateTodo(aTodoWithId.id as number, aChangedTodo), + ).to.eql(true); + sinon.assert.calledWith(updateById, aTodoWithId.id, aChangedTodo); + }); + }); + + describe('deleteTodo', () => { + it('successfully deletes existing items', async () => { + deleteById.resolves(true); + expect(await controller.deleteTodo(aTodoWithId.id as number)).to.eql( + true, + ); + sinon.assert.calledWith(deleteById, aTodoWithId.id); + }); + }); + + function resetRepositories() { + todoRepo = sinon.createStubInstance(TodoRepository); + aTodo = givenTodo(); + aTodoWithId = givenTodo({ + id: 1, + }); + aTodoList = [ + aTodoWithId, + givenTodo({ + id: 2, + title: 'so many things to do', + }), + ] as Todo[]; + aChangedTodo = givenTodo({ + id: aTodoWithId.id, + title: 'Do some important things', + }); + + // Setup CRUD fakes + create = todoRepo.create as sinon.SinonStub; + findById = todoRepo.findById as sinon.SinonStub; + find = todoRepo.find as sinon.SinonStub; + updateById = todoRepo.updateById as sinon.SinonStub; + replaceById = todoRepo.replaceById as sinon.SinonStub; + deleteById = todoRepo.deleteById as sinon.SinonStub; + controller = new TodoController(todoRepo); + } +}); diff --git a/packages/example-getting-started/tsconfig.build.json b/packages/example-getting-started/tsconfig.build.json new file mode 100644 index 000000000000..ea242aa72eb2 --- /dev/null +++ b/packages/example-getting-started/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "test", "index.ts"] +}