github.com/hernad/nomad@v1.6.112/ui/app/adapters/watchable.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 import { get } from '@ember/object'; 7 import { assign } from '@ember/polyfills'; 8 import { inject as service } from '@ember/service'; 9 import { AbortError } from '@ember-data/adapter/error'; 10 import queryString from 'query-string'; 11 import ApplicationAdapter from './application'; 12 import removeRecord from '../utils/remove-record'; 13 import classic from 'ember-classic-decorator'; 14 15 @classic 16 export default class Watchable extends ApplicationAdapter { 17 @service watchList; 18 @service store; 19 20 // Overriding ajax is not advised, but this is a minimal modification 21 // that sets off a series of events that results in query params being 22 // available in handleResponse below. Unfortunately, this is the only 23 // place where what becomes requestData can be modified. 24 // 25 // It's either this weird side-effecting thing that also requires a change 26 // to ajaxOptions or overriding ajax completely. 27 ajax(url, type, options) { 28 const hasParams = hasNonBlockingQueryParams(options); 29 if (!hasParams || type !== 'GET') return super.ajax(url, type, options); 30 31 const params = { ...options.data }; 32 delete params.index; 33 34 // Options data gets appended as query params as part of ajaxOptions. 35 // In order to prevent doubling params, data should only include index 36 // at this point since everything else is added to the URL in advance. 37 options.data = options.data.index ? { index: options.data.index } : {}; 38 39 return super.ajax(`${url}?${queryString.stringify(params)}`, type, options); 40 } 41 42 findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { 43 const params = assign(this.buildQuery(), additionalParams); 44 const url = this.urlForFindAll(type.modelName); 45 46 if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) { 47 params.index = this.watchList.getIndexFor(url); 48 } 49 50 const signal = get( 51 snapshotRecordArray || {}, 52 'adapterOptions.abortController.signal' 53 ); 54 return this.ajax(url, 'GET', { 55 signal, 56 data: params, 57 }); 58 } 59 60 findRecord(store, type, id, snapshot, additionalParams = {}) { 61 const originalUrl = this.buildURL( 62 type.modelName, 63 id, 64 snapshot, 65 'findRecord' 66 ); 67 let [url, params] = originalUrl.split('?'); 68 params = assign( 69 queryString.parse(params) || {}, 70 this.buildQuery(), 71 additionalParams 72 ); 73 74 if (get(snapshot || {}, 'adapterOptions.watch')) { 75 params.index = this.watchList.getIndexFor(originalUrl); 76 } 77 78 const signal = get(snapshot || {}, 'adapterOptions.abortController.signal'); 79 return this.ajax(url, 'GET', { 80 signal, 81 data: params, 82 }).catch((error) => { 83 if (error instanceof AbortError || error.name == 'AbortError') { 84 return; 85 } 86 throw error; 87 }); 88 } 89 90 query( 91 store, 92 type, 93 query, 94 snapshotRecordArray, 95 options, 96 additionalParams = {} 97 ) { 98 const url = this.buildURL(type.modelName, null, null, 'query', query); 99 let [urlPath, params] = url.split('?'); 100 params = assign( 101 queryString.parse(params) || {}, 102 this.buildQuery(), 103 additionalParams, 104 query 105 ); 106 107 if (get(options, 'adapterOptions.watch')) { 108 // The intended query without additional blocking query params is used 109 // to track the appropriate query index. 110 params.index = this.watchList.getIndexFor( 111 `${urlPath}?${queryString.stringify(query)}` 112 ); 113 } 114 115 const signal = get(options, 'adapterOptions.abortController.signal'); 116 return this.ajax(urlPath, 'GET', { 117 signal, 118 data: params, 119 }).then((payload) => { 120 const adapter = store.adapterFor(type.modelName); 121 122 // Query params may not necessarily map one-to-one to attribute names. 123 // Adapters are responsible for declaring param mappings. 124 const queryParamsToAttrs = Object.keys( 125 adapter.queryParamsToAttrs || {} 126 ).map((key) => ({ 127 queryParam: key, 128 attr: adapter.queryParamsToAttrs[key], 129 })); 130 131 // Remove existing records that match this query. This way if server-side 132 // deletes have occurred, the store won't have stale records. 133 store 134 .peekAll(type.modelName) 135 .filter((record) => 136 queryParamsToAttrs.some( 137 (mapping) => get(record, mapping.attr) === query[mapping.queryParam] 138 ) 139 ) 140 .forEach((record) => { 141 removeRecord(store, record); 142 }); 143 144 return payload; 145 }); 146 } 147 148 reloadRelationship( 149 model, 150 relationshipName, 151 options = { watch: false, abortController: null, replace: false } 152 ) { 153 const { watch, abortController, replace } = options; 154 const relationship = model.relationshipFor(relationshipName); 155 if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { 156 throw new Error( 157 `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` 158 ); 159 } else { 160 const url = model[relationship.kind](relationship.key).link(); 161 let params = {}; 162 163 if (watch) { 164 params.index = this.watchList.getIndexFor(url); 165 } 166 167 // Avoid duplicating existing query params by passing them to ajax 168 // in the URL and in options.data 169 if (url.includes('?')) { 170 const paramsInUrl = queryString.parse(url.split('?')[1]); 171 Object.keys(paramsInUrl).forEach((key) => { 172 delete params[key]; 173 }); 174 } 175 176 return this.ajax(url, 'GET', { 177 signal: abortController && abortController.signal, 178 data: params, 179 }).then( 180 (json) => { 181 const store = this.store; 182 const normalizeMethod = 183 relationship.kind === 'belongsTo' 184 ? 'normalizeFindBelongsToResponse' 185 : 'normalizeFindHasManyResponse'; 186 const serializer = store.serializerFor(relationship.type); 187 const modelClass = store.modelFor(relationship.type); 188 const normalizedData = serializer[normalizeMethod]( 189 store, 190 modelClass, 191 json 192 ); 193 if (replace) { 194 store.unloadAll(relationship.type); 195 } 196 store.push(normalizedData); 197 }, 198 (error) => { 199 if (error instanceof AbortError || error.name === 'AbortError') { 200 return relationship.kind === 'belongsTo' ? {} : []; 201 } 202 throw error; 203 } 204 ); 205 } 206 } 207 208 handleResponse(status, headers, payload, requestData) { 209 // Some browsers lowercase all headers. Others keep them 210 // case sensitive. 211 const newIndex = headers['x-nomad-index'] || headers['X-Nomad-Index']; 212 if (newIndex) { 213 this.watchList.setIndexFor(requestData.url, newIndex); 214 } 215 216 return super.handleResponse(...arguments); 217 } 218 } 219 220 function hasNonBlockingQueryParams(options) { 221 if (!options || !options.data) return false; 222 const keys = Object.keys(options.data); 223 if (!keys.length) return false; 224 if (keys.length === 1 && keys[0] === 'index') return false; 225 226 return true; 227 }