From c55116644c2ba0dec147935e19fbb1b1db68a618 Mon Sep 17 00:00:00 2001 From: mrloop Date: Thu, 11 Mar 2021 10:35:05 +0000 Subject: [PATCH 1/2] fix: url with query params This PR will correctly construct a URL where the ember-api-action path contains query params and/or where the ember data adapter buildURL returns a url containing query params. Fixes #414 --- addon/utils/build-url.ts | 19 ++++++++-- tests/acceptance/index-test.ts | 32 ++++++++++++++++ tests/dummy/app/adapters/vegatable.ts | 8 ++++ tests/dummy/app/controllers/index.js | 9 +++++ tests/dummy/app/models/vegatable.js | 15 ++++++++ tests/dummy/app/routes/index.ts | 54 +++++++++++++++++++++++---- tests/dummy/app/templates/index.hbs | 49 ++++++++++++++++++++---- 7 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 tests/dummy/app/adapters/vegatable.ts create mode 100644 tests/dummy/app/models/vegatable.js diff --git a/addon/utils/build-url.ts b/addon/utils/build-url.ts index d84474e0..2c094772 100644 --- a/addon/utils/build-url.ts +++ b/addon/utils/build-url.ts @@ -62,11 +62,24 @@ export function buildOperationUrl( return baseUrl; } - if (baseUrl.charAt(baseUrl.length - 1) === '/') { - return `${baseUrl}${path}`; + let url; + const [baseUrlNoQueries, baseQueries] = baseUrl.split('?'); + const [pathNoQueries, pathQueries] = path.split('?'); + + if (baseUrlNoQueries.charAt(baseUrl.length - 1) === '/') { + url = `${baseUrlNoQueries}${pathNoQueries}`; } else { - return `${baseUrl}/${path}`; + url = `${baseUrlNoQueries}/${pathNoQueries}`; + } + + if (baseQueries || pathQueries) { + const baseSearchParams = new URLSearchParams(baseQueries); + const pathSearchParams = new URLSearchParams(pathQueries); + for (const [k, v] of pathSearchParams) { baseSearchParams.append(k, v) }; + url = `${url}?${baseSearchParams.toString()}`; } + + return url; } export default buildOperationUrl; diff --git a/tests/acceptance/index-test.ts b/tests/acceptance/index-test.ts index 5ad73578..dbe40f35 100644 --- a/tests/acceptance/index-test.ts +++ b/tests/acceptance/index-test.ts @@ -118,4 +118,36 @@ module('Acceptance | index2', hooks => { (assert as any).dom(`[data-test-fruit-name="Completely Eaten apple"]`).exists(); }); + + test('query params', async function(assert) { + await visit('/'); + assert.expect(9); + + this.server.get('/vegatables/:id/info', (request) => { + assert.equal(request.params.id, '1', 'request made to the right URL'); + assert.equal(request.queryParams.vegatableId, '1', 'request made with the right query params'); + assert.equal(request.queryParams.vegatable, 'potato', 'request made with the right buildURL query params'); + return [200, {}, '{"status": "ok"}']; + }); + + this.server.get('/vegatables/:id/moreInfo', (request) => { + assert.equal(request.params.id, '2', 'request made to the right URL'); + assert.equal(request.queryParams.vegatableId, '2', 'request made with the right query params'); + assert.equal(request.queryParams.vegatable, 'carrot', 'request made with the right buildURL query params'); + assert.equal(request.queryParams.more, 'true', 'request made with the right path query params'); + return [200, {}, '{"status": "ok"}']; + }); + + this.server.get('/vegatables/allInfo', (request) => { + assert.equal(request.queryParams.vegatable, 'potato', 'request made with the right buildURL query params'); + assert.equal(request.queryParams.less, 'false', 'request made with the right path query params'); + return [200, {}, '{"status": "ok"}']; + }); + + await click('#potato .info-instance-button'); + + await click('#carrot .more-info-instance-button'); + + await click('.all-vegatables .all-info-button'); + }); }); diff --git a/tests/dummy/app/adapters/vegatable.ts b/tests/dummy/app/adapters/vegatable.ts new file mode 100644 index 00000000..69da3613 --- /dev/null +++ b/tests/dummy/app/adapters/vegatable.ts @@ -0,0 +1,8 @@ +import ApplicationAdapter from './application'; + +export default class VegatableAdapter extends ApplicationAdapter { + public buildURL(modelName: string, id: string|[string]|object, snapshot: object, requestType: string, query: object) { + const url = super.buildURL(modelName, id, snapshot, requestType, query); + return `${url}?${modelName}=${snapshot.attr('name')}`; + } +} diff --git a/tests/dummy/app/controllers/index.js b/tests/dummy/app/controllers/index.js index a779da60..4228a0ba 100644 --- a/tests/dummy/app/controllers/index.js +++ b/tests/dummy/app/controllers/index.js @@ -31,6 +31,15 @@ export default Controller.extend({ }, juiceAllFruit(fruit) { Fruit.juiceAll({ was_eaten: true }); + }, + vegatableInfo(vegatable) { + vegatable.info({ vegatableId: vegatable.id }); + }, + vegatableMoreInfo(vegatable) { + vegatable.moreInfo({ vegatableId: vegatable.id }); + }, + allInfoVegatables(vegatable) { + vegatable.allInfo(); } } // END-SNIPPET diff --git a/tests/dummy/app/models/vegatable.js b/tests/dummy/app/models/vegatable.js new file mode 100644 index 00000000..fecd69e4 --- /dev/null +++ b/tests/dummy/app/models/vegatable.js @@ -0,0 +1,15 @@ +// BEGIN-SNIPPET vegatable-model +import DS from 'ember-data'; +import { collectionAction, memberAction } from 'ember-api-actions'; + +const { attr, Model } = DS; + +const Vegatable = Model.extend({ + name: attr('string'), + info: memberAction({ path: 'info', type: 'get' }), + moreInfo: memberAction({ path: 'moreInfo?more=true', type: 'get' }), + allInfo: collectionAction({ path: 'allInfo?less=false', type: 'get' }), +}); + +export default Vegatable; +// END-SNIPPET diff --git a/tests/dummy/app/routes/index.ts b/tests/dummy/app/routes/index.ts index 78ef0f4e..15c9816f 100644 --- a/tests/dummy/app/routes/index.ts +++ b/tests/dummy/app/routes/index.ts @@ -5,7 +5,7 @@ import Pretender from 'pretender'; const { testing } = Ember; -const LEGACY_PAYLOAD = { +const LEGACY_FRUIT_PAYLOAD = { fruit: [ { id: 1, @@ -26,7 +26,20 @@ const LEGACY_PAYLOAD = { ] }; -const PAYLOAD = { +const LEGACY_VEGATABLE_PAYLOAD = { + vegatable: [ + { + id: 1, + name: 'potato', + }, + { + id: 2, + name: 'carrot', + } + ] +} + +const FRUIT_PAYLOAD = { data: [ { type: 'fruit', @@ -55,23 +68,50 @@ const PAYLOAD = { attributes: { name: 'grape' } + }, + ] +}; + +const VEGATABLE_PAYLOAD = { + data: [ + { + type: 'vegatable', + id: 1, + attributes: { + name: 'potato' + } + }, + { + type: 'vegatable', + id: 2, + attributes: { + name: 'carrot' + } } ] }; + + + export default Route.extend({ server: undefined as any, requests: [] as any[], currentModel: undefined as any, model() { - let arr: any = []; - this.store.pushPayload('fruit', !this.store.peekAll ? LEGACY_PAYLOAD : PAYLOAD); + let fruitArr: any = []; + let vegatableArr: any = []; + this.store.pushPayload('fruit', !this.store.peekAll ? LEGACY_FRUIT_PAYLOAD : FRUIT_PAYLOAD); + this.store.pushPayload('vegatable', !this.store.peekAll ? LEGACY_VEGATABLE_PAYLOAD : VEGATABLE_PAYLOAD); if (!this.store.peekAll) { - arr = [1, 2, 3, 4].map(id => (this.store as any).getById('fruit', id)); + fruitArr = [1, 2, 3, 4].map(id => (this.store as any).getById('fruit', id)); + vegatableArr = [1, 2].map(id => (this.store as any).getById('vegatable', id)); } else { - arr = this.store.peekAll('fruit'); + fruitArr = this.store.peekAll('fruit'); + vegatableArr = this.store.peekAll('vegatable'); } - return A(arr); + + return { fruit: A(fruitArr), vegatable: A(vegatableArr) }; }, beforeModel() { diff --git a/tests/dummy/app/templates/index.hbs b/tests/dummy/app/templates/index.hbs index 5f730749..ffd54e32 100644 --- a/tests/dummy/app/templates/index.hbs +++ b/tests/dummy/app/templates/index.hbs @@ -1,14 +1,16 @@

API actions on an individual resource

- {{#each model as |fruit|}} + +
Fruit
+ {{#each model.fruit as |fruit|}}

{{#x-btn class="ripen-instance-button yellow" click=(action "ripenFruit" fruit) }} Ripen - {{/x-btn}} + {{/x-btn}} {{#x-btn class="info-instance-button indigo white-text" click=(action "fruitInfo" fruit) @@ -23,26 +25,59 @@ {{#x-btn class="eat-instance-button yellow" click=(action "eatFruit" fruit)}}Eat{{/x-btn}}

{{/each}} + +
Vegatable
+ {{#each model.vegatable as |vegatable|}} +

+ {{#x-btn + class="info-instance-button indigo white-text" + click=(action "vegatableInfo" vegatable) + }} + Info + {{/x-btn}} +

+ +

+ {{#x-btn + class="more-info-instance-button yellow white-text" + click=(action "vegatableMoreInfo" vegatable) + }} + More Info + {{/x-btn}} +

+ {{/each}} +

API action on a collection of resources

+

+ {{content.vegatable.constructor.modelName}} + {{#x-btn + class="all-info-button indigo white-text" + click=(action "allInfoVegatables" + (first-in-array (or content.vegatable model.vegatable))) + }} + All Info + {{/x-btn}} +

+

- {{content.constructor.modelName}} + {{content.fruit.constructor.modelName}} {{#x-btn class="ripen-type-button yellow" - click=(action "ripenAllFruit" (first-in-array (or content model))) + click=(action "ripenAllFruit" (first-in-array (or content.fruit model.fruit))) }} Ripen All {{/x-btn}} {{#x-btn class="fresh-type-button indigo white-text" click=(action "getAllFreshFruit" - (first-in-array (or content model))) + (first-in-array (or content.fruit model.fruit))) }} Get Fresh {{/x-btn}} {{#x-btn class="fresh-type-button indigo white-text" click=(action "juiceAllFruit" - (first-in-array (or content model))) + (first-in-array (or content.fruit model.fruit))) }} Juice Everything {{/x-btn}} @@ -50,7 +85,7 @@ {{#x-btn class="eat-all-button indigo white-text" click=(action "eatAll" - (first-in-array (or content model))) + (first-in-array (or content.fruit model.fruit))) }} Eat all {{/x-btn}} From 09e647d8426d11943760a8ee0cb058c5ddb949e6 Mon Sep 17 00:00:00 2001 From: mrloop Date: Mon, 22 Mar 2021 20:00:28 +0000 Subject: [PATCH 2/2] feat: adapterOptions Allows api actions to take an optional `options` object. If you pass an object on the adapterOptions property of the options argument it will be passed to your adapter via the snapshot. Similar to ember data Store and Modal functions, for example https://api.emberjs.com/ember-data/3.25/classes/Store/methods/findAll?anchor=findAll https://api.emberjs.com/ember-data/3.25/classes/Model/methods/save?anchor=save This is similar to #214 with tests. Closes #214 --- addon/utils/build-url.ts | 2 ++ addon/utils/collection-action.ts | 8 +++++-- addon/utils/member-action.ts | 8 +++++-- tests/acceptance/index-test.ts | 34 +++++++++++++++++++++++++++ tests/dummy/app/adapters/vegatable.ts | 12 ++++++++-- tests/dummy/app/controllers/index.js | 5 +++- tests/dummy/app/templates/index.hbs | 9 +++++++ 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/addon/utils/build-url.ts b/addon/utils/build-url.ts index 2c094772..911163c7 100644 --- a/addon/utils/build-url.ts +++ b/addon/utils/build-url.ts @@ -48,6 +48,7 @@ export function buildOperationUrl( record: M, opPath: string, urlType: EmberDataRequestType, + adapterOptions: any, instance = true ) { const modelClass = _getModelClass(record); @@ -56,6 +57,7 @@ export function buildOperationUrl( const adapter = store.adapterFor(modelName); const path = opPath; const snapshot = snapshotFromRecord(record); + snapshot.adapterOptions = adapterOptions; const baseUrl = adapter.buildURL(modelName, instance ? record.get('id') : null, snapshot, urlType); if (!path) { diff --git a/addon/utils/collection-action.ts b/addon/utils/collection-action.ts index e390157a..49966c6e 100644 --- a/addon/utils/collection-action.ts +++ b/addon/utils/collection-action.ts @@ -14,7 +14,11 @@ export interface CollectionOperationOptions { } export default function collectionOp(options: CollectionOperationOptions) { - return function runCollectionOp(this: Model, payload: IN): Promise { + return function runCollectionOp( + this: Model, + payload: IN, + collectionOptions: any = {} + ): Promise { const model: Model = this; const recordClass = _getModelClass(model); const modelName = _getModelName(recordClass); @@ -22,7 +26,7 @@ export default function collectionOp(options: CollectionOpe const requestType: HTTPVerb = strictifyHttpVerb(options.type || 'put'); const urlType: EmberDataRequestType = options.urlType || 'updateRecord'; const adapter = store.adapterFor(modelName); - const fullUrl = buildOperationUrl(model, options.path, urlType, false); + const fullUrl = buildOperationUrl(model, options.path, urlType, collectionOptions.adapterOptions, false); const data = (options.before && options.before.call(model, payload)) || payload; return adapter .ajax(fullUrl, requestType, assign(options.ajaxOptions || {}, { data })) diff --git a/addon/utils/member-action.ts b/addon/utils/member-action.ts index 633d362b..649e650e 100644 --- a/addon/utils/member-action.ts +++ b/addon/utils/member-action.ts @@ -14,14 +14,18 @@ export interface InstanceOperationOptions { } export default function instanceOp(options: InstanceOperationOptions) { - return function runInstanceOp(this: Model, payload: IN): Promise { + return function runInstanceOp( + this: Model, + payload: IN, + instanceOptions:any = {} + ): Promise { const recordClass = _getModelClass(this); const modelName = _getModelName(recordClass); const store = _getStoreFromRecord(this); const { ajaxOptions, path, before, after, type = 'put', urlType = 'updateRecord' } = options; const requestType: HTTPVerb = strictifyHttpVerb(type); const adapter = store.adapterFor(modelName); - const fullUrl = buildOperationUrl(this, path, urlType); + const fullUrl = buildOperationUrl(this, path, urlType, instanceOptions.adapterOptions); const data = (before && before.call(this, payload)) || payload; return adapter.ajax(fullUrl, requestType, assign(ajaxOptions || {}, { data })).then((response: JSONValue) => { if (after && !this.isDestroyed) { diff --git a/tests/acceptance/index-test.ts b/tests/acceptance/index-test.ts index dbe40f35..1661a201 100644 --- a/tests/acceptance/index-test.ts +++ b/tests/acceptance/index-test.ts @@ -150,4 +150,38 @@ module('Acceptance | index2', hooks => { await click('.all-vegatables .all-info-button'); }); + + test('adapterOptions', async function(assert) { + await visit('/'); + assert.expect(11); + + this.server.get('/vegatables/:id/moreInfo', (request) => { + assert.equal(request.params.id, '1', 'request made to the right URL'); + assert.equal(request.queryParams.vegatableId, '1', 'request made with the right query params'); + assert.equal(request.queryParams.vegatable, 'potato', 'request made with the right buildURL query params'); + assert.equal(request.queryParams.extra, 'false', 'request made with the right adapterOptions query params'); + return [200, {}, '{"status": "ok"}']; + }); + + await click('#potato .options-instance-button'); + + this.server.get('/vegatables/:id/moreInfo', (request) => { + assert.equal(request.params.id, '2', 'request made to the right URL'); + assert.equal(request.queryParams.vegatableId, '2', 'request made with the right query params'); + assert.equal(request.queryParams.vegatable, 'carrot', 'request made with the right buildURL query params'); + assert.equal(request.queryParams.more, 'true', 'request made with the right path query params'); + assert.equal(request.queryParams.extra, 'false', 'request made with the right adapterOptions query params'); + return [200, {}, '{"status": "ok"}']; + }); + + await click('#carrot .options-instance-button'); + + this.server.get('/vegatables/allInfo', (request) => { + assert.equal(request.queryParams.vegatable, 'potato', 'request made with the right buildURL query params'); + assert.equal(request.queryParams.extra, 'false', 'request made with the right adapterOptions query params'); + return [200, {}, '{"status": "ok"}']; + }); + + await click('.all-vegatables .all-info-button'); + }); }); diff --git a/tests/dummy/app/adapters/vegatable.ts b/tests/dummy/app/adapters/vegatable.ts index 69da3613..60555fae 100644 --- a/tests/dummy/app/adapters/vegatable.ts +++ b/tests/dummy/app/adapters/vegatable.ts @@ -2,7 +2,15 @@ import ApplicationAdapter from './application'; export default class VegatableAdapter extends ApplicationAdapter { public buildURL(modelName: string, id: string|[string]|object, snapshot: object, requestType: string, query: object) { - const url = super.buildURL(modelName, id, snapshot, requestType, query); - return `${url}?${modelName}=${snapshot.attr('name')}`; + const urlStr = super.buildURL(modelName, id, snapshot, requestType, query); + const [path, searchStr] = urlStr.split('?'); + const searchParams = new URLSearchParams(searchStr); + // for testing buildURL queryParams + searchParams.append(modelName, snapshot.attr('name')); + // for testing adapterOptions + for (const [k, v] of Object.entries(snapshot.adapterOptions || {})) { + searchParams.append(k, v.toString()); + } + return `${path}?${searchParams.toString()}`; } } diff --git a/tests/dummy/app/controllers/index.js b/tests/dummy/app/controllers/index.js index 4228a0ba..6be7f164 100644 --- a/tests/dummy/app/controllers/index.js +++ b/tests/dummy/app/controllers/index.js @@ -38,8 +38,11 @@ export default Controller.extend({ vegatableMoreInfo(vegatable) { vegatable.moreInfo({ vegatableId: vegatable.id }); }, + vegatableOptions(vegatable) { + vegatable.moreInfo({ vegatableId: vegatable.id }, { adapterOptions: { extra: 'false' }}); + }, allInfoVegatables(vegatable) { - vegatable.allInfo(); + vegatable.allInfo({}, { adapterOptions: { extra: 'false' }}); } } // END-SNIPPET diff --git a/tests/dummy/app/templates/index.hbs b/tests/dummy/app/templates/index.hbs index ffd54e32..050f402f 100644 --- a/tests/dummy/app/templates/index.hbs +++ b/tests/dummy/app/templates/index.hbs @@ -45,6 +45,15 @@ More Info {{/x-btn}}

+ +

+ {{#x-btn + class="options-instance-button yellow white-text" + click=(action "vegatableOptions" vegatable) + }} + Options + {{/x-btn}} +

{{/each}}

API action on a collection of resources