From a0624eb5a7919aa1b179a71beb1c1b9cab574525 Mon Sep 17 00:00:00 2001 From: Mathieu DARTIGUES Date: Wed, 11 Oct 2023 04:45:50 +0200 Subject: [PATCH] fix(client): Replace placeholders in URL with route params (#3270) --- docs/api/client/rest.md | 52 ++++++++++++ docs/api/client/socketio.md | 63 +++++++++++++++ packages/rest-client/src/base.ts | 22 +++-- packages/rest-client/test/index.test.ts | 59 ++++++++++++++ packages/transport-commons/src/client.ts | 25 +++--- .../transport-commons/test/client.test.ts | 80 +++++++++++++++++++ 6 files changed, 284 insertions(+), 17 deletions(-) diff --git a/docs/api/client/rest.md b/docs/api/client/rest.md index a7d6546996..5a981eaf05 100644 --- a/docs/api/client/rest.md +++ b/docs/api/client/rest.md @@ -493,3 +493,55 @@ curl -H "Content-Type: application/json" -H "X-Service-Method: myCustomMethod" - ``` This will call `messages.myCustomMethod({ message: 'Hello world' }, {})`. + +### Route placeholders + +Service URLs can have placeholders, e.g. `users/:userId/messages`. (see in [express](../express.md#params.route) or [koa](../koa.md#params.route)) + +You can call the client with route placeholders in the `params.route` property: + +```ts +import { feathers } from '@feathersjs/feathers' +import rest from '@feathersjs/rest-client' + +const app = feathers() + +// Connect to the same as the browser URL (only in the browser) +const restClient = rest() + +// Connect to a different URL +const restClient = rest('http://feathers-api.com') + +// Configure an AJAX library (see below) with that client +app.configure(restClient.fetch(window.fetch.bind(window))) + +// Connect to the `http://feathers-api.com/messages` service +const messages = app.service('users/:userId/messages') + +// Call the `http://feathers-api.com/users/2/messages` URL +messages.find({ + route: { + userId: 2, + }, +}) +``` + +This can also be achieved by using the client bundled, +sharing several `servicePath` variable exported in the [service shared file](`../../guides/cli/service.shared.md#Variables`) file. + +```ts +import rest from '@feathersjs/rest-client' +// usersMessagesPath contains 'users/:userId/messages' +import { createClient, usersMessagesPath } from 'my-app' + +const connection = rest('https://myapp.com').fetch(window.fetch.bind(window)) + +const client = createClient(connection) + +// Call the `https://myapp.com/users/2/messages` URL +client.service(usersMessagesPath).find({ + route: { + userId: 2 + } +}) +``` diff --git a/docs/api/client/socketio.md b/docs/api/client/socketio.md index 0a38164d0a..0b789c8242 100644 --- a/docs/api/client/socketio.md +++ b/docs/api/client/socketio.md @@ -103,6 +103,69 @@ Just like on the server _all_ methods you want to use have to be listed in the ` + +### Route placeholders + +Service URLs can have placeholders, e.g. `users/:userId/messages`. (see in [express](../express.md#params.route) or [koa](../koa.md#params.route)) + +You can call the client with route placeholders in the `params.route` property: + +```ts +import { feathers } from '@feathersjs/feathers' +import socketio from '@feathersjs/socketio-client' +import io from 'socket.io-client' + +const socket = io('http://api.feathersjs.com') +const app = feathers() + +// Set up Socket.io client with the socket +app.configure(socketio(socket)) + +// Call `users/2/messages` +app.service('users/:userId/messages').find({ + route: { + userId: 2 + } +}) +``` + +This can also be achieved by using the client bundled, +sharing several `servicePath` variable exported in the [service shared file](`../../guides/cli/service.shared.md#Variables`) file. + +```ts +import rest from '@feathersjs/rest-client' + +const connection = rest('https://myapp.com').fetch(window.fetch.bind(window)) + +const client = createClient(connection) + +// Call the `https://myapp.com/users/2/messages` URL +client.service(usersMyMessagesPath).find({ + route: { + userId: 2 + } +}) + +import io from 'socket.io-client' +import socketio from '@feathersjs/socketio-client' +import { createClient, usersMessagesPath } from 'my-app' + +const socket = io('http://api.my-feathers-server.com') +const connection = socketio(socket) + +const client = createClient(connection) + +const messageService = client.service('users/:userId/messages') + +// Call `users/2/messages` +app.service('users/:userId/messages').find({ + route: { + userId: 2 + } +}) +``` + + ## Direct connection Feathers sets up a normal Socket.io server that you can connect to with any Socket.io compatible client, usually the [Socket.io client](http://socket.io/docs/client-api/) either by loading the `socket.io-client` module or `/socket.io/socket.io.js` from the server. Query parameter types do not have to be converted from strings as they do for REST requests. diff --git a/packages/rest-client/src/base.ts b/packages/rest-client/src/base.ts index 5f74adebe7..6e337d784e 100644 --- a/packages/rest-client/src/base.ts +++ b/packages/rest-client/src/base.ts @@ -37,9 +37,15 @@ export abstract class Base, P extends Params = RestClien this.base = `${settings.base}/${this.name}` } - makeUrl(query: Query, id?: string | number | null) { + makeUrl(query: Query, id?: string | number | null, route?: { [key: string]: string }) { let url = this.base + if (route) { + Object.keys(route).forEach((key) => { + url = url.replace(`:${key}`, route[key]) + }) + } + query = query || {} if (typeof id !== 'undefined' && id !== null) { @@ -68,7 +74,7 @@ export abstract class Base, P extends Params = RestClien return this.request( { body: data, - url: this.makeUrl(params.query), + url: this.makeUrl(params.query, null, params.route), method: 'POST', headers: Object.assign( { @@ -92,7 +98,7 @@ export abstract class Base, P extends Params = RestClien _find(params?: P) { return this.request( { - url: this.makeUrl(params.query), + url: this.makeUrl(params.query, null, params.route), method: 'GET', headers: Object.assign({}, params.headers) }, @@ -111,7 +117,7 @@ export abstract class Base, P extends Params = RestClien return this.request( { - url: this.makeUrl(params.query, id), + url: this.makeUrl(params.query, id, params.route), method: 'GET', headers: Object.assign({}, params.headers) }, @@ -126,7 +132,7 @@ export abstract class Base, P extends Params = RestClien _create(data: D, params?: P) { return this.request( { - url: this.makeUrl(params.query), + url: this.makeUrl(params.query, null, params.route), body: data, method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, params.headers) @@ -148,7 +154,7 @@ export abstract class Base, P extends Params = RestClien return this.request( { - url: this.makeUrl(params.query, id), + url: this.makeUrl(params.query, id, params.route), body: data, method: 'PUT', headers: Object.assign({ 'Content-Type': 'application/json' }, params.headers) @@ -170,7 +176,7 @@ export abstract class Base, P extends Params = RestClien return this.request( { - url: this.makeUrl(params.query, id), + url: this.makeUrl(params.query, id, params.route), body: data, method: 'PATCH', headers: Object.assign({ 'Content-Type': 'application/json' }, params.headers) @@ -192,7 +198,7 @@ export abstract class Base, P extends Params = RestClien return this.request( { - url: this.makeUrl(params.query, id), + url: this.makeUrl(params.query, id, params.route), method: 'DELETE', headers: Object.assign({}, params.headers) }, diff --git a/packages/rest-client/test/index.test.ts b/packages/rest-client/test/index.test.ts index 46dd15cc61..d81c98407b 100644 --- a/packages/rest-client/test/index.test.ts +++ b/packages/rest-client/test/index.test.ts @@ -113,4 +113,63 @@ describe('REST client tests', function () { message: 'Custom fetch client' }) }) + + it('replace placeholder in route URLs', async () => { + const app = feathers() + let expectedValue: string | null = null + class MyFetchClient extends FetchClient { + request(options: any, _params: any) { + assert.equal(options.url, expectedValue) + return Promise.resolve() + } + } + app.configure(init('http://localhost:8889').fetch(fetch, {}, MyFetchClient)) + + expectedValue = 'http://localhost:8889/admin/todos' + await app.service(':slug/todos').find({ + route: { + slug: 'admin' + } + }) + await app.service(':slug/todos').create( + {}, + { + route: { + slug: 'admin' + } + } + ) + expectedValue = 'http://localhost:8889/admin/todos/0' + await app.service(':slug/todos').get(0, { + route: { + slug: 'admin' + } + }) + expectedValue = 'http://localhost:8889/admin/todos/0' + await app.service(':slug/todos').patch( + 0, + {}, + { + route: { + slug: 'admin' + } + } + ) + expectedValue = 'http://localhost:8889/admin/todos/0' + await app.service(':slug/todos').update( + 0, + {}, + { + route: { + slug: 'admin' + } + } + ) + expectedValue = 'http://localhost:8889/admin/todos/0' + await app.service(':slug/todos').remove(0, { + route: { + slug: 'admin' + } + }) + }) }) diff --git a/packages/transport-commons/src/client.ts b/packages/transport-commons/src/client.ts index 428cac4a92..f71b88bebc 100644 --- a/packages/transport-commons/src/client.ts +++ b/packages/transport-commons/src/client.ts @@ -78,7 +78,14 @@ export class Service, P extends Params = Params> send(method: string, ...args: any[]) { return new Promise((resolve, reject) => { - args.unshift(method, this.path) + const route: Record = args.pop() + let path = this.path + if (route) { + Object.keys(route).forEach((key) => { + path = path.replace(`:${key}`, route[key]) + }) + } + args.unshift(method, path) args.push(function (error: any, data: any) { return error ? reject(convert(error)) : resolve(data) }) @@ -93,17 +100,17 @@ export class Service, P extends Params = Params> names.forEach((method) => { const _method = `_${method}` this[_method] = function (data: any, params: Params = {}) { - return this.send(method, data, params.query || {}) + return this.send(method, data, params.query || {}, params.route || {}) } this[method] = function (data: any, params: Params = {}) { - return this[_method](data, params) + return this[_method](data, params, params.route || {}) } }) return this } _find(params: Params = {}) { - return this.send('find', params.query || {}) + return this.send('find', params.query || {}, params.route || {}) } find(params: Params = {}) { @@ -111,7 +118,7 @@ export class Service, P extends Params = Params> } _get(id: Id, params: Params = {}) { - return this.send('get', id, params.query || {}) + return this.send('get', id, params.query || {}, params.route || {}) } get(id: Id, params: Params = {}) { @@ -119,7 +126,7 @@ export class Service, P extends Params = Params> } _create(data: D, params: Params = {}) { - return this.send('create', data, params.query || {}) + return this.send('create', data, params.query || {}, params.route || {}) } create(data: D, params: Params = {}) { @@ -130,7 +137,7 @@ export class Service, P extends Params = Params> if (typeof id === 'undefined') { return Promise.reject(new Error("id for 'update' can not be undefined")) } - return this.send('update', id, data, params.query || {}) + return this.send('update', id, data, params.query || {}, params.route || {}) } update(id: NullableId, data: D, params: Params = {}) { @@ -138,7 +145,7 @@ export class Service, P extends Params = Params> } _patch(id: NullableId, data: D, params: Params = {}) { - return this.send('patch', id, data, params.query || {}) + return this.send('patch', id, data, params.query || {}, params.route || {}) } patch(id: NullableId, data: D, params: Params = {}) { @@ -146,7 +153,7 @@ export class Service, P extends Params = Params> } _remove(id: NullableId, params: Params = {}) { - return this.send('remove', id, params.query || {}) + return this.send('remove', id, params.query || {}, params.route || {}) } remove(id: NullableId, params: Params = {}) { diff --git a/packages/transport-commons/test/client.test.ts b/packages/transport-commons/test/client.test.ts index edd5c01506..380b46f929 100644 --- a/packages/transport-commons/test/client.test.ts +++ b/packages/transport-commons/test/client.test.ts @@ -128,6 +128,86 @@ describe('client', () => { }) }) + it('replace placeholder in service paths', async () => { + service = new Service({ + events: ['created'], + name: ':slug/todos', + method: 'emit', + connection + }) as any + + const idCb = (path: any, _id: any, _params: any, callback: DummyCallback) => callback(null, path) + const idDataCb = (path: any, _id: any, _data: any, _params: any, callback: DummyCallback) => + callback(null, path) + const dataCb = (path: any, _data: any, _params: any, callback: DummyCallback) => { + callback(null, path) + } + + connection.once('create', dataCb) + service.methods('customMethod') + + let res = await service.create(testData, { + route: { + slug: 'mySlug' + } + }) + + assert.strictEqual(res, 'mySlug/todos') + + connection.once('get', idCb) + res = await service.get(1, { + route: { + slug: 'mySlug' + } + }) + assert.strictEqual(res, 'mySlug/todos') + + connection.once('remove', idCb) + res = await service.remove(12, { + route: { + slug: 'mySlug' + } + }) + assert.strictEqual(res, 'mySlug/todos') + + connection.once('update', idDataCb) + res = await service.update(12, testData, { + route: { + slug: 'mySlug' + } + }) + assert.strictEqual(res, 'mySlug/todos') + + connection.once('patch', idDataCb) + res = await service.patch(12, testData, { + route: { + slug: 'mySlug' + } + }) + assert.strictEqual(res, 'mySlug/todos') + + connection.once('customMethod', dataCb) + res = await service.customMethod( + { message: 'test' }, + { + route: { + slug: 'mySlug' + } + } + ) + assert.strictEqual(res, 'mySlug/todos') + + connection.once('find', (path: any, _params: any, callback: DummyCallback) => callback(null, path)) + + res = await service.find({ + query: { test: true }, + route: { + slug: 'mySlug' + } + }) + assert.strictEqual(res, 'mySlug/todos') + }) + it('converts to feathers-errors (#19)', async () => { connection.once('create', (_path: any, _data: any, _params: any, callback: DummyCallback) => callback(new NotAuthenticated('Test', { hi: 'me' }).toJSON())