├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── data └── db.json ├── package-lock.json ├── package.json ├── public ├── chats.html └── index.html ├── src ├── __tests__ │ ├── acceptance │ │ ├── home-page.acceptance.ts │ │ ├── test-helper.ts │ │ └── todo.acceptance.ts │ ├── helpers.ts │ ├── integration │ │ └── services │ │ │ └── geocoder.service.integration.ts │ └── unit │ │ └── controllers │ │ └── todo.controller.unit.ts ├── application.ts ├── controllers │ ├── chat.controller.ws.ts │ ├── index.ts │ └── todo.controller.ts ├── datasources │ ├── db.datasource.ts │ ├── geocoder.datasource.ts │ └── index.ts ├── index.ts ├── migrate.ts ├── models │ ├── index.ts │ └── todo.model.ts ├── openapi-spec.ts ├── repositories │ ├── index.ts │ └── todo.repository.ts ├── sequence.ts ├── services │ ├── geocoder.service.ts │ └── index.ts └── websockets │ ├── decorators │ └── websocket.decorator.ts │ ├── websocket-controller-factory.ts │ ├── websocket.application.ts │ ├── websocket.booter.ts │ └── websocket.server.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | /dist 4 | # Cache used by TypeScript's incremental build 5 | *.tsbuildinfo 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | module.exports = { 7 | extends: ['@loopback/eslint-config'], 8 | }; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lerna-debug.log 2 | node_modules 3 | npm-debug.log 4 | .DS_Store 5 | coverage 6 | .nyc_output 7 | **/*.tgz 8 | fixtures/*/dist 9 | fixtures/tsdocs-monorepo/docs 10 | fixtures/tsdocs-monorepo/packages/pkg1/docs 11 | acceptance/*/dist 12 | packages/*/dist 13 | extensions/*/dist 14 | examples/*/dist 15 | benchmark/dist 16 | **/package 17 | .sandbox 18 | /docs/site/readmes 19 | /docs/site/changelogs 20 | /docs/site/CHANGELOG.md 21 | /docs/apidocs/reports-temp 22 | /docs/apidocs/models 23 | *.tsbuildinfo 24 | 25 | # TBD: Exclude api reports from git for now 26 | /docs/apidocs/reports 27 | /docs/site/apidocs 28 | 29 | # Exclude all files under sandbox except README.md and example 30 | /sandbox/* 31 | !/sandbox/README.md 32 | !/sandbox/example/ 33 | 34 | # ESLint cache 35 | .eslintcache 36 | 37 | # Docs preview 38 | docs/_loopback.io/ 39 | docs/_preview/ 40 | .idea/ 41 | dist/ 42 | .vscode 43 | .idea -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.json 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "trailingComma": "all", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Check out https://hub.docker.com/_/node to select a new base image 2 | FROM node:10-slim 3 | 4 | # Set to a non-root built-in user `node` 5 | USER node 6 | 7 | # Create app directory (with user `node`) 8 | RUN mkdir -p /home/node/app 9 | 10 | WORKDIR /home/node/app 11 | 12 | # Install app dependencies 13 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 14 | # where available (npm@5+) 15 | COPY --chown=node package*.json ./ 16 | 17 | RUN npm install 18 | 19 | # Bundle app source code 20 | COPY --chown=node . . 21 | 22 | RUN npm run build 23 | 24 | # Bind to all network interfaces so that it can be mapped to the host OS 25 | ENV HOST=0.0.0.0 PORT=3000 26 | 27 | EXPOSE ${PORT} 28 | CMD [ "node", "." ] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) IBM Corp. 2018,2019. 2 | Node module: @loopback/example-todo 3 | This project is licensed under the MIT License, full text below. 4 | 5 | -------- 6 | 7 | MIT license 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loopback4-example-websocket-app 2 | 3 | This example integrate a resp [Loopback 4](https://loopback.io/doc/en/lb4/) application [(socket.io)](https://socket.io). 4 | 5 | Based on: 6 | * [@loopback/examples-todo](https://github.com/strongloop/loopback-next/tree/master/examples/todo) 7 | * [raymondfeng/loopback4-example-websocket](https://github.com/raymondfeng/loopback4-example-websocket) 8 | 9 | ## Basic use 10 | 11 | ``` 12 | npm install 13 | npm start 14 | Open your browser to http://localhost:3000 15 | ``` 16 | 17 | ## Routes examples 18 | * /chat/{id:number} -> ChatControllerWs (socket.io connections) 19 | * /todo -> TodoController (HTTP Requests) 20 | 21 | ## License 22 | 23 | MIT 24 | 25 | ## Change Log 26 | 27 | ### 0.1.1 (2020-07-24) 28 | 29 | #### Features 30 | 31 | * WebSocketMetadata attribute name added 32 | * bind of io instance and namespace to inject in other controllers. 33 | 34 | ### 0.1.1 (2020-07-24) 35 | 36 | #### Features 37 | 38 | * integration websocket controller booter 39 | 40 | ### 0.1.0 (2020-07-24) 41 | 42 | #### Features 43 | 44 | * integration with websocket example [https://github.com/raymondfeng/loopback4-example-websocket](https://github.com/raymondfeng/loopback4-example-websocket) 45 | -------------------------------------------------------------------------------- /data/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "ids": { 3 | "Todo": 5 4 | }, 5 | "models": { 6 | "Todo": { 7 | "1": "{\"title\":\"Take over the galaxy\",\"desc\":\"MWAHAHAHAHAHAHAHAHAHAHAHAHAMWAHAHAHAHAHAHAHAHAHAHAHAHA\",\"id\":1}", 8 | "2": "{\"title\":\"destroy alderaan\",\"desc\":\"Make sure there are no survivors left!\",\"id\":2}", 9 | "3": "{\"title\":\"play space invaders\",\"desc\":\"Become the very best!\",\"id\":3}", 10 | "4": "{\"title\":\"crush rebel scum\",\"desc\":\"Every.Last.One.\",\"id\":4}" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-example-websocket-app", 3 | "version": "0.1.2", 4 | "description": "loopback-example-websocket-app", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "engines": { 8 | "node": ">=10.16" 9 | }, 10 | "author": "arondn2@gmail.com", 11 | "license": "MIT", 12 | "publishConfig": { 13 | "access": "public" 14 | }, 15 | "scripts": { 16 | "build": "lb-tsc", 17 | "build:watch": "lb-tsc --watch", 18 | "clean": "lb-clean *example-websocket-app*.tgz dist *.tsbuildinfo package", 19 | "lint": "npm run prettier:check && npm run eslint", 20 | "lint:fix": "npm run eslint:fix && npm run prettier:fix", 21 | "prettier:cli": "lb-prettier \"**/*.ts\"", 22 | "prettier:check": "npm run prettier:cli -- -l", 23 | "prettier:fix": "npm run prettier:cli -- --write", 24 | "eslint": "lb-eslint --report-unused-disable-directives .", 25 | "eslint:fix": "npm run eslint -- --fix", 26 | "pretest": "npm run build", 27 | "test": "lb-mocha \"dist/__tests__/**/*.js\"", 28 | "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", 29 | "verify": "npm pack && tar xf loopback-todo*.tgz && tree package && npm run clean", 30 | "migrate": "node ./dist/migrate", 31 | "openapi-spec": "node ./dist/openapi-spec", 32 | "prestart": "npm run build", 33 | "start": "node ." 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/arondn2/loopback4-example-websocket-app.git", 38 | "directory": "/" 39 | }, 40 | "dependencies": { 41 | "@loopback/boot": "^2.4.0", 42 | "@loopback/core": "^2.9.2", 43 | "@loopback/repository": "^2.10.0", 44 | "@loopback/rest": "^5.2.1", 45 | "@loopback/rest-explorer": "^2.2.7", 46 | "@loopback/service-proxy": "^2.3.5", 47 | "@types/socket.io": "^2.1.10", 48 | "@types/socket.io-client": "^1.4.33", 49 | "loopback-connector-rest": "^3.7.0", 50 | "morgan": "^1.10.0", 51 | "socket.io": "^2.3.0", 52 | "tslib": "^2.0.0" 53 | }, 54 | "devDependencies": { 55 | "@loopback/build": "^6.1.1", 56 | "@loopback/eslint-config": "^8.0.4", 57 | "@loopback/http-caching-proxy": "^2.1.10", 58 | "@loopback/testlab": "^3.2.1", 59 | "@types/lodash": "^4.14.157", 60 | "@types/morgan": "^1.9.1", 61 | "@types/node": "^10.17.27", 62 | "eslint": "^7.5.0", 63 | "lodash": "^4.17.19", 64 | "socket.io-client": "^2.3.0", 65 | "typescript": "~3.9.7" 66 | }, 67 | "keywords": [ 68 | "loopback", 69 | "example", 70 | "tutorial", 71 | "CRUD", 72 | "models", 73 | "todo", 74 | "websocket", 75 | "socket", 76 | "socket.io" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /public/chats.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO chat 5 | 56 | 57 | 58 | 59 |
60 | 61 | 62 |
63 | 64 | 65 | 66 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @loopback/example-todo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 55 | 56 | 57 | 58 |
59 |

@loopback/example-todo

60 | 61 |

OpenAPI spec: /openapi.json

62 |

API Explorer: /explorer

63 |

Chats: /chats.html

64 |
65 | 66 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/home-page.acceptance.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2019. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {Client} from '@loopback/testlab'; 7 | import {TodoListApplication} from '../..'; 8 | import {setupApplication} from './test-helper'; 9 | 10 | describe('HomePage', () => { 11 | let app: TodoListApplication; 12 | let client: Client; 13 | 14 | before('setupApplication', async () => { 15 | ({app, client} = await setupApplication()); 16 | }); 17 | 18 | after(async () => { 19 | await app.stop(); 20 | }); 21 | 22 | it('exposes a default home page', async () => { 23 | await client 24 | .get('/') 25 | .expect(200) 26 | .expect('Content-Type', /text\/html/); 27 | }); 28 | 29 | it('exposes self-hosted explorer', async () => { 30 | await client 31 | .get('/explorer/') 32 | .expect(200) 33 | .expect('Content-Type', /text\/html/) 34 | .expect(/LoopBack API Explorer/); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/test-helper.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2019. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {TodoListApplication} from '../..'; 7 | import { 8 | createRestAppClient, 9 | givenHttpServerConfig, 10 | Client, 11 | } from '@loopback/testlab'; 12 | 13 | export async function setupApplication(): Promise<AppWithClient> { 14 | const app = new TodoListApplication({ 15 | rest: givenHttpServerConfig(), 16 | }); 17 | 18 | await app.boot(); 19 | await app.start(); 20 | 21 | const client = createRestAppClient(app); 22 | 23 | return {app, client}; 24 | } 25 | 26 | export interface AppWithClient { 27 | app: TodoListApplication; 28 | client: Client; 29 | } 30 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/todo.acceptance.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2019,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {EntityNotFoundError} from '@loopback/repository'; 7 | import {Request, Response} from '@loopback/rest'; 8 | import { 9 | Client, 10 | createRestAppClient, 11 | expect, 12 | givenHttpServerConfig, 13 | toJSON, 14 | } from '@loopback/testlab'; 15 | import morgan from 'morgan'; 16 | import {TodoListApplication} from '../../application'; 17 | import {Todo} from '../../models/'; 18 | import {TodoRepository} from '../../repositories/'; 19 | import {Geocoder} from '../../services'; 20 | import { 21 | aLocation, 22 | getProxiedGeoCoderConfig, 23 | givenCachingProxy, 24 | givenTodo, 25 | HttpCachingProxy, 26 | isGeoCoderServiceAvailable, 27 | } from '../helpers'; 28 | 29 | describe('TodoApplication', () => { 30 | let app: TodoListApplication; 31 | let client: Client; 32 | let todoRepo: TodoRepository; 33 | 34 | let cachingProxy: HttpCachingProxy; 35 | before(async () => (cachingProxy = await givenCachingProxy())); 36 | after(() => cachingProxy.stop()); 37 | 38 | before(givenRunningApplicationWithCustomConfiguration); 39 | after(() => app.stop()); 40 | 41 | let available = true; 42 | before(async function (this: Mocha.Context) { 43 | this.timeout(30 * 1000); 44 | const service = await app.get<Geocoder>('services.Geocoder'); 45 | available = await isGeoCoderServiceAvailable(service); 46 | }); 47 | 48 | before(givenTodoRepository); 49 | before(() => { 50 | client = createRestAppClient(app); 51 | }); 52 | 53 | beforeEach(async () => { 54 | await todoRepo.deleteAll(); 55 | }); 56 | 57 | it('creates a todo', async function (this: Mocha.Context) { 58 | // Set timeout to 30 seconds as `post /todos` triggers geocode look up 59 | // over the internet and it takes more than 2 seconds 60 | this.timeout(30000); 61 | const todo = givenTodo(); 62 | const response = await client.post('/todos').send(todo).expect(200); 63 | expect(response.body).to.containDeep(todo); 64 | const result = await todoRepo.findById(response.body.id); 65 | expect(result).to.containDeep(todo); 66 | }); 67 | 68 | it('creates a todo with arbitrary property', async function () { 69 | const todo = givenTodo({tag: {random: 'random'}}); 70 | const response = await client.post('/todos').send(todo).expect(200); 71 | expect(response.body).to.containDeep(todo); 72 | const result = await todoRepo.findById(response.body.id); 73 | expect(result).to.containDeep(todo); 74 | }); 75 | 76 | it('rejects requests to create a todo with no title', async () => { 77 | const todo: Partial<Todo> = givenTodo(); 78 | delete todo.title; 79 | await client.post('/todos').send(todo).expect(422); 80 | }); 81 | 82 | it('rejects requests with input that contains excluded properties', async () => { 83 | const todo = givenTodo(); 84 | todo.id = 1; 85 | await client.post('/todos').send(todo).expect(422); 86 | }); 87 | 88 | it('creates an address-based reminder', async function (this: Mocha.Context) { 89 | if (!available) return this.skip(); 90 | // Increase the timeout to accommodate slow network connections 91 | this.timeout(30000); 92 | 93 | const todo = givenTodo({remindAtAddress: aLocation.address}); 94 | const response = await client.post('/todos').send(todo).expect(200); 95 | todo.remindAtGeo = aLocation.geostring; 96 | 97 | expect(response.body).to.containEql(todo); 98 | 99 | const result = await todoRepo.findById(response.body.id); 100 | expect(result).to.containEql(todo); 101 | }); 102 | 103 | it('returns 400 if it cannot find an address', async function (this: Mocha.Context) { 104 | if (!available) return this.skip(); 105 | // Increase the timeout to accommodate slow network connections 106 | this.timeout(30000); 107 | 108 | const todo = givenTodo({remindAtAddress: 'this address does not exist'}); 109 | const response = await client.post('/todos').send(todo).expect(400); 110 | 111 | expect(response.body.error.message).to.eql( 112 | 'Address not found: this address does not exist', 113 | ); 114 | }); 115 | 116 | context('when dealing with a single persisted todo', () => { 117 | let persistedTodo: Todo; 118 | 119 | beforeEach(async () => { 120 | persistedTodo = await givenTodoInstance(); 121 | }); 122 | 123 | it('gets a todo by ID', () => { 124 | return client 125 | .get(`/todos/${persistedTodo.id}`) 126 | .send() 127 | .expect(200, toJSON(persistedTodo)); 128 | }); 129 | 130 | it('returns 404 when getting a todo that does not exist', () => { 131 | return client.get('/todos/99999').expect(404); 132 | }); 133 | 134 | it('replaces the todo by ID', async () => { 135 | const updatedTodo = givenTodo({ 136 | title: 'DO SOMETHING AWESOME', 137 | desc: 'It has to be something ridiculous', 138 | isComplete: true, 139 | }); 140 | await client 141 | .put(`/todos/${persistedTodo.id}`) 142 | .send(updatedTodo) 143 | .expect(204); 144 | const result = await todoRepo.findById(persistedTodo.id); 145 | expect(result).to.containEql(updatedTodo); 146 | }); 147 | 148 | it('returns 404 when replacing a todo that does not exist', () => { 149 | return client.put('/todos/99999').send(givenTodo()).expect(404); 150 | }); 151 | 152 | it('updates the todo by ID ', async () => { 153 | const updatedTodo = givenTodo({ 154 | isComplete: true, 155 | }); 156 | await client 157 | .patch(`/todos/${persistedTodo.id}`) 158 | .send(updatedTodo) 159 | .expect(204); 160 | const result = await todoRepo.findById(persistedTodo.id); 161 | expect(result).to.containEql(updatedTodo); 162 | }); 163 | 164 | it('returns 404 when updating a todo that does not exist', () => { 165 | return client 166 | .patch('/todos/99999') 167 | .send(givenTodo({isComplete: true})) 168 | .expect(404); 169 | }); 170 | 171 | it('deletes the todo', async () => { 172 | await client.del(`/todos/${persistedTodo.id}`).send().expect(204); 173 | await expect(todoRepo.findById(persistedTodo.id)).to.be.rejectedWith( 174 | EntityNotFoundError, 175 | ); 176 | }); 177 | 178 | it('returns 404 when deleting a todo that does not exist', async () => { 179 | await client.del(`/todos/99999`).expect(404); 180 | }); 181 | }); 182 | 183 | context('allows logging to be reconfigured', () => { 184 | it('logs http requests', async () => { 185 | const logs: string[] = []; 186 | const logToArray = (str: string) => { 187 | logs.push(str); 188 | }; 189 | app.configure<morgan.Options<Request, Response>>('middleware.morgan').to({ 190 | stream: { 191 | write: logToArray, 192 | }, 193 | }); 194 | await client.get('/todos'); 195 | expect(logs.length).to.eql(1); 196 | expect(logs[0]).to.match(/"GET \/todos HTTP\/1\.1" 200 - "-"/); 197 | }); 198 | }); 199 | 200 | it('queries todos with a filter', async () => { 201 | await givenTodoInstance({title: 'wake up', isComplete: true}); 202 | 203 | const todoInProgress = await givenTodoInstance({ 204 | title: 'go to sleep', 205 | isComplete: false, 206 | }); 207 | 208 | await client 209 | .get('/todos') 210 | .query({filter: {where: {isComplete: false}}}) 211 | .expect(200, [toJSON(todoInProgress)]); 212 | }); 213 | 214 | it('exploded filter conditions work', async () => { 215 | await givenTodoInstance({title: 'wake up', isComplete: true}); 216 | await givenTodoInstance({ 217 | title: 'go to sleep', 218 | isComplete: false, 219 | }); 220 | 221 | const response = await client.get('/todos').query('filter[limit]=2'); 222 | expect(response.body).to.have.length(2); 223 | }); 224 | 225 | /* 226 | ============================================================================ 227 | TEST HELPERS 228 | These functions help simplify setup of your test fixtures so that your tests 229 | can: 230 | - operate on a "clean" environment each time (a fresh in-memory database) 231 | - avoid polluting the test with large quantities of setup logic to keep 232 | them clear and easy to read 233 | - keep them DRY (who wants to write the same stuff over and over?) 234 | ============================================================================ 235 | */ 236 | 237 | async function givenRunningApplicationWithCustomConfiguration() { 238 | app = new TodoListApplication({ 239 | rest: givenHttpServerConfig(), 240 | }); 241 | 242 | await app.boot(); 243 | 244 | /** 245 | * Override default config for DataSource for testing so we don't write 246 | * test data to file when using the memory connector. 247 | */ 248 | app.bind('datasources.config.db').to({ 249 | name: 'db', 250 | connector: 'memory', 251 | }); 252 | 253 | // Override Geocoder datasource to use a caching proxy to speed up tests. 254 | app 255 | .bind('datasources.config.geocoder') 256 | .to(getProxiedGeoCoderConfig(cachingProxy)); 257 | 258 | // Start Application 259 | await app.start(); 260 | } 261 | 262 | async function givenTodoRepository() { 263 | todoRepo = await app.getRepository(TodoRepository); 264 | } 265 | 266 | async function givenTodoInstance(todo?: Partial<Todo>) { 267 | return todoRepo.create(givenTodo(todo)); 268 | } 269 | }); 270 | -------------------------------------------------------------------------------- /src/__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2019,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {HttpCachingProxy} from '@loopback/http-caching-proxy'; 7 | import {merge} from 'lodash'; 8 | import path from 'path'; 9 | import {GeocoderDataSource} from '../datasources/geocoder.datasource'; 10 | import {Todo} from '../models/index'; 11 | import {Geocoder, GeoPoint} from '../services/geocoder.service'; 12 | 13 | /* 14 | ============================================================================== 15 | HELPER FUNCTIONS 16 | If you find yourself creating the same helper functions across different 17 | test files, then extracting those functions into helper modules is an easy 18 | way to reduce duplication. 19 | 20 | Other tips: 21 | 22 | - Using the super awesome Partial<T> type in conjunction with Object.assign 23 | means you can: 24 | * customize the object you get back based only on what's important 25 | to you during a particular test 26 | * avoid writing test logic that is brittle with respect to the properties 27 | of your object 28 | - Making the input itself optional means you don't need to do anything special 29 | for tests where the particular details of the input don't matter. 30 | ============================================================================== 31 | * 32 | 33 | /** 34 | * Generate a complete Todo object for use with tests. 35 | * @param todo - A partial (or complete) Todo object. 36 | */ 37 | export function givenTodo(todo?: Partial<Todo>) { 38 | const data = Object.assign( 39 | { 40 | title: 'do a thing', 41 | desc: 'There are some things that need doing', 42 | isComplete: false, 43 | }, 44 | todo, 45 | ); 46 | return new Todo(data); 47 | } 48 | 49 | export const aLocation = { 50 | address: '1 New Orchard Road, Armonk, 10504', 51 | geopoint: <GeoPoint>{y: 41.109653, x: -73.72467}, 52 | get geostring() { 53 | return `${this.geopoint.y},${this.geopoint.x}`; 54 | }, 55 | }; 56 | 57 | export function getProxiedGeoCoderConfig(proxy: HttpCachingProxy) { 58 | return merge({}, GeocoderDataSource.defaultConfig, { 59 | options: { 60 | proxy: proxy.url, 61 | tunnel: false, 62 | }, 63 | }); 64 | } 65 | 66 | export {HttpCachingProxy}; 67 | export async function givenCachingProxy() { 68 | const proxy = new HttpCachingProxy({ 69 | cachePath: path.resolve(__dirname, '.http-cache'), 70 | logError: false, 71 | timeout: 5000, 72 | }); 73 | await proxy.start(); 74 | return proxy; 75 | } 76 | 77 | export async function isGeoCoderServiceAvailable(service: Geocoder) { 78 | try { 79 | await service.geocode(aLocation.address); 80 | return true; 81 | } catch (err) { 82 | if (err.statusCode === 502) { 83 | return false; 84 | } 85 | throw err; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/__tests__/integration/services/geocoder.service.integration.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2019,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {expect} from '@loopback/testlab'; 7 | import {GeocoderDataSource} from '../../../datasources/geocoder.datasource'; 8 | import {Geocoder, GeocoderProvider} from '../../../services'; 9 | import { 10 | aLocation, 11 | getProxiedGeoCoderConfig, 12 | givenCachingProxy, 13 | HttpCachingProxy, 14 | isGeoCoderServiceAvailable, 15 | } from '../../helpers'; 16 | 17 | describe('GeoLookupService', function (this: Mocha.Suite) { 18 | this.timeout(30 * 1000); 19 | 20 | let cachingProxy: HttpCachingProxy; 21 | before(async () => (cachingProxy = await givenCachingProxy())); 22 | after(() => cachingProxy.stop()); 23 | 24 | let service: Geocoder; 25 | before(givenGeoService); 26 | 27 | let available = true; 28 | before(async () => { 29 | available = await isGeoCoderServiceAvailable(service); 30 | }); 31 | 32 | it('resolves an address to a geo point', async function (this: Mocha.Context) { 33 | if (!available) return this.skip(); 34 | 35 | const points = await service.geocode(aLocation.address); 36 | 37 | expect(points).to.deepEqual([aLocation.geopoint]); 38 | }); 39 | 40 | async function givenGeoService() { 41 | const config = getProxiedGeoCoderConfig(cachingProxy); 42 | const dataSource = new GeocoderDataSource(config); 43 | service = await new GeocoderProvider(dataSource).value(); 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /src/__tests__/unit/controllers/todo.controller.unit.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2019,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {Filter} from '@loopback/repository'; 7 | import { 8 | createStubInstance, 9 | expect, 10 | sinon, 11 | StubbedInstanceWithSinonAccessor, 12 | } from '@loopback/testlab'; 13 | import {TodoController} from '../../../controllers'; 14 | import {Todo} from '../../../models/index'; 15 | import {TodoRepository} from '../../../repositories'; 16 | import {Geocoder} from '../../../services'; 17 | import {aLocation, givenTodo} from '../../helpers'; 18 | 19 | describe('TodoController', () => { 20 | let todoRepo: StubbedInstanceWithSinonAccessor<TodoRepository>; 21 | let geoService: Geocoder; 22 | 23 | let geocode: sinon.SinonStub; 24 | 25 | /* 26 | ============================================================================= 27 | TEST VARIABLES 28 | Combining top-level objects with our resetRepositories method means we don't 29 | need to duplicate several variable assignments (and generation statements) 30 | in all of our test logic. 31 | 32 | NOTE: If you wanted to parallelize your test runs, you should avoid this 33 | pattern since each of these tests is sharing references. 34 | ============================================================================= 35 | */ 36 | let controller: TodoController; 37 | let aTodo: Todo; 38 | let aTodoWithId: Todo; 39 | let aChangedTodo: Todo; 40 | let aListOfTodos: Todo[]; 41 | 42 | beforeEach(resetRepositories); 43 | 44 | describe('createTodo', () => { 45 | it('creates a Todo', async () => { 46 | const create = todoRepo.stubs.create; 47 | create.resolves(aTodoWithId); 48 | const result = await controller.createTodo(aTodo); 49 | expect(result).to.eql(aTodoWithId); 50 | sinon.assert.calledWith(create, aTodo); 51 | }); 52 | 53 | it('resolves remindAtAddress to a geocode', async () => { 54 | const create = todoRepo.stubs.create; 55 | geocode.resolves([aLocation.geopoint]); 56 | 57 | const input = givenTodo({remindAtAddress: aLocation.address}); 58 | 59 | const expected = new Todo(input); 60 | Object.assign(expected, { 61 | remindAtAddress: aLocation.address, 62 | remindAtGeo: aLocation.geostring, 63 | }); 64 | create.resolves(expected); 65 | 66 | const result = await controller.createTodo(input); 67 | 68 | expect(result).to.eql(expected); 69 | sinon.assert.calledWith(create, input); 70 | sinon.assert.calledWith(geocode, input.remindAtAddress); 71 | }); 72 | }); 73 | 74 | describe('findTodoById', () => { 75 | it('returns a todo if it exists', async () => { 76 | const findById = todoRepo.stubs.findById; 77 | findById.resolves(aTodoWithId); 78 | expect(await controller.findTodoById(aTodoWithId.id as number)).to.eql( 79 | aTodoWithId, 80 | ); 81 | sinon.assert.calledWith(findById, aTodoWithId.id); 82 | }); 83 | }); 84 | 85 | describe('findTodos', () => { 86 | it('returns multiple todos if they exist', async () => { 87 | const find = todoRepo.stubs.find; 88 | find.resolves(aListOfTodos); 89 | expect(await controller.findTodos()).to.eql(aListOfTodos); 90 | sinon.assert.called(find); 91 | }); 92 | 93 | it('returns empty list if no todos exist', async () => { 94 | const find = todoRepo.stubs.find; 95 | const expected: Todo[] = []; 96 | find.resolves(expected); 97 | expect(await controller.findTodos()).to.eql(expected); 98 | sinon.assert.called(find); 99 | }); 100 | 101 | it('uses the provided filter', async () => { 102 | const find = todoRepo.stubs.find; 103 | const filter: Filter<Todo> = {where: {isComplete: false}}; 104 | 105 | find.resolves(aListOfTodos); 106 | await controller.findTodos(filter); 107 | sinon.assert.calledWith(find, filter); 108 | }); 109 | }); 110 | 111 | describe('replaceTodo', () => { 112 | it('successfully replaces existing items', async () => { 113 | const replaceById = todoRepo.stubs.replaceById; 114 | replaceById.resolves(); 115 | await controller.replaceTodo(aTodoWithId.id as number, aChangedTodo); 116 | sinon.assert.calledWith(replaceById, aTodoWithId.id, aChangedTodo); 117 | }); 118 | }); 119 | 120 | describe('updateTodo', () => { 121 | it('successfully updates existing items', async () => { 122 | const updateById = todoRepo.stubs.updateById; 123 | updateById.resolves(); 124 | await controller.updateTodo(aTodoWithId.id as number, aChangedTodo); 125 | sinon.assert.calledWith(updateById, aTodoWithId.id, aChangedTodo); 126 | }); 127 | }); 128 | 129 | describe('deleteTodo', () => { 130 | it('successfully deletes existing items', async () => { 131 | const deleteById = todoRepo.stubs.deleteById; 132 | deleteById.resolves(); 133 | await controller.deleteTodo(aTodoWithId.id as number); 134 | sinon.assert.calledWith(deleteById, aTodoWithId.id); 135 | }); 136 | }); 137 | 138 | function resetRepositories() { 139 | todoRepo = createStubInstance(TodoRepository); 140 | aTodo = givenTodo(); 141 | aTodoWithId = givenTodo({ 142 | id: 1, 143 | }); 144 | aListOfTodos = [ 145 | aTodoWithId, 146 | givenTodo({ 147 | id: 2, 148 | title: 'so many things to do', 149 | }), 150 | ] as Todo[]; 151 | aChangedTodo = givenTodo({ 152 | id: aTodoWithId.id, 153 | title: 'Do some important things', 154 | }); 155 | 156 | geoService = {geocode: sinon.stub()}; 157 | geocode = geoService.geocode as sinon.SinonStub; 158 | 159 | controller = new TodoController(todoRepo, geoService); 160 | } 161 | }); 162 | -------------------------------------------------------------------------------- /src/application.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import { BootMixin } from '@loopback/boot'; 7 | import { ApplicationConfig } from '@loopback/core'; 8 | import { RepositoryMixin } from '@loopback/repository'; 9 | import { Request, Response } from '@loopback/rest'; 10 | import { RestExplorerComponent } from '@loopback/rest-explorer'; 11 | import { ServiceMixin } from '@loopback/service-proxy'; 12 | import morgan from 'morgan'; 13 | import path from 'path'; 14 | import { MySequence } from './sequence'; 15 | import { WebsocketApplication } from "./websockets/websocket.application"; 16 | import { WebsocketControllerBooter } from "./websockets/websocket.booter"; 17 | 18 | export { ApplicationConfig }; 19 | 20 | export class TodoListApplication extends BootMixin( 21 | ServiceMixin(RepositoryMixin(WebsocketApplication)), 22 | ) { 23 | constructor(options: ApplicationConfig = {}) { 24 | super(options); 25 | 26 | // Set up the custom sequence 27 | this.sequence(MySequence); 28 | 29 | // Set up default home page 30 | this.static('/', path.join(__dirname, '../public')); 31 | 32 | this.component(RestExplorerComponent); 33 | 34 | this.booters(WebsocketControllerBooter); 35 | 36 | this.projectRoot = __dirname; 37 | // Customize @loopback/boot Booter Conventions here 38 | this.bootOptions = { 39 | controllers: { 40 | // Customize ControllerBooter Conventions here 41 | dirs: ['controllers'], 42 | extensions: ['.controller.js'], 43 | nested: true, 44 | }, 45 | websocketControllers: { 46 | dirs: ['controllers'], 47 | extensions: ['.controller.ws.js'], 48 | nested: true, 49 | }, 50 | }; 51 | 52 | this.setupLogging(); 53 | } 54 | 55 | private setupLogging() { 56 | // Register `morgan` express middleware 57 | // Create a middleware factory wrapper for `morgan(format, options)` 58 | const morganFactory = (config?: morgan.Options<Request, Response>) => { 59 | this.debug('Morgan configuration', config); 60 | return morgan('combined', config); 61 | }; 62 | 63 | // Print out logs using `debug` 64 | const defaultConfig: morgan.Options<Request, Response> = { 65 | stream: { 66 | write: str => { 67 | this._debug(str); 68 | }, 69 | }, 70 | }; 71 | this.expressMiddleware(morganFactory, defaultConfig, { 72 | injectConfiguration: 'watch', 73 | key: 'middleware.morgan', 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/controllers/chat.controller.ws.ts: -------------------------------------------------------------------------------- 1 | import {Socket} from 'socket.io'; 2 | import {ws} from '../websockets/decorators/websocket.decorator'; 3 | 4 | /** 5 | * A demo controller for websocket 6 | */ 7 | @ws({ name: 'chatNsp', namespace: /^\/chats\/\d+$/ }) 8 | export class ChatControllerWs { 9 | constructor( 10 | @ws.socket() // Equivalent to `@inject('ws.socket')` 11 | private socket: Socket, 12 | ) {} 13 | 14 | /** 15 | * The method is invoked when a client connects to the server 16 | * @param socket 17 | */ 18 | @ws.connect() 19 | connect(socket: Socket) { 20 | console.log('Client connected: %s', this.socket.id); 21 | socket.join('room 1'); 22 | // Room notification of request /todos/room/example/emit (TodoController) 23 | socket.join('some room'); 24 | } 25 | 26 | /** 27 | * Register a handler for 'chat message' events 28 | * @param msg 29 | */ 30 | @ws.subscribe('chat message') 31 | // @ws.emit('namespace' | 'requestor' | 'broadcast') 32 | handleChatMessage(msg: unknown) { 33 | console.log('Chat message: %s', msg); 34 | this.socket.nsp.emit('chat message', `[${this.socket.id}] ${msg}`); 35 | } 36 | 37 | /** 38 | * Register a handler for all events 39 | * @param msg 40 | */ 41 | @ws.subscribe(/.+/) 42 | logMessage(...args: unknown[]) { 43 | console.log('Message: %s', args); 44 | } 45 | 46 | /** 47 | * The method is invoked when a client disconnects from the server 48 | * @param socket 49 | */ 50 | @ws.disconnect() 51 | disconnect() { 52 | console.log('Client disconnected: %s', this.socket.id); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | export * from './todo.controller'; 7 | export * from './chat.controller.ws'; 8 | -------------------------------------------------------------------------------- /src/controllers/todo.controller.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import { inject } from '@loopback/core'; 7 | import { Filter, repository } from '@loopback/repository'; 8 | import { Server } from 'socket.io'; 9 | import { del, get, getModelSchemaRef, HttpErrors, param, patch, post, put, requestBody, } from '@loopback/rest'; 10 | import { Todo } from '../models'; 11 | import { TodoRepository } from '../repositories'; 12 | import { Geocoder } from '../services'; 13 | import { ws } from "../websockets/decorators/websocket.decorator"; 14 | 15 | export class TodoController { 16 | constructor( 17 | @repository(TodoRepository) protected todoRepository: TodoRepository, 18 | @inject('services.Geocoder') protected geoService: Geocoder, 19 | ) { 20 | } 21 | 22 | @post('/todos', { 23 | responses: { 24 | '200': { 25 | description: 'Todo model instance', 26 | content: { 'application/json': { schema: getModelSchemaRef(Todo) } }, 27 | }, 28 | }, 29 | }) 30 | async createTodo( 31 | @requestBody({ 32 | content: { 33 | 'application/json': { 34 | schema: getModelSchemaRef(Todo, { title: 'NewTodo', exclude: ['id'] }), 35 | }, 36 | }, 37 | }) 38 | todo: Omit<Todo, 'id'>, 39 | ): Promise<Todo> { 40 | if (todo.remindAtAddress) { 41 | const geo = await this.geoService.geocode(todo.remindAtAddress); 42 | 43 | if (!geo[0]) { 44 | // address not found 45 | throw new HttpErrors.BadRequest( 46 | `Address not found: ${todo.remindAtAddress}`, 47 | ); 48 | } 49 | // Encode the coordinates as "lat,lng" (Google Maps API format). See also 50 | // https://stackoverflow.com/q/7309121/69868 51 | // https://gis.stackexchange.com/q/7379 52 | todo.remindAtGeo = `${geo[0].y},${geo[0].x}`; 53 | } 54 | return this.todoRepository.create(todo); 55 | } 56 | 57 | @get('/todos/{id}', { 58 | responses: { 59 | '200': { 60 | description: 'Todo model instance', 61 | content: { 'application/json': { schema: getModelSchemaRef(Todo) } }, 62 | }, 63 | }, 64 | }) 65 | async findTodoById( 66 | @param.path.number('id') id: number, 67 | @param.query.boolean('items') items?: boolean, 68 | ): Promise<Todo> { 69 | return this.todoRepository.findById(id); 70 | } 71 | 72 | @get('/todos', { 73 | responses: { 74 | '200': { 75 | description: 'Array of Todo model instances', 76 | content: { 77 | 'application/json': { 78 | schema: { type: 'array', items: getModelSchemaRef(Todo) }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }) 84 | async findTodos( 85 | @param.filter(Todo) 86 | filter?: Filter<Todo>, 87 | ): Promise<Todo[]> { 88 | return this.todoRepository.find(filter); 89 | } 90 | 91 | @put('/todos/{id}', { 92 | responses: { 93 | '204': { 94 | description: 'Todo PUT success', 95 | }, 96 | }, 97 | }) 98 | async replaceTodo( 99 | @param.path.number('id') id: number, 100 | @requestBody() todo: Todo, 101 | ): Promise<void> { 102 | await this.todoRepository.replaceById(id, todo); 103 | } 104 | 105 | @patch('/todos/{id}', { 106 | responses: { 107 | '204': { 108 | description: 'Todo PATCH success', 109 | }, 110 | }, 111 | }) 112 | async updateTodo( 113 | @param.path.number('id') id: number, 114 | @requestBody({ 115 | content: { 116 | 'application/json': { 117 | schema: getModelSchemaRef(Todo, { partial: true }), 118 | }, 119 | }, 120 | }) 121 | todo: Partial<Todo>, 122 | ): Promise<void> { 123 | await this.todoRepository.updateById(id, todo); 124 | } 125 | 126 | @del('/todos/{id}', { 127 | responses: { 128 | '204': { 129 | description: 'Todo DELETE success', 130 | }, 131 | }, 132 | }) 133 | async deleteTodo( 134 | @param.path.number('id') id: number 135 | ): Promise<void> { 136 | await this.todoRepository.deleteById(id); 137 | } 138 | 139 | @post('/todos/room/example/emit') 140 | async exampleRoomEmmit( 141 | @ws.namespace('chatNsp') nsp: Server 142 | ): Promise<any> { 143 | nsp.to('some room').emit('some room event', `time: ${new Date().getTime()}`); 144 | console.log('exampleRoomEmmit'); 145 | return 'room event emitted'; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/datasources/db.datasource.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {inject} from '@loopback/core'; 7 | import {juggler} from '@loopback/repository'; 8 | 9 | const config = { 10 | name: 'db', 11 | connector: 'memory', 12 | localStorage: '', 13 | file: './data/db.json', 14 | }; 15 | 16 | export class DbDataSource extends juggler.DataSource { 17 | static dataSourceName = 'db'; 18 | static readonly defaultConfig = config; 19 | 20 | constructor( 21 | @inject('datasources.config.db', {optional: true}) 22 | dsConfig: object = config, 23 | ) { 24 | super(dsConfig); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/datasources/geocoder.datasource.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {inject} from '@loopback/core'; 7 | import {AnyObject, juggler} from '@loopback/repository'; 8 | 9 | const config = { 10 | name: 'geocoder', 11 | // A workaround for the current design flaw where inside our monorepo, 12 | // packages/service-proxy/node_modules/loopback-datasource-juggler 13 | // cannot see/load the connector from 14 | // examples/todo/node_modules/loopback-connector-rest 15 | connector: require('loopback-connector-rest'), 16 | options: { 17 | headers: { 18 | accept: 'application/json', 19 | 'content-type': 'application/json', 20 | }, 21 | timeout: 15000, 22 | }, 23 | operations: [ 24 | { 25 | template: { 26 | method: 'GET', 27 | url: 28 | 'https://geocoding.geo.census.gov/geocoder/locations/onelineaddress', 29 | query: { 30 | format: '{format=json}', 31 | benchmark: 'Public_AR_Current', 32 | address: '{address}', 33 | }, 34 | responsePath: '$.result.addressMatches[*].coordinates', 35 | }, 36 | functions: { 37 | geocode: ['address'], 38 | }, 39 | }, 40 | ], 41 | }; 42 | 43 | export class GeocoderDataSource extends juggler.DataSource { 44 | static dataSourceName = 'geocoder'; 45 | static readonly defaultConfig = config; 46 | 47 | constructor( 48 | @inject('datasources.config.geocoder', {optional: true}) 49 | dsConfig: AnyObject = config, 50 | ) { 51 | super(dsConfig); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/datasources/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | export * from './db.datasource'; 7 | export * from './geocoder.datasource'; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import { ApplicationConfig, TodoListApplication } from './application'; 7 | 8 | export async function main(options: ApplicationConfig = {}) { 9 | const app = new TodoListApplication(options); 10 | await app.boot(); 11 | await app.start(); 12 | 13 | const url = app.restServer.url; 14 | console.log(`Server is running at ${url}`); 15 | return app; 16 | } 17 | 18 | if (require.main === module) { 19 | const port = process.env.PORT ?? 3000; 20 | // Run the application 21 | const config = { 22 | rest: { 23 | port, 24 | host: process.env.HOST ?? 'localhost', 25 | openApiSpec: { 26 | // useful when used with OpenAPI-to-GraphQL to locate your application 27 | setServersFromRequest: true, 28 | }, 29 | }, 30 | websocket: { 31 | port 32 | } 33 | }; 34 | main(config).catch(err => { 35 | console.error('Cannot start the application.', err); 36 | process.exit(1); 37 | }); 38 | } 39 | 40 | // re-exports for our benchmark, not needed for the tutorial itself 41 | export * from '@loopback/rest'; 42 | export * from './application'; 43 | export * from './models'; 44 | export * from './repositories'; 45 | -------------------------------------------------------------------------------- /src/migrate.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {TodoListApplication} from './application'; 7 | 8 | export async function migrate(args: string[]) { 9 | const existingSchema = args.includes('--rebuild') ? 'drop' : 'alter'; 10 | console.log('Migrating schemas (%s existing schema)', existingSchema); 11 | 12 | const app = new TodoListApplication(); 13 | await app.boot(); 14 | await app.migrateSchema({existingSchema}); 15 | 16 | // Connectors usually keep a pool of opened connections, 17 | // this keeps the process running even after all work is done. 18 | // We need to exit explicitly. 19 | process.exit(0); 20 | } 21 | 22 | migrate(process.argv).catch(err => { 23 | console.error('Cannot migrate database schema', err); 24 | process.exit(1); 25 | }); 26 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | export * from './todo.model'; 7 | -------------------------------------------------------------------------------- /src/models/todo.model.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {Entity, model, property} from '@loopback/repository'; 7 | 8 | @model() 9 | export class Todo extends Entity { 10 | @property({ 11 | type: 'number', 12 | id: true, 13 | generated: false, 14 | }) 15 | id?: number; 16 | 17 | @property({ 18 | type: 'string', 19 | required: true, 20 | }) 21 | title: string; 22 | 23 | @property({ 24 | type: 'string', 25 | }) 26 | desc?: string; 27 | 28 | @property({ 29 | type: 'boolean', 30 | }) 31 | isComplete?: boolean; 32 | 33 | @property({ 34 | type: 'string', 35 | }) 36 | remindAtAddress?: string; // address,city,zipcode 37 | 38 | // TODO(bajtos) Use LoopBack's GeoPoint type here 39 | @property({ 40 | type: 'string', 41 | }) 42 | remindAtGeo?: string; // latitude,longitude 43 | 44 | @property({ 45 | type: 'any', 46 | }) 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | tag?: any; 49 | 50 | constructor(data?: Partial<Todo>) { 51 | super(data); 52 | } 53 | } 54 | 55 | export interface TodoRelations { 56 | // describe navigational properties here 57 | } 58 | 59 | export type TodoWithRelations = Todo & TodoRelations; 60 | -------------------------------------------------------------------------------- /src/openapi-spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {ApplicationConfig} from '@loopback/core'; 7 | import {TodoListApplication} from './application'; 8 | 9 | /** 10 | * Export the OpenAPI spec from the application 11 | */ 12 | async function exportOpenApiSpec(): Promise<void> { 13 | const config: ApplicationConfig = { 14 | rest: { 15 | port: +(process.env.PORT ?? 3000), 16 | host: process.env.HOST ?? 'localhost', 17 | }, 18 | }; 19 | const outFile = process.argv[2] ?? ''; 20 | const app = new TodoListApplication(config); 21 | await app.boot(); 22 | await app.exportOpenApiSpec(outFile); 23 | } 24 | 25 | exportOpenApiSpec().catch(err => { 26 | console.error('Fail to export OpenAPI spec from the application.', err); 27 | process.exit(1); 28 | }); 29 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | export * from './todo.repository'; 7 | -------------------------------------------------------------------------------- /src/repositories/todo.repository.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2019. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {inject} from '@loopback/core'; 7 | import {DefaultCrudRepository, juggler} from '@loopback/repository'; 8 | import {Todo, TodoRelations} from '../models'; 9 | 10 | export class TodoRepository extends DefaultCrudRepository< 11 | Todo, 12 | typeof Todo.prototype.id, 13 | TodoRelations 14 | > { 15 | constructor(@inject('datasources.db') dataSource: juggler.DataSource) { 16 | super(Todo, dataSource); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/sequence.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {inject} from '@loopback/core'; 7 | import { 8 | FindRoute, 9 | InvokeMethod, 10 | InvokeMiddleware, 11 | ParseParams, 12 | Reject, 13 | RequestContext, 14 | RestBindings, 15 | Send, 16 | SequenceHandler, 17 | } from '@loopback/rest'; 18 | 19 | const SequenceActions = RestBindings.SequenceActions; 20 | 21 | export class MySequence implements SequenceHandler { 22 | /** 23 | * Optional invoker for registered middleware in a chain. 24 | * To be injected via SequenceActions.INVOKE_MIDDLEWARE. 25 | */ 26 | @inject(SequenceActions.INVOKE_MIDDLEWARE, {optional: true}) 27 | protected invokeMiddleware: InvokeMiddleware = () => false; 28 | 29 | constructor( 30 | @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 31 | @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, 32 | @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 33 | @inject(SequenceActions.SEND) public send: Send, 34 | @inject(SequenceActions.REJECT) public reject: Reject, 35 | ) {} 36 | 37 | async handle(context: RequestContext) { 38 | try { 39 | const {request, response} = context; 40 | const finished = await this.invokeMiddleware(context); 41 | if (finished) return; 42 | const route = this.findRoute(request); 43 | const args = await this.parseParams(request, route); 44 | const result = await this.invoke(route, args); 45 | this.send(response, result); 46 | } catch (err) { 47 | this.reject(context, err); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/services/geocoder.service.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | import {inject, Provider} from '@loopback/core'; 7 | import {getService} from '@loopback/service-proxy'; 8 | import {GeocoderDataSource} from '../datasources'; 9 | 10 | export interface GeoPoint { 11 | /** 12 | * latitude 13 | */ 14 | y: number; 15 | 16 | /** 17 | * longitude 18 | */ 19 | x: number; 20 | } 21 | 22 | export interface Geocoder { 23 | geocode(address: string): Promise<GeoPoint[]>; 24 | } 25 | 26 | export class GeocoderProvider implements Provider<Geocoder> { 27 | constructor( 28 | // geocoder must match the name property in the datasource json file 29 | @inject('datasources.geocoder') 30 | protected dataSource: GeocoderDataSource = new GeocoderDataSource(), 31 | ) {} 32 | 33 | value(): Promise<Geocoder> { 34 | return getService(this.dataSource); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2018,2020. All Rights Reserved. 2 | // Node module: @loopback/example-todo 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | export * from './geocoder.service'; 7 | -------------------------------------------------------------------------------- /src/websockets/decorators/websocket.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassDecoratorFactory, 3 | Constructor, 4 | inject, 5 | MetadataAccessor, 6 | MetadataInspector, 7 | MethodDecoratorFactory, 8 | } from '@loopback/context'; 9 | 10 | export interface WebSocketMetadata { 11 | name?: string, 12 | namespace?: string | RegExp; 13 | } 14 | 15 | export const WEBSOCKET_METADATA = MetadataAccessor.create<WebSocketMetadata, 16 | ClassDecorator>('websocket'); 17 | 18 | /** 19 | * Decorate a websocket controller class to specify the namespace 20 | * For example, 21 | * ```ts 22 | * @ws({namespace: '/chats'}) 23 | * export class WebSocketController {} 24 | * ``` 25 | * @param spec A namespace or object 26 | */ 27 | export function ws(spec: WebSocketMetadata | string | RegExp = {}) { 28 | if (typeof spec === 'string' || spec instanceof RegExp) { 29 | spec = { namespace: spec }; 30 | } 31 | return ClassDecoratorFactory.createDecorator(WEBSOCKET_METADATA, spec); 32 | } 33 | 34 | export function getWebSocketMetadata(controllerClass: Constructor<unknown>) { 35 | return MetadataInspector.getClassMetadata( 36 | WEBSOCKET_METADATA, 37 | controllerClass, 38 | ); 39 | } 40 | 41 | export namespace ws { 42 | export function socket() { 43 | return inject('ws.socket'); 44 | } 45 | 46 | export function server() { 47 | return inject('ws.server'); 48 | } 49 | 50 | export function namespace(name: string) { 51 | return inject(`ws.namespace.${name}`); 52 | } 53 | 54 | /** 55 | * Decorate a method to subscribe to websocket events. 56 | * For example, 57 | * ```ts 58 | * @ws.subscribe('chat message') 59 | * async function onChat(msg: string) { 60 | * } 61 | * ``` 62 | * @param messageTypes 63 | */ 64 | export function subscribe(...messageTypes: (string | RegExp)[]) { 65 | return MethodDecoratorFactory.createDecorator( 66 | 'websocket:subscribe', 67 | messageTypes, 68 | ); 69 | } 70 | 71 | /** 72 | * Decorate a controller method for `disconnect` 73 | */ 74 | export function disconnect() { 75 | return subscribe('disconnect'); 76 | } 77 | 78 | /** 79 | * Decorate a controller method for `connect` 80 | */ 81 | export function connect() { 82 | return MethodDecoratorFactory.createDecorator('websocket:connect', true); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/websockets/websocket-controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BindingScope, Constructor, Context, invokeMethod, MetadataInspector, } from '@loopback/context'; 2 | import { Socket } from 'socket.io'; 3 | 4 | /* eslint-disable @typescript-eslint/no-misused-promises */ 5 | export class WebSocketControllerFactory { 6 | private controller: { [method: string]: Function }; 7 | 8 | constructor( 9 | private ctx: Context, 10 | private controllerClass: Constructor<{ [method: string]: Function }>, 11 | ) { 12 | this.ctx 13 | .bind('ws.controller') 14 | .toClass(this.controllerClass) 15 | .tag('websocket') 16 | .inScope(BindingScope.CONTEXT); 17 | } 18 | 19 | async create(socket: Socket) { 20 | // Instantiate the controller instance 21 | this.controller = await this.ctx.get<{ [method: string]: Function }>( 22 | 'ws.controller', 23 | ); 24 | await this.setup(socket); 25 | return this.controller; 26 | } 27 | 28 | async connect(socket: Socket) { 29 | const connectMethods = 30 | MetadataInspector.getAllMethodMetadata( 31 | 'websocket:connect', 32 | this.controllerClass.prototype, 33 | ) || {}; 34 | for (const m in connectMethods) { 35 | await invokeMethod(this.controller, m, this.ctx, [socket]); 36 | } 37 | } 38 | 39 | registerSubscribeMethods(socket: Socket) { 40 | const regexpEventHandlers = new Map<RegExp[], 41 | (...args: unknown[]) => Promise<void>>(); 42 | const subscribeMethods = 43 | MetadataInspector.getAllMethodMetadata<(string | RegExp)[]>( 44 | 'websocket:subscribe', 45 | this.controllerClass.prototype, 46 | ) || {}; 47 | for (const m in subscribeMethods) { 48 | for (const t of subscribeMethods[m]) { 49 | const regexps: RegExp[] = []; 50 | if (typeof t === 'string') { 51 | socket.on(t, async (...args: unknown[]) => { 52 | let done: Function = (result: any) => null; 53 | if (typeof args[args.length - 1] === 'function') { 54 | done = args.pop() as Function; 55 | } 56 | const result = await invokeMethod(this.controller, m, this.ctx, args); 57 | done(result); 58 | }); 59 | } else if (t instanceof RegExp) { 60 | regexps.push(t); 61 | } 62 | if (regexps.length) { 63 | // Build a map of regexp based message handlers 64 | regexpEventHandlers.set(regexps, async (...args: unknown[]) => { 65 | await invokeMethod(this.controller, m, this.ctx, args); 66 | }); 67 | } 68 | } 69 | } 70 | return regexpEventHandlers; 71 | } 72 | 73 | /** 74 | * Set up the controller for the given socket 75 | * @param socket 76 | */ 77 | async setup(socket: Socket) { 78 | // Invoke connect handlers 79 | await this.connect(socket); 80 | 81 | // Register event handlers 82 | const regexpHandlers = this.registerSubscribeMethods(socket); 83 | 84 | // Register event handlers with regexp 85 | if (regexpHandlers.size) { 86 | // Use a socket middleware to match event names with regexp 87 | socket.use(async (packet, next) => { 88 | const eventName = packet[0]; 89 | for (const e of regexpHandlers.entries()) { 90 | if (e[0].some(re => !!eventName.match(re))) { 91 | const handler = e[1]; 92 | const args = [packet[1]]; 93 | if (packet[2]) { 94 | // TODO: Should we auto-ack? 95 | // Ack callback 96 | args.push(packet[2]); 97 | } 98 | await handler(args); 99 | } 100 | } 101 | next(); 102 | }); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/websockets/websocket.application.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@loopback/core'; 2 | import { HttpServer } from '@loopback/http-server'; 3 | import { RestApplication } from '@loopback/rest'; 4 | import { WebSocketServer } from "./websocket.server"; 5 | import { Constructor } from "@loopback/context"; 6 | import { Namespace } from "socket.io"; 7 | 8 | export { ApplicationConfig }; 9 | 10 | export class WebsocketApplication extends RestApplication { 11 | readonly httpServer: HttpServer; 12 | readonly wsServer: WebSocketServer; 13 | 14 | constructor(options: ApplicationConfig = {}) { 15 | super(options); 16 | this.httpServer = new HttpServer(this.requestHandler, options.websocket); 17 | this.wsServer = new WebSocketServer(this, this.httpServer); 18 | } 19 | 20 | public websocketRoute(controllerClass: Constructor<any>, namespace?: string | RegExp): Namespace { 21 | return this.wsServer.route(controllerClass, namespace) as Namespace; 22 | } 23 | 24 | public async start(): Promise<void> { 25 | await this.wsServer.start(); 26 | await super.start(); 27 | } 28 | 29 | public async stop(): Promise<void> { 30 | await this.wsServer.stop(); 31 | await super.stop(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/websockets/websocket.booter.ts: -------------------------------------------------------------------------------- 1 | // Author: Alexander Rondón 2 | // This file is licensed under the MIT License. 3 | // License text available at https://opensource.org/licenses/MIT 4 | // Base on: https://github.com/strongloop/loopback-next/blob/master/packages/boot/src/booters/repository.booter.ts 5 | 6 | import { config, CoreBindings, inject } from '@loopback/core'; 7 | import { ArtifactOptions, BaseArtifactBooter, BootBindings, booter } from "@loopback/boot"; 8 | import { WebsocketApplication } from "./websocket.application"; 9 | 10 | /** 11 | * A class that extends BaseArtifactBooter to boot the 'WebsocketController' artifact type. 12 | * Discovered controllers are bound using `app.controller()`. 13 | * 14 | * Supported phases: configure, discover, load 15 | * 16 | * @param app - Application instance 17 | * @param projectRoot - Root of User Project relative to which all paths are resolved 18 | * @param websocketControllerConfig - Controller Artifact Options Object 19 | */ 20 | @booter('websocketControllers') 21 | export class WebsocketControllerBooter extends BaseArtifactBooter { 22 | constructor( 23 | @inject(CoreBindings.APPLICATION_INSTANCE) public app: WebsocketApplication, 24 | @inject(BootBindings.PROJECT_ROOT) projectRoot: string, 25 | @config() 26 | public websocketControllerConfig: ArtifactOptions = {}, 27 | ) { 28 | super( 29 | projectRoot, 30 | // Set Controller Booter Options if passed in via bootConfig 31 | Object.assign({}, WebsocketControllerDefaults, websocketControllerConfig), 32 | ); 33 | } 34 | 35 | /** 36 | * Uses super method to get a list of Artifact classes. Boot each class by 37 | * binding it to the application using `app.controller(controller);`. 38 | */ 39 | async load() { 40 | await super.load(); 41 | this.classes.forEach(cls => { 42 | this.app.websocketRoute(cls); 43 | }); 44 | } 45 | } 46 | 47 | /** 48 | * Default ArtifactOptions for WebsocketControllerBooter. 49 | */ 50 | export const WebsocketControllerDefaults: ArtifactOptions = { 51 | dirs: ['controllers'], 52 | extensions: ['.controller.ws.js'], 53 | nested: true, 54 | }; 55 | 56 | -------------------------------------------------------------------------------- /src/websockets/websocket.server.ts: -------------------------------------------------------------------------------- 1 | import { Constructor, Context } from '@loopback/context'; 2 | import { HttpServer } from '@loopback/http-server'; 3 | import { Server, ServerOptions, Socket } from 'socket.io'; 4 | import { WebSocketControllerFactory } from './websocket-controller-factory'; 5 | import { getWebSocketMetadata, WebSocketMetadata } from "./decorators/websocket.decorator"; 6 | import SocketIOServer = require("socket.io"); 7 | 8 | const debug = require('debug')('loopback:websocket'); 9 | 10 | /* eslint-disable @typescript-eslint/no-explicit-any */ 11 | export type SockIOMiddleware = ( 12 | socket: Socket, 13 | fn: (err?: any) => void, 14 | ) => void; 15 | 16 | /** 17 | * A websocket server 18 | */ 19 | export class WebSocketServer extends Context { 20 | private io: Server; 21 | 22 | constructor( 23 | public ctx: Context, 24 | public readonly httpServer: HttpServer, 25 | private options: ServerOptions = {}, 26 | ) { 27 | super(ctx); 28 | this.io = SocketIOServer(options); 29 | ctx.bind('ws.server').to(this.io); 30 | } 31 | 32 | /** 33 | * Register a sock.io middleware function 34 | * @param fn 35 | */ 36 | use(fn: SockIOMiddleware) { 37 | return this.io.use(fn); 38 | } 39 | 40 | /** 41 | * Register a websocket controller 42 | * @param ControllerClass 43 | * @param meta 44 | */ 45 | route(ControllerClass: Constructor<any>, meta?: WebSocketMetadata | string | RegExp) { 46 | if(meta instanceof RegExp || typeof meta === 'string'){ 47 | meta = { namespace: meta } as WebSocketMetadata; 48 | } 49 | if (meta == null) { 50 | meta = getWebSocketMetadata(ControllerClass) as WebSocketMetadata; 51 | } 52 | const nsp = (meta && meta.namespace) ? this.io.of(meta.namespace) : this.io; 53 | if (meta && meta.name) { 54 | this.ctx.bind(`ws.namespace.${meta.name}`).to(nsp); 55 | } 56 | 57 | /* eslint-disable @typescript-eslint/no-misused-promises */ 58 | nsp.on('connection', async socket => { 59 | console.log('connection', 'connection'); 60 | debug( 61 | 'Websocket connected: id=%s namespace=%s', 62 | socket.id, 63 | socket.nsp.name, 64 | ); 65 | // Create a request context 66 | const reqCtx = new Context(this); 67 | // Bind websocket 68 | reqCtx.bind('ws.socket').to(socket); 69 | // Instantiate the controller instance 70 | await new WebSocketControllerFactory(reqCtx, ControllerClass).create( 71 | socket, 72 | ); 73 | }); 74 | return nsp; 75 | } 76 | 77 | /** 78 | * Start the websocket server 79 | */ 80 | async start() { 81 | await this.httpServer.start(); 82 | // FIXME: Access HttpServer.server 83 | const server = (this.httpServer as any).server; 84 | this.io.attach(server, this.options); 85 | } 86 | 87 | /** 88 | * Stop the websocket server 89 | */ 90 | async stop() { 91 | const close = new Promise<void>((resolve, reject) => { 92 | this.io.close(() => { 93 | resolve(); 94 | }); 95 | }); 96 | await close; 97 | await this.httpServer.stop(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | {"$schema":"http://json.schemastore.org/tsconfig","extends":"@loopback/build/config/tsconfig.common.json","compilerOptions":{"outDir":"dist","rootDir":"src","composite":false},"include":["src/**/*","src/**/*.json"]} 2 | --------------------------------------------------------------------------------