/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ import { get } from '@ember/object'; import { assign } from '@ember/polyfills'; import { inject as service } from '@ember/service'; import { AbortError } from '@ember-data/adapter/error'; import queryString from 'query-string'; import ApplicationAdapter from './application'; import removeRecord from '../utils/remove-record'; import classic from 'ember-classic-decorator'; @classic export default class Watchable extends ApplicationAdapter { @service watchList; @service store; // Overriding ajax is not advised, but this is a minimal modification // that sets off a series of events that results in query params being // available in handleResponse below. Unfortunately, this is the only // place where what becomes requestData can be modified. // // It's either this weird side-effecting thing that also requires a change // to ajaxOptions or overriding ajax completely. ajax(url, type, options) { const hasParams = hasNonBlockingQueryParams(options); if (!hasParams || type !== 'GET') return super.ajax(url, type, options); const params = { ...options.data }; delete params.index; // Options data gets appended as query params as part of ajaxOptions. // In order to prevent doubling params, data should only include index // at this point since everything else is added to the URL in advance. options.data = options.data.index ? { index: options.data.index } : {}; return super.ajax(`${url}?${queryString.stringify(params)}`, type, options); } findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { const params = assign(this.buildQuery(), additionalParams); const url = this.urlForFindAll(type.modelName); if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) { params.index = this.watchList.getIndexFor(url); } const signal = get( snapshotRecordArray || {}, 'adapterOptions.abortController.signal' ); return this.ajax(url, 'GET', { signal, data: params, }); } findRecord(store, type, id, snapshot, additionalParams = {}) { const originalUrl = this.buildURL( type.modelName, id, snapshot, 'findRecord' ); let [url, params] = originalUrl.split('?'); params = assign( queryString.parse(params) || {}, this.buildQuery(), additionalParams ); if (get(snapshot || {}, 'adapterOptions.watch')) { params.index = this.watchList.getIndexFor(originalUrl); } const signal = get(snapshot || {}, 'adapterOptions.abortController.signal'); return this.ajax(url, 'GET', { signal, data: params, }).catch((error) => { if (error instanceof AbortError || error.name == 'AbortError') { return; } throw error; }); } query( store, type, query, snapshotRecordArray, options, additionalParams = {} ) { const url = this.buildURL(type.modelName, null, null, 'query', query); let [urlPath, params] = url.split('?'); params = assign( queryString.parse(params) || {}, this.buildQuery(), additionalParams, query ); if (get(options, 'adapterOptions.watch')) { // The intended query without additional blocking query params is used // to track the appropriate query index. params.index = this.watchList.getIndexFor( `${urlPath}?${queryString.stringify(query)}` ); } const signal = get(options, 'adapterOptions.abortController.signal'); return this.ajax(urlPath, 'GET', { signal, data: params, }).then((payload) => { const adapter = store.adapterFor(type.modelName); // Query params may not necessarily map one-to-one to attribute names. // Adapters are responsible for declaring param mappings. const queryParamsToAttrs = Object.keys( adapter.queryParamsToAttrs || {} ).map((key) => ({ queryParam: key, attr: adapter.queryParamsToAttrs[key], })); // Remove existing records that match this query. This way if server-side // deletes have occurred, the store won't have stale records. store .peekAll(type.modelName) .filter((record) => queryParamsToAttrs.some( (mapping) => get(record, mapping.attr) === query[mapping.queryParam] ) ) .forEach((record) => { removeRecord(store, record); }); return payload; }); } reloadRelationship( model, relationshipName, options = { watch: false, abortController: null, replace: false } ) { const { watch, abortController, replace } = options; const relationship = model.relationshipFor(relationshipName); if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { throw new Error( `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` ); } else { const url = model[relationship.kind](relationship.key).link(); let params = {}; if (watch) { params.index = this.watchList.getIndexFor(url); } // Avoid duplicating existing query params by passing them to ajax // in the URL and in options.data if (url.includes('?')) { const paramsInUrl = queryString.parse(url.split('?')[1]); Object.keys(paramsInUrl).forEach((key) => { delete params[key]; }); } return this.ajax(url, 'GET', { signal: abortController && abortController.signal, data: params, }).then( (json) => { const store = this.store; const normalizeMethod = relationship.kind === 'belongsTo' ? 'normalizeFindBelongsToResponse' : 'normalizeFindHasManyResponse'; const serializer = store.serializerFor(relationship.type); const modelClass = store.modelFor(relationship.type); const normalizedData = serializer[normalizeMethod]( store, modelClass, json ); if (replace) { store.unloadAll(relationship.type); } store.push(normalizedData); }, (error) => { if (error instanceof AbortError || error.name === 'AbortError') { return relationship.kind === 'belongsTo' ? {} : []; } throw error; } ); } } handleResponse(status, headers, payload, requestData) { // Some browsers lowercase all headers. Others keep them // case sensitive. const newIndex = headers['x-nomad-index'] || headers['X-Nomad-Index']; if (newIndex) { this.watchList.setIndexFor(requestData.url, newIndex); } return super.handleResponse(...arguments); } } function hasNonBlockingQueryParams(options) { if (!options || !options.data) return false; const keys = Object.keys(options.data); if (!keys.length) return false; if (keys.length === 1 && keys[0] === 'index') return false; return true; }