ogcopenapis 1.1.0 → 1.2.0

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.
@@ -25,6 +25,22 @@ export interface OgcOpenApiFeatureStoreConstructorOptions {
25
25
  extent?: { spatial: { bbox: any[] } };
26
26
  crs: string;
27
27
  proxify?: ProxifyFunction
28
+ // Called once for each query that succeeds, with what the response says about its size
29
+ onQueryResult?: (result: OgcOpenApiFeatureQueryResult) => void;
30
+ }
31
+
32
+ // What the response to a query says about its size (OGC API Features Part 1)
33
+ export interface OgcOpenApiFeatureQueryResult {
34
+ // The request, and the query it was made from
35
+ url: string;
36
+ query: OgcOpenApiFeatureStoreQueryOptions;
37
+ // The number of features that match the query, left out when the server gives none (it is optional, PDOK)
38
+ numberMatched?: number;
39
+ // The number of features in the response. Servers cap limit: it can be less than limit (pygeoapi gives 10)
40
+ numberReturned?: number;
41
+ // The response links a next page. Left out when the response has no links to tell: a GML response whose Link
42
+ // header the browser may not read (CORS)
43
+ hasNext?: boolean;
28
44
  }
29
45
 
30
46
  export interface OgcOpenApiFeatureStoreQueryOptions {
@@ -86,11 +102,13 @@ export class OgcOpenApiFeatureStore extends Store {
86
102
  private credentials: boolean;
87
103
  private serverOptions: { proxy: (url: string, fetchOptions) => { fetchOptions: any; url: string }; hostname: string; complete: (url: string) => string };
88
104
  private swapAxes: string[] | undefined;
105
+ private onQueryResult: ((result: OgcOpenApiFeatureQueryResult) => void) | undefined;
89
106
 
90
107
  constructor(options: OgcOpenApiFeatureStoreConstructorOptions) {
91
108
  super();
92
109
  // console.log(options);
93
110
  this.swapAxes = options.swapAxes;
111
+ this.onQueryResult = options.onQueryResult;
94
112
  this.useCrs84Bounds = typeof options.useCrs84Bounds !== "undefined" ? options.useCrs84Bounds : false;
95
113
  this.dataUrl = options.dataUrl;
96
114
  this.outputFormat = options.outputFormat;
@@ -152,7 +170,7 @@ export class OgcOpenApiFeatureStore extends Store {
152
170
  query.f = query.f ? query.f : this.dataFormat;
153
171
  query.crs = query.crs ? query.crs : this.customCrs;
154
172
  query.limit = query.limit ? query.limit : undefined;
155
- return this.fetchFeatures(this.withQuery(this.baseUrl, query), options?.abortSignal);
173
+ return this.fetchFeatures(this.withQuery(this.baseUrl, query), options?.abortSignal, query);
156
174
  }
157
175
 
158
176
  // The parameters of the items link, then those of the request, which replace a link parameter of the same name.
@@ -169,7 +187,8 @@ export class OgcOpenApiFeatureStore extends Store {
169
187
 
170
188
  // Fetches and decodes features. The promise always settles: it rejects with the reason when the request,
171
189
  // the response or the decoding fails, and when LuciadRIA aborts the request through abortSignal.
172
- private fetchFeatures(request: string, abortSignal?: AbortSignal): Promise<Cursor> {
190
+ // A query is reported to onQueryResult once its features are decoded, unless it was aborted
191
+ private fetchFeatures(request: string, abortSignal?: AbortSignal, query?: OgcOpenApiFeatureStoreQueryOptions): Promise<Cursor> {
173
192
  // Mod starta
174
193
  const itemsAccept =
175
194
  typeof this.requestHeaders.accept !== 'undefined'
@@ -203,10 +222,13 @@ export class OgcOpenApiFeatureStore extends Store {
203
222
  const codecOptions: OgcOpenApiFeaturesCodecDecodeOptions = {
204
223
  content, contentType, contentCrs, contentLength
205
224
  };
206
- if (this.codec instanceof GeoJsonCodec || this.codec instanceof GMLCodec) {
207
- return this.codec.decode({...codecOptions, reference: getReference(OgcOpenApiCrsTools.getReferenceName(contentCrs))});
225
+ const cursor = this.codec instanceof GeoJsonCodec || this.codec instanceof GMLCodec ?
226
+ this.codec.decode({...codecOptions, reference: getReference(OgcOpenApiCrsTools.getReferenceName(contentCrs))}) :
227
+ this.codec.decode(codecOptions);
228
+ if (query && this.onQueryResult && !abortSignal?.aborted) {
229
+ this.onQueryResult({url: request, query, ...OgcOpenApiFeatureStore.getResultSize(content, contentType, response.headers.get('Link'))});
208
230
  }
209
- return this.codec.decode(codecOptions);
231
+ return cursor;
210
232
  });
211
233
  });
212
234
  }
@@ -251,6 +273,47 @@ export class OgcOpenApiFeatureStore extends Store {
251
273
  return this.query(query, options);
252
274
  }
253
275
 
276
+ // The size of a response: the members of a JSON response, or the attributes of the root element of GML, and the
277
+ // next link of the response or of its Link header
278
+ private static getResultSize(content: string, contentType: string, linkHeader: string | null) {
279
+ const size: { numberMatched?: number, numberReturned?: number, hasNext?: boolean } = {};
280
+ const count = (value: any) => {
281
+ const n = typeof value === "string" ? (/^\d+$/.test(value) ? Number(value) : undefined) : value;
282
+ return typeof n === "number" && isFinite(n) && n >= 0 ? n : undefined;
283
+ };
284
+ if (/json/i.test(contentType)) {
285
+ let data: any;
286
+ try {
287
+ data = JSON.parse(content);
288
+ } catch (e) {
289
+ data = undefined;
290
+ }
291
+ if (data && typeof data === "object") {
292
+ size.numberMatched = count(data.numberMatched);
293
+ size.numberReturned = count(data.numberReturned);
294
+ if (Array.isArray(data.links)) size.hasNext = data.links.some(l => l && l.rel === "next");
295
+ }
296
+ } else if (/xml/i.test(contentType)) {
297
+ // WFS 2.0 allows numberMatched="unknown"
298
+ const root = /<(?:[\w.-]+:)?FeatureCollection\b([^>]*)>/.exec(content);
299
+ const attribute = (name: string) => {
300
+ const match = root ? new RegExp("\\s" + name + "=[\"']([^\"']*)[\"']").exec(root[1]) : null;
301
+ return match ? count(match[1]) : undefined;
302
+ };
303
+ size.numberMatched = attribute("numberMatched");
304
+ size.numberReturned = attribute("numberReturned");
305
+ }
306
+ if (linkHeader) {
307
+ const next = linkHeader.split(/,\s*(?=<)/).some(link => {
308
+ const rel = /;\s*rel\s*=\s*"?([^";]*)"?/i.exec(link);
309
+ return rel !== null && rel[1].split(/\s+/).indexOf("next") > -1;
310
+ });
311
+ size.hasNext = size.hasNext || next;
312
+ }
313
+ for (const key of Object.keys(size)) if (typeof size[key] === "undefined") delete size[key];
314
+ return size;
315
+ }
316
+
254
317
  private static getCRS84BoundingBox(shape: Bounds) {
255
318
  const WGS84 = getReference("CRS:84");
256
319
  const bounds = shape.bounds;
@@ -22,6 +22,36 @@ export const COLLECTIONS_SELECTION_CONFORMANCE = "http://www.opengis.net/spec/og
22
22
  // collections parameter as the standard requires. To be removed once ldproxy declares the class of the standard
23
23
  export const COLLECTIONS_SELECTION_CONFORMANCE_VARIANTS = ["http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/collection-selection"];
24
24
 
25
+ // The conformance class of OGC API Features Part 3 for the filter parameter of the items of a collection
26
+ export const FEATURES_FILTER_CONFORMANCE = "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter";
27
+ // The conformance classes of CQL2 1.0 (OGC 21-065) share this prefix, the encodings and the levels of the language
28
+ export const CQL2_CONFORMANCE_PREFIX = "http://www.opengis.net/spec/cql2/1.0/conf/";
29
+ // The CQL2 encodings, as conformance classes name them. They are also the values of the filter-lang parameter
30
+ export const CQL2_ENCODINGS = ["cql2-text", "cql2-json"];
31
+
32
+ export interface OgcOpenApiFilterSupport {
33
+ // The server declares the Features Filter class of OGC API Features Part 3: the items of its collections take a filter
34
+ filter: boolean;
35
+ // The CQL2 encodings the server declares, cql2-text or cql2-json
36
+ encodings: string[];
37
+ // The other CQL2 classes the server declares, such as basic-cql2, advanced-comparison-operators or spatial-functions
38
+ levels: string[];
39
+ }
40
+
41
+ // The queryables of a collection (OGC API Features Part 3): a JSON Schema, as the server sends it. Each property has
42
+ // a JSON Schema of its own, with type, format, title, enum or x-ogc-role among others
43
+ export interface OgcOpenApiQueryables {
44
+ $schema?: string;
45
+ $id?: string;
46
+ type?: string;
47
+ title?: string;
48
+ properties?: { [name: string]: { [keyword: string]: any } };
49
+ [keyword: string]: any;
50
+ }
51
+
52
+ // The media types of a queryables document, in the order they are preferred
53
+ const QUERYABLES_MEDIA_TYPES = ["application/schema+json", "application/json"];
54
+
25
55
  export interface TileMatrix {
26
56
  id: string,
27
57
  scaleDenominator: number;
@@ -187,155 +217,97 @@ export interface OgcOpenApiGetCapabilitiesFromURL {
187
217
 
188
218
  export class OgcOpenApiGetCapabilities {
189
219
 
190
- public static fromURL(inputRequest: string, fromUrlOptions?: OgcOpenApiGetCapabilitiesFromURL) {
220
+ /**
221
+ * Reads the landing page of a server, its collections, the API document and the conformance classes, asking for
222
+ * JSON. Rejects with an Error when the landing page or the collections cannot be read, and with the error of the
223
+ * request as it is when it fails, an AbortError included. The API document and the conformance classes are
224
+ * optional: without them the capabilities have no version and declare no conformance classes.
225
+ */
226
+ public static async fromURL(inputRequest: string, fromUrlOptions?: OgcOpenApiGetCapabilitiesFromURL): Promise<OgcOpenApiCapabilitiesObject> {
191
227
  const options: OgcOpenApiGetCapabilitiesFromURL = fromUrlOptions ? fromUrlOptions : {};
192
- return new Promise<OgcOpenApiCapabilitiesObject>((resolve, reject) => {
193
- // The landing page, with the query it was given, such as an api key. baseUrl is without it
194
- const baseUrl = OgcOpenApiFetchTools.cleanUrl(inputRequest);
195
- const requestUrl = OgcOpenApiFetchTools.appendPath(inputRequest, "");
196
- const serverOptions = OgcOpenApiFetchTools.createFetchOptions({originalUrl: requestUrl, proxify: options?.proxify});
197
-
198
- OgcOpenApiFetchTools.fetch(requestUrl, {
199
- method: 'GET',
200
- credentials: options?.credentials ? "same-origin" : "omit",
201
- headers: options? options.requestHeaders: {},
202
- }, serverOptions).then(
203
- (response) => {
204
- if (response.status === 200) {
205
- response.json().then((jsonObject) => {
206
- if (!jsonObject.links) {
207
- reject("Invalid format: (property 'links' is missing)")
208
- return;
209
- }
210
- let linkToData = jsonObject.links.find(
211
- (link: OgcOpenApiCapabilitiesCollectionServiceLinkType) =>
212
- link.rel === 'data' && link.type === 'application/json'
213
- );
214
- if (!linkToData) {
215
- linkToData = jsonObject.links.find((link) => link.rel === 'data' );
216
- }
217
- const linkToApi = jsonObject.links.find(
218
- (link: OgcOpenApiCapabilitiesCollectionServiceLinkType) => {
219
- return (
220
- (link.rel === 'service-desc' || link.rel === 'service') &&
221
- link.type.indexOf('openapi+json') > -1
222
- );
223
- }
224
- );
225
- const tilingSchemes = jsonObject.links.filter((link) => isRel(link, 'tiling-schemes'));
226
- const linkToTileMatrixSets = tilingSchemes.find((link) => link.type === 'application/json') || tilingSchemes.find((link) => !link.type);
227
- // The links of the landing page are relative to it, the links of the collections to /collections
228
- const resolveLink = (link: OgcOpenApiCapabilitiesCollectionServiceLinkType, documentUrl: string) =>
229
- ({...link, href: OgcOpenApiFetchTools.resolveUrl(link.href, documentUrl, serverOptions)});
230
- // The conformance classes, read with the collections. A server without them declares no classes
231
- const conformance = jsonObject.links.find((link) => isRel(link, 'conformance'));
232
- const conformancePromise: Promise<string[]> = conformance ?
233
- OgcOpenApiGetCapabilities.fetchJson({serverOptions} as OgcOpenApiCapabilitiesObject, resolveLink(conformance, requestUrl).href, options)
234
- .then(data => Array.isArray(data.conformsTo) ? data.conformsTo : [], () => []) :
235
- Promise.resolve([]);
236
- const promiseArray = [];
237
- if (linkToData) {
238
- const collectionsLink = resolveLink(linkToData, requestUrl);
239
- const collectionsPromise = OgcOpenApiGetCapabilities.fetchLinkContentAsJSON(
240
- collectionsLink,
241
- {credentials: options.credentials, requestHeaders: options.requestHeaders},
242
- serverOptions
243
- );
244
- promiseArray.push(collectionsPromise);
245
- if (linkToApi) {
246
- const apiPromise = OgcOpenApiGetCapabilities.fetchLinkContentAsJSON(
247
- resolveLink(linkToApi, requestUrl),
248
- {credentials: options.credentials, requestHeaders: options.requestHeaders},
249
- serverOptions
250
- );
251
- promiseArray.push(apiPromise);
252
- }
253
- Promise.all(promiseArray).then((responses: any) => {
254
- const responseDataLink = responses[0];
255
- const responseOpenApi =
256
- responses.length >= 1 ? responses[1] : undefined;
257
- const crsArray = typeof responseDataLink.crs !== "undefined" ? { crs: responseDataLink.crs } : {};
258
- const foundCollections = responseDataLink.collections.filter( c =>
259
- options && options.filterCollectionsByLinkType ?
260
- OgcOpenApiGetCapabilities.filterCollectionLinks(c.links,options.filterCollectionsByLinkType).length > 0 : true
261
- )
262
- .map(
263
- (collection: any) => {
264
- const name =
265
- typeof collection.id !== 'undefined'
266
- ? collection.id
267
- : collection.name;
268
- const collectionCrsObject = {} as any;
269
- if (typeof collection.crs !== "undefined") collectionCrsObject.crs = collection.crs;
270
- if (typeof collection.storageCrs !== "undefined") collectionCrsObject.storageCrs = collection.storageCrs;
271
- const layer: OgcOpenApiCapabilitiesCollection = {
272
- description: collection.description,
273
- id: name,
274
- name,
275
- title: collection.title,
276
- keywords: collection.keywords
277
- ? collection.keywords
278
- : [],
279
- defaultReference: 'CRS:84',
280
- links: collection.links,
281
- outputFormats: options.filterCollectionsByLinkType ?
282
- this.filterCollectionLinks(collection.links, options.filterCollectionsByLinkType).map(link => this.detectTypeFormat(link))
283
- :
284
- collection.links.map(link => this.detectTypeFormat(link))
285
- ,
286
- extent: collection.extent,
287
- ...collectionCrsObject
288
- };
289
- return layer;
290
- }
291
- );
292
- // console.log(featureTypes);
293
- const o: OgcOpenApiCapabilitiesObject = {
294
- name: '',
295
- collections: foundCollections.map(col=>({...col, crs: col.crs ? col.crs : [], links: col.links.map(l => resolveLink(l, collectionsLink.href))})),
296
- version: responseOpenApi ? responseOpenApi.openapi : '',
297
- service: '',
298
- info: responseOpenApi ? responseOpenApi.info : {},
299
- ...crsArray,
300
- serverOptions: serverOptions,
301
- links: jsonObject.links.map(l => resolveLink(l, requestUrl)),
302
- baseUrl,
303
- tileMatrixSetsUrl: linkToTileMatrixSets ? resolveLink(linkToTileMatrixSets, requestUrl).href : undefined
304
- };
305
- conformancePromise.then(conformsTo => resolve({...o, conformsTo}));
306
- });
307
- } else {
308
- reject("Invalid format: (property 'links' contains no links with rel=data");
309
- }
310
- }, ()=>{
311
- reject("Not JSON")
312
- });
313
- } else {
314
- reject(response.status);
315
- }
316
- },
317
- (err) => {
318
- reject(err);
319
- }
320
- );
321
- });
228
+ // The landing page, with the query it was given, such as an api key. baseUrl is without it
229
+ const baseUrl = OgcOpenApiFetchTools.cleanUrl(inputRequest);
230
+ const requestUrl = OgcOpenApiFetchTools.appendPath(inputRequest, "");
231
+ const serverOptions = OgcOpenApiFetchTools.createFetchOptions({originalUrl: requestUrl, proxify: options.proxify});
232
+ // The requests before the capabilities exist
233
+ const server = {serverOptions} as OgcOpenApiCapabilitiesObject;
234
+ const getJson = (href: string, accept?: string) =>
235
+ OgcOpenApiGetCapabilities.fetchJson(server, href, options, accept).catch(e => {
236
+ throw OgcOpenApiGetCapabilities.toError(e, href);
237
+ });
238
+
239
+ const landing = await getJson(requestUrl);
240
+ if (!landing || !Array.isArray(landing.links)) throw new Error("Invalid format: (property 'links' is missing) in " + requestUrl);
241
+ const links: OgcOpenApiCapabilitiesCollectionServiceLinkType[] = landing.links;
242
+ const linkToData = links.find(link => link.rel === 'data' && link.type === 'application/json') || links.find(link => link.rel === 'data');
243
+ if (!linkToData) throw new Error("Invalid format: (property 'links' contains no links with rel=data) in " + requestUrl);
244
+ const linkToApi = links.find(link => (link.rel === 'service-desc' || link.rel === 'service') && (link.type || "").indexOf('openapi+json') > -1);
245
+ const tilingSchemes = links.filter(link => isRel(link, 'tiling-schemes'));
246
+ const linkToTileMatrixSets = tilingSchemes.find(link => link.type === 'application/json') || tilingSchemes.find(link => !link.type);
247
+ const conformance = links.find(link => isRel(link, 'conformance'));
248
+ // The links of the landing page are relative to it, the links of the collections to /collections
249
+ const resolveLink = (link: OgcOpenApiCapabilitiesCollectionServiceLinkType, documentUrl: string) =>
250
+ ({...link, href: OgcOpenApiFetchTools.resolveUrl(link.href, documentUrl, serverOptions)});
251
+ const collectionsLink = resolveLink(linkToData, requestUrl);
252
+
253
+ // The collections are required, the API document (in its own media type) and the conformance classes are not
254
+ const [collectionsDocument, api, conformsTo] = await Promise.all([
255
+ getJson(collectionsLink.href),
256
+ linkToApi ? getJson(resolveLink(linkToApi, requestUrl).href, linkToApi.type).catch(() => undefined) : Promise.resolve(undefined),
257
+ conformance ?
258
+ getJson(resolveLink(conformance, requestUrl).href).then(data => Array.isArray(data.conformsTo) ? data.conformsTo as string[] : [], () => [] as string[]) :
259
+ Promise.resolve([] as string[]),
260
+ ]);
261
+ if (!collectionsDocument || !Array.isArray(collectionsDocument.collections)) {
262
+ throw new Error("Invalid format: (property 'collections' is missing) in " + collectionsLink.href);
263
+ }
264
+
265
+ const crsArray = typeof collectionsDocument.crs !== "undefined" ? {crs: collectionsDocument.crs} : {};
266
+ const foundCollections = collectionsDocument.collections
267
+ .filter(c => options.filterCollectionsByLinkType ?
268
+ OgcOpenApiGetCapabilities.filterCollectionLinks(c.links, options.filterCollectionsByLinkType).length > 0 : true)
269
+ .map((collection: any) => {
270
+ const name = typeof collection.id !== 'undefined' ? collection.id : collection.name;
271
+ const collectionCrsObject = {} as any;
272
+ if (typeof collection.crs !== "undefined") collectionCrsObject.crs = collection.crs;
273
+ if (typeof collection.storageCrs !== "undefined") collectionCrsObject.storageCrs = collection.storageCrs;
274
+ const layer: OgcOpenApiCapabilitiesCollection = {
275
+ description: collection.description,
276
+ id: name,
277
+ name,
278
+ title: collection.title,
279
+ keywords: collection.keywords ? collection.keywords : [],
280
+ defaultReference: 'CRS:84',
281
+ links: collection.links,
282
+ outputFormats: options.filterCollectionsByLinkType ?
283
+ this.filterCollectionLinks(collection.links, options.filterCollectionsByLinkType).map(link => this.detectTypeFormat(link)) :
284
+ collection.links.map(link => this.detectTypeFormat(link)),
285
+ extent: collection.extent,
286
+ ...collectionCrsObject
287
+ };
288
+ return layer;
289
+ });
290
+ return {
291
+ name: '',
292
+ collections: foundCollections.map(col => ({...col, crs: col.crs ? col.crs : [], links: col.links.map(l => resolveLink(l, collectionsLink.href))})),
293
+ version: api ? api.openapi : '',
294
+ service: '',
295
+ info: api ? api.info : {},
296
+ ...crsArray,
297
+ serverOptions: serverOptions,
298
+ links: links.map(l => resolveLink(l, requestUrl)),
299
+ baseUrl,
300
+ tileMatrixSetsUrl: linkToTileMatrixSets ? resolveLink(linkToTileMatrixSets, requestUrl).href : undefined,
301
+ conformsTo,
302
+ };
322
303
  }
323
304
 
324
- private static fetchLinkContentAsJSON(link: OgcOpenApiCapabilitiesCollectionServiceLinkType, options: {credentials: boolean, requestHeaders: RequestHeaders}, serverOptions?: FetchServerOptions) {
325
- return new Promise((resolve) => {
326
- OgcOpenApiFetchTools.fetch(link.href, {method: "GET", credentials: options.credentials ? "same-origin":"omit", headers: options.requestHeaders}, serverOptions).then(
327
- (result) => {
328
- if (result.status === 200) {
329
- result.json().then((json) => resolve(json));
330
- } else {
331
- resolve(undefined);
332
- }
333
- },
334
- () => {
335
- resolve(undefined);
336
- }
337
- );
338
- });
305
+ // An Error for a failed request of fromURL: the error of fetch as it is, such as an AbortError, otherwise its message
306
+ // and the URL. An error of fetch can come from another realm (a DOMException of jsdom is not instanceof Error), so
307
+ // anything with a name and a message is kept; the {error, message} of fetchJson has no name
308
+ private static toError(e: any, href: string): Error {
309
+ if (e instanceof Error || (e && typeof e.name === "string" && typeof e.message === "string")) return e;
310
+ return new Error((e && e.message ? e.message : String(e)) + " for " + href);
339
311
  }
340
312
 
341
313
  static filterCollectionLinks(
@@ -451,17 +423,15 @@ export class OgcOpenApiGetCapabilities {
451
423
  }
452
424
 
453
425
  // GETs a JSON document. Servers answer JSON for the Accept header, or for the f parameter that their links carry.
454
- private static async fetchJson(capabilities: OgcOpenApiCapabilitiesObject, href: string, options?: {credentials?: boolean, requestHeaders?: RequestHeaders}) {
455
- let result: Response;
456
- try {
457
- result = await OgcOpenApiFetchTools.fetch(href, {
458
- method: "GET",
459
- credentials: options?.credentials ? "same-origin" : "omit",
460
- headers: {Accept: "application/json", ...(options?.requestHeaders ? options.requestHeaders : {})}
461
- }, capabilities.serverOptions);
462
- } catch (err) {
463
- throw {error: true, message: err.message};
464
- }
426
+ // accept is the media type to ask for, application/json by default; requestHeaders can replace it. A failed request
427
+ // rejects with the error of fetch as it is, such as an AbortError
428
+ private static async fetchJson(capabilities: OgcOpenApiCapabilitiesObject, href: string, options?: {credentials?: boolean, requestHeaders?: RequestHeaders},
429
+ accept = "application/json") {
430
+ const result = await OgcOpenApiFetchTools.fetch(href, {
431
+ method: "GET",
432
+ credentials: options?.credentials ? "same-origin" : "omit",
433
+ headers: {Accept: accept, ...(options?.requestHeaders ? options.requestHeaders : {})}
434
+ }, capabilities.serverOptions);
465
435
  if (result.status !== 200) throw {error: true, message: "Invalid response: " + result.status};
466
436
  try {
467
437
  return await result.json();
@@ -510,6 +480,76 @@ export class OgcOpenApiGetCapabilities {
510
480
  COLLECTIONS_SELECTION_CONFORMANCE_VARIANTS.some(variant => conformsTo.indexOf(variant) > -1);
511
481
  }
512
482
 
483
+ /**
484
+ * Whether, and how, the server filters features, as its conformance classes declare: the Features Filter class of
485
+ * OGC API Features Part 3, and the CQL2 1.0 classes by their names, in the order the server lists them. Each is
486
+ * reported as declared, a CQL2 encoding without the Features Filter class gives filter false. Pre-standard CQL
487
+ * classes (ogcapi-features-3/1.0/conf/cql-text) are not CQL2 and are left out.
488
+ */
489
+ public static getFilterSupport(capabilities: OgcOpenApiCapabilitiesObject | null): OgcOpenApiFilterSupport {
490
+ const conformsTo = capabilities && capabilities.conformsTo ? capabilities.conformsTo : [];
491
+ const cql2 = conformsTo
492
+ .filter(uri => uri.indexOf(CQL2_CONFORMANCE_PREFIX) === 0)
493
+ .map(uri => uri.substring(CQL2_CONFORMANCE_PREFIX.length));
494
+ return {
495
+ filter: conformsTo.indexOf(FEATURES_FILTER_CONFORMANCE) > -1,
496
+ encodings: cql2.filter(name => CQL2_ENCODINGS.indexOf(name) > -1),
497
+ levels: cql2.filter(name => CQL2_ENCODINGS.indexOf(name) === -1),
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Fetches the queryables of a collection (OGC API Features Part 3), the properties a filter can use: a JSON Schema,
503
+ * as the server sends it. Resolves null for a collection that has none: a collection without features, or a
504
+ * queryables request that answers 404. Rejects with an Error otherwise, and with the error of the request as it is
505
+ * when it fails, an AbortError included.
506
+ */
507
+ public static async fetchQueryables(
508
+ capabilities: OgcOpenApiCapabilitiesObject | null,
509
+ collection: OgcOpenApiCapabilitiesCollection,
510
+ options?: {credentials?: boolean, requestHeaders?: RequestHeaders}
511
+ ): Promise<OgcOpenApiQueryables | null> {
512
+ if (capabilities === null) throw new Error("No capabilities to fetch the queryables of " + collection.id);
513
+ const href = OgcOpenApiGetCapabilities.getQueryablesHref(capabilities, collection);
514
+ if (!href) return null;
515
+ let response: Response;
516
+ try {
517
+ response = await OgcOpenApiFetchTools.fetch(href, {
518
+ method: "GET",
519
+ credentials: options?.credentials ? "same-origin" : "omit",
520
+ headers: {Accept: QUERYABLES_MEDIA_TYPES.join(", "), ...(options?.requestHeaders ? options.requestHeaders : {})}
521
+ }, capabilities.serverOptions);
522
+ } catch (e) {
523
+ throw OgcOpenApiGetCapabilities.toError(e, href);
524
+ }
525
+ if (response.status === 404) return null;
526
+ if (response.status !== 200) throw new Error("Invalid response: " + response.status + " for " + href);
527
+ let data: any;
528
+ try {
529
+ data = await response.json();
530
+ } catch (e) {
531
+ throw new Error("Not JSON data for " + href);
532
+ }
533
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid format: (not a JSON Schema) in " + href);
534
+ return data;
535
+ }
536
+
537
+ // The queryables link of the collection, in JSON. Some servers (ldproxy, PDOK) list it only in the collection
538
+ // document, not in /collections: then the path of the standard, /collections/{collectionId}/queryables, for a
539
+ // collection with features. Other collections have no queryables, '' then
540
+ private static getQueryablesHref(capabilities: OgcOpenApiCapabilitiesObject, collection: OgcOpenApiCapabilitiesCollection) {
541
+ const links = collection.links ? collection.links : [];
542
+ const queryables = links.filter(l => isRel(l, "queryables"));
543
+ const link = QUERYABLES_MEDIA_TYPES.map(type => queryables.find(l => l.type === type)).find(l => l) ||
544
+ queryables.find(l => !l.type);
545
+ if (link) return link.href;
546
+ if (OgcOpenApiGetCapabilities.filterCollectionLinks(links, CollectionLinkType.Items).length === 0) return '';
547
+ // The self link keeps its query, such as f=json (PDOK)
548
+ const self = links.find(l => l.rel === "self" && (!l.type || l.type === "application/json"));
549
+ const collectionHref = self ? self.href : capabilities.baseUrl + "collections/" + encodeURIComponent(collection.id);
550
+ return OgcOpenApiFetchTools.appendPath(collectionHref, "queryables");
551
+ }
552
+
513
553
  /**
514
554
  * Whether the server tiles the dataset as a whole, all its collections in each tile, for linkType Tiles or VectorTiles
515
555
  */
@@ -21,6 +21,8 @@ interface OgcOpenApiMapsModelConstructorOptions {
21
21
  samplingMode?: RasterSamplingMode;
22
22
  credentials?: boolean;
23
23
  requestHeaders?: { [p: string]: string };
24
+ // The f value of the requests, as the server names its formats (pygeoapi: png), not a media type (image/png), unlike
25
+ // the format of OgcOpenApiTilesModel. Best left out: the f value of the map link, in baseURL, is then used
24
26
  format?: string;
25
27
  reverseAxis?: boolean;
26
28
  datetime?: string;
@@ -31,7 +31,8 @@ export interface OgcOpenApiTilesModelCreateOptions extends OgcOpenApiTilesModelO
31
31
  tileMatrixSetLimits?: TileMatrixLimits[];
32
32
  // The data extent in CRS:84, [minX, minY, maxX, maxY], such as the extent of the collection
33
33
  extent?: number[];
34
- // The media type of the tiles, image/png by default
34
+ // The media type of the tiles, image/png by default, such as the type of the tile link. Unlike the format of
35
+ // OgcOpenApiMapsModel, which is an f value
35
36
  format?: string;
36
37
  }
37
38