@jcbuisson/express-x 1.1.4 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,12 +33,11 @@ backed in a [Prisma](https://www.prisma.io/) database
33
33
 
34
34
  ```js
35
35
  // app.js
36
- import express from 'express'
37
36
  import bodyParser from 'body-parser'
38
- import expressX from '@jcbuisson/express-x'
37
+ import { expressXServer } from '@jcbuisson/express-x'
39
38
 
40
- // `app` is a regular express application, enhanced with express-x features
41
- const app = expressX(express())
39
+ // `app` is a regular express application, enhanced with service and real-time features
40
+ const app = expressX()
42
41
 
43
42
  // create two CRUD database services. They provide Prisma methods: `create`, 'createMany', 'find', 'findMany', 'upsert', etc.
44
43
  app.createDatabaseService('User')
@@ -126,22 +125,16 @@ With a few lines of code, we got a complete REST API over the database tables. B
126
125
 
127
126
  ## Use it with a websocket client
128
127
 
129
- First install ExpressX client library:
130
-
131
- ```bash
132
- npm i @jcbuisson/express-x-client
133
- ```
134
-
135
128
  Create the following client NodeJS script:
136
129
 
137
130
  ```js
138
131
  // client.js
139
132
  import io from 'socket.io-client'
140
- import expressxClient from '@jcbuisson/express-x-client'
133
+ import { expressXClient } from '@jcbuisson/express-x'
141
134
 
142
135
  const socket = io('http://localhost:8000', { transports: ["websocket"] })
143
136
 
144
- const app = expressxClient(socket)
137
+ const app = expressXClient(socket)
145
138
 
146
139
  async function main() {
147
140
  const user = await app.service('User').create({
@@ -180,7 +173,7 @@ node client.js
180
173
  ```
181
174
 
182
175
  It prints the following lines in the console:
183
- ```
176
+ ```json
184
177
  joe {
185
178
  id: 11,
186
179
  name: 'Joe',
@@ -218,12 +211,11 @@ and then broacasted to all connected clients, leading to real-time updates.
218
211
 
219
212
  ```js
220
213
  // app.js
221
- import express from 'express'
222
- import expressX from '@jcbuisson/express-x'
214
+ import { expressXServer } from '@jcbuisson/express-x'
223
215
  import { PrismaClient } from '@prisma/client'
224
216
 
225
- // `app` is a regular express application, enhanced with express-x features
226
- const app = expressX(express())
217
+ // `app` is a regular express application, enhanced with service and real-time features
218
+ const app = expressX()
227
219
 
228
220
  // configure prisma client from schema
229
221
  app.set('prisma', new PrismaClient())
@@ -252,7 +244,6 @@ app.server.listen(8000, () => console.log(`App listening at http://localhost:800
252
244
  Here is how a client may listen to channel events:
253
245
 
254
246
  ```js
255
- // client.js
256
247
  ...
257
248
  app.service('Post').on('create', post => {
258
249
  console.log('post event created', post)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -21,6 +21,7 @@
21
21
  "@jcbuisson/express-x-client": "^1.0.10",
22
22
  "@prisma/client": "^4.10.1",
23
23
  "axios": "^1.4.0",
24
+ "bcrypt": "^5.1.0",
24
25
  "config": "^3.3.9",
25
26
  "esm": "^3.2.25",
26
27
  "express": "^4.18.2",
package/prisma/dev.db CHANGED
Binary file
package/src/client.mjs ADDED
@@ -0,0 +1,95 @@
1
+
2
+ import { v4 } from 'uuid'
3
+
4
+ export function expressXClient(socket, options={}) {
5
+ if (options.debug === undefined) options.debug = false
6
+
7
+ const waitingPromisesByUid = {}
8
+ const action2service2handlers = {}
9
+ let onConnectionCallback = null
10
+ let onDisconnectionCallback = null
11
+
12
+ const setConnectionCallback = (callback) => {
13
+ onConnectionCallback = callback
14
+ }
15
+
16
+ const setDisconnectionCallback = (callback) => {
17
+ onDisconnectionCallback = callback
18
+ }
19
+
20
+ // on connection
21
+ socket.on("connected", async (connectionId) => {
22
+ if (options.debug) console.log('connected', connectionId)
23
+ if (onConnectionCallback) onConnectionCallback(connectionId)
24
+ })
25
+
26
+ // on receiving response from service request
27
+ socket.on('client-response', ({ uid, error, result }) => {
28
+ if (options.debug) console.log('client-response', uid, error, result)
29
+ if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
30
+ const [resolve, reject] = waitingPromisesByUid[uid]
31
+ if (error) {
32
+ reject(error)
33
+ } else {
34
+ resolve(result)
35
+ }
36
+ delete waitingPromisesByUid[uid]
37
+ })
38
+
39
+ // on receiving events from pub/sub
40
+ socket.on('service-event', ({ name, action, result }) => {
41
+ if (!action2service2handlers[action]) action2service2handlers[action] = {}
42
+ const serviceHandlers = action2service2handlers[action]
43
+ const handler = serviceHandlers[name]
44
+ if (handler) handler(result)
45
+ })
46
+
47
+ async function serviceMethodRequest(name, action, ...args) {
48
+ const uid = v4()
49
+ // create a promise which will resolve or reject by an event 'client-response'
50
+ const promise = new Promise((resolve, reject) => {
51
+ waitingPromisesByUid[uid] = [resolve, reject]
52
+ // a 5s timeout may also reject the promise
53
+ setTimeout(() => {
54
+ delete waitingPromisesByUid[uid]
55
+ reject(`Error: timeout on service '${name}', action '${action}', args: ${args}`)
56
+ }, 5000)
57
+ })
58
+ // send request to server through websocket
59
+ socket.emit('client-request', {
60
+ uid,
61
+ name,
62
+ action,
63
+ args,
64
+ })
65
+ return promise
66
+ }
67
+
68
+ function service(name) {
69
+ const service = {
70
+ // associate a handler to a pub/sub event for this service
71
+ on: (action, handler) => {
72
+ if (!action2service2handlers[action]) action2service2handlers[action] = {}
73
+ const serviceHandlers = action2service2handlers[action]
74
+ serviceHandlers[name] = handler
75
+ },
76
+ }
77
+ // use a Proxy to allow for any method name for a service
78
+ const handler = {
79
+ get(service, action) {
80
+ if (!(action in service)) {
81
+ // newly used property `action`: define it as a service method request function
82
+ service[action] = (...args) => serviceMethodRequest(name, action, ...args)
83
+ }
84
+ return service[action]
85
+ }
86
+ }
87
+ return new Proxy(service, handler)
88
+ }
89
+
90
+ return {
91
+ setConnectionCallback,
92
+ setDisconnectionCallback,
93
+ service,
94
+ }
95
+ }
@@ -0,0 +1,53 @@
1
+
2
+ import config from 'config'
3
+ import bcrypt from 'bcrypt'
4
+
5
+
6
+ // hash password of user record
7
+ // name of the password field is found in `config.authentication.local.passwordField` (default: 'password')
8
+ export async function hashPassword(context) {
9
+ const passwordField = config.authentication.local.passwordField
10
+ context.args[0][passwordField] = await bcrypt.hash(context.args[0][passwordField], 5)
11
+ return context
12
+ }
13
+
14
+
15
+ export async function authenticate(context) {
16
+ console.log('authenticate hook', context.transport, context.name, context.action, context?.connection?.accessToken)
17
+ return context
18
+
19
+ if (context.transport === 'ws') {
20
+ // for WS transport, just check that connection.accessToken is set and valid
21
+ if (!context?.connection?.accessToken) throw new Error("WS connection is not secured by a JWT access token")
22
+ // TODO: check token validity
23
+
24
+ } else if (context.transport === 'http') {
25
+ // for HTTP transport, check that the associated request has a header with a valid JWT
26
+ // TODO: check headers for access token
27
+ }
28
+ return (context)
29
+ }
30
+
31
+
32
+ // remove `field` from `result`
33
+ export function protect(field) {
34
+ return async (context) => {
35
+ if (Array.isArray(context.result)) {
36
+ for (const value of context.result) {
37
+ delete value[field]
38
+ }
39
+ } else {
40
+ delete context.result[field]
41
+ }
42
+ return (context)
43
+ }
44
+ }
45
+
46
+
47
+ export async function setSessionJWT(context) {
48
+ if (context.transport === 'ws') {
49
+ // associate JWT token to WS connection to mark it as secure
50
+ context.connection.accessToken = context.result.accessToken
51
+ }
52
+ return context
53
+ }
package/src/index.mjs CHANGED
@@ -1,381 +1,13 @@
1
1
 
2
- import http from 'http'
3
- import { Server } from "socket.io"
4
- import { PrismaClient } from '@prisma/client'
2
+ import { expressX } from './server.mjs'
3
+ import { expressXClient } from './client.mjs'
4
+ import { hashPassword, protect, setSessionJWT, } from './common-hooks.mjs'
5
5
 
6
- /*
7
- * Enhance `app` express application with Feathers-like services
8
- */
9
- function expressX(app, options={}) {
6
+ export {
7
+ expressX,
8
+ expressXClient,
10
9
 
11
- if (options.debug === undefined) options.debug = false
12
- if (options.ws === undefined) options.ws = { ws_prefix: "expressx" }
13
-
14
- const services = {}
15
- const connections = {}
16
-
17
- let lastConnectionId = 1
18
-
19
- /*
20
- * create a service `name` based on Prisma table `entity`
21
- */
22
- function createDatabaseService(name, prismaOptions = { entity: name }) {
23
-
24
- let prisma = app.get('prisma')
25
- if (!prisma) {
26
- prisma = new PrismaClient()
27
- app.set('prisma', prisma)
28
- }
29
-
30
- // take all prisma methods on `entity` table
31
- const methods = prisma[prismaOptions.entity]
32
-
33
- const service = createService(name, methods)
34
-
35
- service.prisma = prisma
36
- service.entity = prismaOptions.entity
37
-
38
- if (options.debug) console.log(`created service '${name}' over table '${prismaOptions.entity}'`)
39
- return service
40
- }
41
-
42
- /*
43
- * create a service `name` with given `methods`
44
- */
45
- function createService(name, methods) {
46
- const service = { name }
47
-
48
- for (const methodName in methods) {
49
- const method = methods[methodName]
50
- if (! method instanceof Function) continue
51
-
52
- // `context` is the context of execution (transport type, connection, app)
53
- // `args` is the list of arguments of the method
54
- service['__' + methodName] = async (context, ...args) => {
55
- context.args = args
56
-
57
- // if a hook or the method throws an error, it will be caught by `socket.on('client-request'` (ws)
58
- // or by express (http) and the client will get a rejected promise
59
-
60
- // call 'before' hooks, modifying `context.args`
61
- const beforeMethodHooks = service?.hooks?.before && service.hooks.before[methodName] || []
62
- const beforeAllHooks = service?.hooks?.before?.all || []
63
- for (const hook of [...beforeMethodHooks, ...beforeAllHooks]) {
64
- context = await hook({ ...context, args })
65
- }
66
-
67
- // call method
68
- const result = await method(...context.args)
69
- // if (options.debug) console.log('result', result)
70
-
71
- // call 'after' hooks
72
- const afterMethodHooks = service?.hooks?.after && service.hooks.after[methodName] || []
73
- const afterAllHooks = service?.hooks?.after?.all || []
74
- for (const hook of [...afterMethodHooks, ...afterAllHooks]) {
75
- context = await hook({ ...context, result })
76
- }
77
- return result
78
- }
79
-
80
- // hooked version of method: `create`, etc., to be called from backend with no context
81
- service[methodName] = method
82
-
83
- // un-hooked version of method: `_create`, etc., to be called from backend with no context
84
- service['_' + methodName] = method
85
- }
86
-
87
- // attach pub/sub publish callback
88
- service.publish = async (func) => {
89
- service.publishCallback = func
90
- },
91
-
92
- // attach hooks
93
- service.hooks = (hooks) => {
94
- service.hooks = hooks
95
- }
96
-
97
- // cache service in `services`
98
- services[name] = service
99
- return service
100
- }
101
-
102
- // `app.service(name)` starts here!
103
- function service(name) {
104
- // get service from `services` cache
105
- if (name in services) return services[name]
106
- throw Error(`there is no service named '${name}'`)
107
- }
108
-
109
- function configure(callback) {
110
- callback(app)
111
- }
112
-
113
- /*
114
- * add an HTTP REST endpoint at `path`, based on `service`
115
- */
116
- async function addHttpRest(path, service) {
117
- const context = {
118
- app,
119
- http: { name: service.name }
120
- }
121
-
122
- // introspect table schema
123
- async function getFieldTypes() {
124
- const fieldTypes = {}
125
- if (service.prisma._activeProvider === 'sqlite') {
126
- const fieldInfo = await service.prisma.$queryRawUnsafe(`
127
- PRAGMA table_info(${service.entity})
128
- `)
129
- fieldInfo.forEach(column => {
130
- fieldTypes[column.name] = column.type.toLowerCase()
131
- })
132
- } else if (service.prisma._activeProvider === 'postgresql') {
133
- const fieldInfo = await service.prisma.$queryRawUnsafe(`
134
- SELECT column_name, data_type
135
- FROM information_schema.columns
136
- WHERE table_name = '${service.entity}';
137
- `)
138
- fieldInfo.forEach(column => {
139
- fieldTypes[column.column_name] = column.data_type
140
- })
141
- }
142
- return fieldTypes
143
- }
144
-
145
-
146
- app.post(path, async (req, res) => {
147
- if (options.debug) console.log("http request POST", req)
148
- context.http.req = req
149
- try {
150
- const value = await service.__create(context, { data: req.body })
151
- publish(service, 'create', value)
152
- res.json(value)
153
- } catch(err) {
154
- console.log('callErr', err)
155
- res.status(500).send(err.toString())
156
- }
157
- })
158
-
159
- app.get(path, async (req, res) => {
160
- if (options.debug) console.log("http request GET", req)
161
- context.http.req = req
162
- const query = { ...req.query }
163
- try {
164
- // the values in `req.query` are all strings, but Prisma need proper types
165
- // we need to introspect column types and do the proper transtyping
166
- for (const fieldName in query) {
167
- if (!service.fieldTypes) service.fieldTypes = await getFieldTypes()
168
- const fieldType = service.fieldTypes[fieldName]
169
-
170
- if (fieldType === 'integer') {
171
- query[fieldName] = parseInt(query[fieldName])
172
- } else if (fieldType === 'numeric') {
173
- query[fieldName] = parseFloat(query[fieldName])
174
- } else if (fieldType === 'boolean') {
175
- query[fieldName] = (query[fieldName] === 't') ? true : false
176
- } else if (fieldType === 'text' || fieldType === 'character varying') {
177
- query[fieldName] = query[fieldName]
178
- } else {
179
- // ?
180
- query[fieldName] = query[fieldName]
181
- }
182
- }
183
-
184
- const values = await service.__findMany(context, {
185
- where: query,
186
- })
187
- publish(service, 'findMany', values)
188
- res.json(values)
189
- } catch(err) {
190
- console.log('callErr', err)
191
- res.status(500).send(err.toString())
192
- }
193
- })
194
-
195
- app.get(`${path}/:id`, async (req, res) => {
196
- if (options.debug) console.log("http request GET", req)
197
- context.http.req = req
198
- try {
199
- const value = await service.__findUnique(context, {
200
- where: {
201
- id: parseInt(req.params.id)
202
- }
203
- })
204
- publish(service, 'findUnique', value)
205
- res.json(value)
206
- } catch(err) {
207
- console.log('callErr', err)
208
- res.status(500).send(err.toString())
209
- }
210
- })
211
-
212
- app.patch(`${path}/:id`, async (req, res) => {
213
- if (options.debug) console.log("http request PATCH", req)
214
- context.http.req = req
215
- try {
216
- const value = await service.__update(context, {
217
- where: {
218
- id: parseInt(req.params.id),
219
- },
220
- data: req.body,
221
- })
222
- publish(service, 'update', value)
223
- res.json(value)
224
- } catch(err) {
225
- console.log('callErr', err)
226
- res.status(500).send(err.toString())
227
- }
228
- })
229
-
230
- app.delete(`${path}/:id`, async (req, res) => {
231
- if (options.debug) console.log("http request DELETE", req)
232
- context.http.req = req
233
- try {
234
- const value = await service.__delete(context, {
235
- where: {
236
- id: parseInt(req.params.id)
237
- }
238
- })
239
- publish(service, 'delete', value)
240
- res.json(value)
241
- } catch(err) {
242
- console.log('callErr', err)
243
- res.status(500).send(err.toString())
244
- }
245
- })
246
-
247
- if (options.debug) console.log(`added HTTP endpoints for service '${service.name}' at path '${path}'`)
248
- }
249
-
250
- /*
251
- * Create HTTP server
252
- */
253
- const server = new http.Server(app)
254
-
255
- if (options.ws) {
256
- /*
257
- * Add websocket transport
258
- */
259
- const io = new Server(server)
260
-
261
- io.on('connection', function(socket) {
262
- if (options.debug) console.log('Client connected to the WebSocket')
263
- const connection = {
264
- id: lastConnectionId++,
265
- socket,
266
- channelNames: new Set(),
267
- }
268
- // store connection in cache
269
- connections[connection.id] = connection
270
- if (options.debug) console.log('active connections', Object.keys(connections))
271
-
272
- // emit 'connection' event for app (expressjs extends EventEmitter)
273
- app.emit('connection', connection)
274
-
275
- // send 'connected' event to client
276
- socket.emit('connected', connection.id)
277
-
278
- socket.on('disconnect', () => {
279
- if (options.debug) console.log('Client disconnected', connection.id)
280
- delete connections[connection.id]
281
- })
282
-
283
-
284
- /*
285
- * Handle websocket client request
286
- * Emit in return a 'client-response' message
287
- */
288
- socket.on('client-request', async ({ uid, name, action, args }) => {
289
- if (options.debug) console.log("client-request", uid, name, action, args)
290
- if (name in services) {
291
- const service = services[name]
292
- try {
293
- const serviceMethod = service['__' + action]
294
- if (serviceMethod) {
295
- const context = {
296
- app,
297
- ws: { connection, name, action, args },
298
- }
299
-
300
- try {
301
- const result = await serviceMethod(context, ...args)
302
- socket.emit('client-response', {
303
- uid,
304
- result,
305
- })
306
- // pub/sub: send event on associated channels
307
- publish(service, action, result)
308
- } catch(err) {
309
- console.log('callErr', err)
310
- io.emit('client-response', {
311
- uid,
312
- error: err.toString(),
313
- })
314
- }
315
- } else {
316
- io.emit('client-response', {
317
- uid,
318
- error: `there is no method named '${action}' for service '${name}'`,
319
- })
320
- }
321
- } catch(error) {
322
- io.emit('client-response', {
323
- uid,
324
- error,
325
- })
326
- }
327
- } else {
328
- io.emit('client-response', {
329
- uid,
330
- error: `there is no service named '${name}'`,
331
- })
332
- }
333
- })
334
- })
335
- }
336
-
337
- // publish event on associated channels
338
- async function publish(service, action, result) {
339
- const publishFunc = service.publishCallback
340
- if (publishFunc) {
341
- const channelNames = await publishFunc(result, app)
342
- if (options.debug) console.log('publish channels', service.name, action, channelNames)
343
- for (const channelName of channelNames) {
344
- if (options.debug) console.log('service-event', service.name, action, channelName)
345
- const connectionList = Object.values(connections).filter(cnx => cnx.channelNames.has(channelName))
346
- for (const connection of connectionList) {
347
- if (options.debug) console.log('emit to', connection.id, service.name, action, result)
348
- connection.socket.emit('service-event', {
349
- name: service.name,
350
- action,
351
- result,
352
- })
353
- }
354
- }
355
- }
356
- }
357
-
358
- function joinChannel(channelName, connection) {
359
- connection.channelNames.add(channelName)
360
- }
361
-
362
- function leaveChannel(channelName, connection) {
363
- connection.channelNames.delete(channelName)
364
- }
365
-
366
- // enhance `app` with objects and methods
367
- Object.assign(app, {
368
- options,
369
- createDatabaseService,
370
- createService,
371
- service,
372
- configure,
373
- addHttpRest,
374
- server,
375
- joinChannel,
376
- leaveChannel,
377
- })
378
- return app
10
+ hashPassword,
11
+ protect,
12
+ setSessionJWT,
379
13
  }
380
-
381
- export default expressX
package/src/server.mjs ADDED
@@ -0,0 +1,381 @@
1
+
2
+ import http from 'http'
3
+ import { Server } from "socket.io"
4
+ import express from 'express'
5
+ import { PrismaClient } from '@prisma/client'
6
+
7
+ /*
8
+ * Enhance `app` express application with services and real-time features
9
+ */
10
+ export function expressX(options={ debug: false }) {
11
+
12
+ const app = express()
13
+
14
+ if (options.debug === undefined) options.debug = true
15
+ if (options.ws === undefined) options.ws = { ws_prefix: "expressx" }
16
+
17
+ const services = {}
18
+ const connections = {}
19
+
20
+ let lastConnectionId = 1
21
+
22
+ /*
23
+ * create a service `name` based on Prisma table `entity`
24
+ */
25
+ function createDatabaseService(name, prismaOptions = { entity: name }) {
26
+
27
+ let prisma = app.get('prisma')
28
+ if (!prisma) {
29
+ prisma = new PrismaClient()
30
+ app.set('prisma', prisma)
31
+ }
32
+
33
+ // take all prisma methods on `entity` table
34
+ const methods = prisma[prismaOptions.entity]
35
+
36
+ const service = createService(name, methods)
37
+
38
+ service.prisma = prisma
39
+ service.entity = prismaOptions.entity
40
+
41
+ if (options.debug) console.log(`created service '${name}' over table '${prismaOptions.entity}'`)
42
+ return service
43
+ }
44
+
45
+ /*
46
+ * create a service `name` with given `methods`
47
+ */
48
+ function createService(name, methods) {
49
+ const service = { name }
50
+
51
+ for (const methodName in methods) {
52
+ const method = methods[methodName]
53
+ if (! method instanceof Function) continue
54
+
55
+ // `context` is the context of execution (transport type, connection, app)
56
+ // `args` is the list of arguments of the method
57
+ service['__' + methodName] = async (context, ...args) => {
58
+ context.args = args
59
+
60
+ // if a hook or the method throws an error, it will be caught by `socket.on('client-request'` (ws)
61
+ // or by express (http) and the client will get a rejected promise
62
+
63
+ // call 'before' hooks, modifying `context.args`
64
+ const beforeMethodHooks = service?.hooks?.before && service.hooks.before[methodName] || []
65
+ const beforeAllHooks = service?.hooks?.before?.all || []
66
+ for (const hook of [...beforeMethodHooks, ...beforeAllHooks]) {
67
+ context = await hook({ ...context, args })
68
+ }
69
+
70
+ // call method
71
+ const result = await method(...context.args)
72
+ // if (options.debug) console.log('result', result)
73
+
74
+ // call 'after' hooks
75
+ const afterMethodHooks = service?.hooks?.after && service.hooks.after[methodName] || []
76
+ const afterAllHooks = service?.hooks?.after?.all || []
77
+ for (const hook of [...afterMethodHooks, ...afterAllHooks]) {
78
+ context = await hook({ ...context, result })
79
+ }
80
+ return result
81
+ }
82
+
83
+ // hooked version of method: `create`, etc., to be called from backend with no context
84
+ service[methodName] = method
85
+
86
+ // un-hooked version of method: `_create`, etc., to be called from backend with no context
87
+ service['_' + methodName] = method
88
+ }
89
+
90
+ // attach pub/sub publish callback
91
+ service.publish = async (func) => {
92
+ service.publishCallback = func
93
+ },
94
+
95
+ // attach hooks
96
+ service.hooks = (hooks) => {
97
+ service.hooks = hooks
98
+ }
99
+
100
+ // cache service in `services`
101
+ services[name] = service
102
+ return service
103
+ }
104
+
105
+ // `app.service(name)` starts here!
106
+ function service(name) {
107
+ // get service from `services` cache
108
+ if (name in services) return services[name]
109
+ throw Error(`there is no service named '${name}'`)
110
+ }
111
+
112
+ function configure(callback) {
113
+ callback(app)
114
+ }
115
+
116
+ /*
117
+ * add an HTTP REST endpoint at `path`, based on `service`
118
+ */
119
+ async function addHttpRest(path, service) {
120
+ const context = {
121
+ app,
122
+ http: { name: service.name }
123
+ }
124
+
125
+ // introspect table schema
126
+ async function getFieldTypes() {
127
+ const fieldTypes = {}
128
+ if (service.prisma._activeProvider === 'sqlite') {
129
+ const fieldInfo = await service.prisma.$queryRawUnsafe(`
130
+ PRAGMA table_info(${service.entity})
131
+ `)
132
+ fieldInfo.forEach(column => {
133
+ fieldTypes[column.name] = column.type.toLowerCase()
134
+ })
135
+ } else if (service.prisma._activeProvider === 'postgresql') {
136
+ const fieldInfo = await service.prisma.$queryRawUnsafe(`
137
+ SELECT column_name, data_type
138
+ FROM information_schema.columns
139
+ WHERE table_name = '${service.entity}';
140
+ `)
141
+ fieldInfo.forEach(column => {
142
+ fieldTypes[column.column_name] = column.data_type
143
+ })
144
+ }
145
+ return fieldTypes
146
+ }
147
+
148
+
149
+ app.post(path, async (req, res) => {
150
+ if (options.debug) console.log("http request POST", req.url)
151
+ context.http.req = req
152
+ try {
153
+ const value = await service.__create(context, { data: req.body })
154
+ publish(service, 'create', value)
155
+ res.json(value)
156
+ } catch(err) {
157
+ console.log('callErr', err)
158
+ res.status(500).send(err.toString())
159
+ }
160
+ })
161
+
162
+ app.get(path, async (req, res) => {
163
+ if (options.debug) console.log("http request GET", req.url)
164
+ context.http.req = req
165
+ const query = { ...req.query }
166
+ try {
167
+ // the values in `req.query` are all strings, but Prisma need proper types
168
+ // we need to introspect column types and do the proper transtyping
169
+ for (const fieldName in query) {
170
+ if (!service.fieldTypes) service.fieldTypes = await getFieldTypes()
171
+ const fieldType = service.fieldTypes[fieldName]
172
+
173
+ if (fieldType === 'integer') {
174
+ query[fieldName] = parseInt(query[fieldName])
175
+ } else if (fieldType === 'numeric') {
176
+ query[fieldName] = parseFloat(query[fieldName])
177
+ } else if (fieldType === 'boolean') {
178
+ query[fieldName] = (query[fieldName] === 't') ? true : false
179
+ } else if (fieldType === 'text' || fieldType === 'character varying') {
180
+ query[fieldName] = query[fieldName]
181
+ } else {
182
+ // ?
183
+ query[fieldName] = query[fieldName]
184
+ }
185
+ }
186
+
187
+ const values = await service.__findMany(context, {
188
+ where: query,
189
+ })
190
+ publish(service, 'findMany', values)
191
+ res.json(values)
192
+ } catch(err) {
193
+ console.log('callErr', err)
194
+ res.status(500).send(err.toString())
195
+ }
196
+ })
197
+
198
+ app.get(`${path}/:id`, async (req, res) => {
199
+ if (options.debug) console.log("http request GET", req.url)
200
+ context.http.req = req
201
+ try {
202
+ const value = await service.__findUnique(context, {
203
+ where: {
204
+ id: parseInt(req.params.id)
205
+ }
206
+ })
207
+ publish(service, 'findUnique', value)
208
+ res.json(value)
209
+ } catch(err) {
210
+ console.log('callErr', err)
211
+ res.status(500).send(err.toString())
212
+ }
213
+ })
214
+
215
+ app.patch(`${path}/:id`, async (req, res) => {
216
+ if (options.debug) console.log("http request PATCH", req.url)
217
+ context.http.req = req
218
+ try {
219
+ const value = await service.__update(context, {
220
+ where: {
221
+ id: parseInt(req.params.id),
222
+ },
223
+ data: req.body,
224
+ })
225
+ publish(service, 'update', value)
226
+ res.json(value)
227
+ } catch(err) {
228
+ console.log('callErr', err)
229
+ res.status(500).send(err.toString())
230
+ }
231
+ })
232
+
233
+ app.delete(`${path}/:id`, async (req, res) => {
234
+ if (options.debug) console.log("http request DELETE", req.url)
235
+ context.http.req = req
236
+ try {
237
+ const value = await service.__delete(context, {
238
+ where: {
239
+ id: parseInt(req.params.id)
240
+ }
241
+ })
242
+ publish(service, 'delete', value)
243
+ res.json(value)
244
+ } catch(err) {
245
+ console.log('callErr', err)
246
+ res.status(500).send(err.toString())
247
+ }
248
+ })
249
+
250
+ if (options.debug) console.log(`added HTTP endpoints for service '${service.name}' at path '${path}'`)
251
+ }
252
+
253
+ /*
254
+ * Create HTTP server
255
+ */
256
+ const server = new http.Server(app)
257
+
258
+ if (options.ws) {
259
+ /*
260
+ * Add websocket transport
261
+ */
262
+ const io = new Server(server)
263
+
264
+ io.on('connection', function(socket) {
265
+ if (options.debug) console.log('Client connected to the WebSocket')
266
+ const connection = {
267
+ id: lastConnectionId++,
268
+ socket,
269
+ channelNames: new Set(),
270
+ }
271
+ // store connection in cache
272
+ connections[connection.id] = connection
273
+ if (options.debug) console.log('active connections', Object.keys(connections))
274
+
275
+ // emit 'connection' event for app (expressjs extends EventEmitter)
276
+ app.emit('connection', connection)
277
+
278
+ // send 'connected' event to client
279
+ socket.emit('connected', connection.id)
280
+
281
+ socket.on('disconnect', () => {
282
+ if (options.debug) console.log('Client disconnected', connection.id)
283
+ delete connections[connection.id]
284
+ })
285
+
286
+
287
+ /*
288
+ * Handle websocket client request
289
+ * Emit in return a 'client-response' message
290
+ */
291
+ socket.on('client-request', async ({ uid, name, action, args }) => {
292
+ if (options.debug) console.log("client-request", uid, name, action, args)
293
+ if (name in services) {
294
+ const service = services[name]
295
+ try {
296
+ const serviceMethod = service['__' + action]
297
+ if (serviceMethod) {
298
+ const context = {
299
+ app,
300
+ ws: { connection, name, action, args },
301
+ }
302
+
303
+ try {
304
+ const result = await serviceMethod(context, ...args)
305
+ socket.emit('client-response', {
306
+ uid,
307
+ result,
308
+ })
309
+ // pub/sub: send event on associated channels
310
+ publish(service, action, result)
311
+ } catch(err) {
312
+ console.log('callErr', err)
313
+ io.emit('client-response', {
314
+ uid,
315
+ error: err.toString(),
316
+ })
317
+ }
318
+ } else {
319
+ io.emit('client-response', {
320
+ uid,
321
+ error: `there is no method named '${action}' for service '${name}'`,
322
+ })
323
+ }
324
+ } catch(error) {
325
+ io.emit('client-response', {
326
+ uid,
327
+ error,
328
+ })
329
+ }
330
+ } else {
331
+ io.emit('client-response', {
332
+ uid,
333
+ error: `there is no service named '${name}'`,
334
+ })
335
+ }
336
+ })
337
+ })
338
+ }
339
+
340
+ // publish event on associated channels
341
+ async function publish(service, action, result) {
342
+ const publishFunc = service.publishCallback
343
+ if (publishFunc) {
344
+ const channelNames = await publishFunc(result, app)
345
+ if (options.debug) console.log('publish channels', service.name, action, channelNames)
346
+ for (const channelName of channelNames) {
347
+ if (options.debug) console.log('service-event', service.name, action, channelName)
348
+ const connectionList = Object.values(connections).filter(cnx => cnx.channelNames.has(channelName))
349
+ for (const connection of connectionList) {
350
+ if (options.debug) console.log('emit to', connection.id, service.name, action, result)
351
+ connection.socket.emit('service-event', {
352
+ name: service.name,
353
+ action,
354
+ result,
355
+ })
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ function joinChannel(channelName, connection) {
362
+ connection.channelNames.add(channelName)
363
+ }
364
+
365
+ function leaveChannel(channelName, connection) {
366
+ connection.channelNames.delete(channelName)
367
+ }
368
+
369
+ // enhance `app` with objects and methods
370
+ return Object.assign(app, {
371
+ options,
372
+ createDatabaseService,
373
+ createService,
374
+ service,
375
+ configure,
376
+ addHttpRest,
377
+ server,
378
+ joinChannel,
379
+ leaveChannel,
380
+ })
381
+ }
@@ -1,18 +1,16 @@
1
1
 
2
- import express from 'express'
3
2
  import bodyParser from 'body-parser'
4
3
  import axios from 'axios'
5
4
  import io from 'socket.io-client'
6
- import expressxClient from '@jcbuisson/express-x-client'
7
5
 
8
6
 
9
- import { expect, assert } from 'chai'
7
+ import { assert } from 'chai'
10
8
 
11
- import expressX from '../src/index.mjs'
9
+ import { expressX, expressXClient } from '../src/index.mjs'
12
10
 
13
11
 
14
- // `app` is a regular express application, enhanced with express-x features
15
- const app = expressX(express(), { debug: false })
12
+ // `app` is a regular express application, enhanced with services and real-time features
13
+ const app = expressX()
16
14
 
17
15
  app.createDatabaseService('User')
18
16
  app.createDatabaseService('Post')
@@ -23,7 +21,8 @@ describe('ExpressX API (no running server)', () => {
23
21
 
24
22
  it("can delete all users", async () => {
25
23
  const res = await app.service('User').deleteMany()
26
- assert(res.count === 1)
24
+ console.log('res delete', res)
25
+ assert(res.count >= 0)
27
26
  })
28
27
 
29
28
  it("can create a user", async () => {
@@ -125,7 +124,7 @@ describe('Client API', () => {
125
124
  app.server.listen(8008, () => console.log(`App listening at http://localhost:8008`))
126
125
 
127
126
  socket = io('http://localhost:8008', { transports: ["websocket"] })
128
- clientApp = expressxClient(socket)
127
+ clientApp = expressXClient(socket)
129
128
  })
130
129
 
131
130
  it("can create a user", async () => {