├── .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 |
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 |
62 |
63 |
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 {
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('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 = 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>('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) {
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 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) {
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: {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;
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 = {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) => {
59 | this.debug('Morgan configuration', config);
60 | return morgan('combined', config);
61 | };
62 |
63 | // Print out logs using `debug`
64 | const defaultConfig: morgan.Options = {
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,
39 | ): Promise {
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 {
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,
87 | ): Promise {
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 {
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,
122 | ): Promise {
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 {
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 {
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) {
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 {
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;
24 | }
25 |
26 | export class GeocoderProvider implements Provider {
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 {
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('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) {
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 Promise>();
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, namespace?: string | RegExp): Namespace {
21 | return this.wsServer.route(controllerClass, namespace) as Namespace;
22 | }
23 |
24 | public async start(): Promise {
25 | await this.wsServer.start();
26 | await super.start();
27 | }
28 |
29 | public async stop(): Promise {
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, 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((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 |
--------------------------------------------------------------------------------