Skip to content

Commit

Permalink
Merge pull request #37 from camptocamp/ogc-api-better-collection-info…
Browse files Browse the repository at this point in the history
…-fetch

OGC API: misc improvements
  • Loading branch information
jahow authored Apr 24, 2024
2 parents daff63a + 83fd44b commit 1ac4b12
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 38 deletions.
41 changes: 40 additions & 1 deletion src/ogc-api/endpoint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,16 @@ beforeAll(() => {
};
});

jest.useFakeTimers();

describe('OgcApiEndpoint', () => {
let endpoint: OgcApiEndpoint;

afterEach(async () => {
// this will exhaust all microtasks, effectively preventing rejected promises from leaking between tests
await jest.runAllTimersAsync();
});

describe('nominal case', () => {
beforeEach(() => {
endpoint = new OgcApiEndpoint('http://local/sample-data/');
Expand Down Expand Up @@ -216,6 +224,7 @@ describe('OgcApiEndpoint', () => {
'application/vnd.ogc.fg+json;compatibility=geojson',
'text/html',
],
bulkDownloadLinks: {},
extent: {
spatial: {
bbox: [
Expand Down Expand Up @@ -266,6 +275,7 @@ describe('OgcApiEndpoint', () => {
description:
'Sample metadata records from Dutch Nationaal georegister',
formats: ['application/geo+json', 'application/ld+json', 'text/html'],
bulkDownloadLinks: {},
keywords: ['netherlands', 'open data', 'georegister'],
extent: {
spatial: {
Expand Down Expand Up @@ -343,6 +353,7 @@ describe('OgcApiEndpoint', () => {
'application/vnd.ogc.fg+json;compatibility=geojson',
'text/html',
],
bulkDownloadLinks: {},
extent: {
spatial: {
bbox: [
Expand Down Expand Up @@ -1549,10 +1560,11 @@ describe('OgcApiEndpoint', () => {
});
describe('a failure happens while parsing the endpoint capabilities', () => {
beforeEach(() => {
endpoint = new OgcApiEndpoint('http://local/sample-data/notjson'); // not actually json
// endpoint = new OgcApiEndpoint('http://local/sample-data/notjson'); // not actually json
});
describe('#info', () => {
it('throws an explicit error', async () => {
endpoint = new OgcApiEndpoint('http://local/sample-data/notjson'); // not actually json
await expect(endpoint.info).rejects.toEqual(
new EndpointError(
`The endpoint appears non-conforming, the following error was encountered:
Expand Down Expand Up @@ -1713,6 +1725,33 @@ The document at http://local/nonexisting?f=json could not be fetched.`
]);
});
});
describe('#getCollectionInfo', () => {
it('returns a collection info', async () => {
await expect(
endpoint.getCollectionInfo('aires-covoiturage')
).resolves.toStrictEqual({
crs: ['http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'EPSG:4326'],
formats: ['application/geo+json'],
bulkDownloadLinks: {
'application/geo+json':
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=geojson&limit=-1',
'application/json':
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=json&limit=-1',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=ooxml&limit=-1',
'application/x-shapefile':
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=shapefile&limit=-1',
'text/csv;charset=UTF-8':
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=csv&limit=-1',
},
id: 'aires-covoiturage',
itemType: 'feature',
queryables: [],
sortables: [],
title: 'aires-covoiturage',
});
});
});
});
});
});
95 changes: 60 additions & 35 deletions src/ogc-api/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
fetchLink,
fetchRoot,
getLinkUrl,
hasLinks,
} from './link-utils.js';
import { EndpointError } from '../shared/errors.js';
import { BoundingBox, CrsCode, MimeType } from '../shared/models.js';
Expand All @@ -30,49 +31,66 @@ import { BoundingBox, CrsCode, MimeType } from '../shared/models.js';
* Represents an OGC API endpoint advertising various collections and services.
*/
export default class OgcApiEndpoint {
private root: Promise<OgcApiDocument>;
private conformance: Promise<OgcApiDocument>;
private data: Promise<OgcApiDocument>;
// these are cached results because the getters rely on HTTP requests; to avoid
// unhandled promise rejections the getters are evaluated lazily
private root_: Promise<OgcApiDocument>;
private conformance_: Promise<OgcApiDocument>;
private data_: Promise<OgcApiDocument>;

/**
* Creates a new OGC API endpoint.
* @param baseUrl Base URL used to query the endpoint. Note that this can point to nested
* documents inside the endpoint, such as `/collections`, `/collections/items` etc.
*/
constructor(private baseUrl: string) {
this.root = fetchRoot(this.baseUrl).catch((e) => {
throw new Error(`The endpoint appears non-conforming, the following error was encountered:
private get root(): Promise<OgcApiDocument> {
if (!this.root_) {
this.root_ = fetchRoot(this.baseUrl).catch((e) => {
throw new Error(`The endpoint appears non-conforming, the following error was encountered:
${e.message}`);
});
this.conformance = this.root
.then((root) =>
});
}
return this.root_;
}
private get conformance(): Promise<OgcApiDocument> {
if (!this.conformance_) {
this.conformance_ = this.root.then((root) =>
fetchLink(
root,
['conformance', 'http://www.opengis.net/def/rel/ogc/1.0/conformance'],
this.baseUrl
)
);
}
return this.conformance_;
}
private get collectionsUrl(): Promise<string> {
return this.root.then((root) =>
getLinkUrl(
root,
['data', 'http://www.opengis.net/def/rel/ogc/1.0/data'],
this.baseUrl
)
.catch(() => null);
this.data = this.root
.then((root) =>
fetchLink(
root,
['data', 'http://www.opengis.net/def/rel/ogc/1.0/data'],
this.baseUrl
)
)
.then(async (data) => {
// check if there's a collection in the path; if yes, keep only this one
const singleCollection = await fetchCollectionRoot(this.baseUrl);
if (singleCollection !== null && Array.isArray(data.collections)) {
data.collections = data.collections.filter(
(collection) => collection.id === singleCollection.id
);
}
return data;
})
.catch(() => null);
);
}
private get data(): Promise<OgcApiDocument> {
if (!this.data_) {
this.data_ = this.collectionsUrl
.then(fetchDocument)
.then(async (data) => {
// check if there's a collection in the path; if yes, keep only this one
const singleCollection = await fetchCollectionRoot(this.baseUrl);
if (singleCollection !== null && Array.isArray(data.collections)) {
data.collections = data.collections.filter(
(collection) => collection.id === singleCollection.id
);
}
return data;
});
}
return this.data_;
}

/**
* Creates a new OGC API endpoint.
* @param baseUrl Base URL used to query the endpoint. Note that this can point to nested
* documents inside the endpoint, such as `/collections`, `/collections/items` etc.
*/
constructor(private baseUrl: string) {}

/**
* A Promise which resolves to the endpoint information.
Expand Down Expand Up @@ -155,7 +173,14 @@ ${e.message}`);
(collection) => collection.id === collectionId
);
})
.then((collection) => fetchLink(collection, 'self', this.baseUrl));
.then(async (collection) => {
// if a self link is there, use it!
if (hasLinks(collection, ['self'])) {
return fetchLink(collection, 'self', this.baseUrl);
}
// otherwise build a URL for the collection
return fetchDocument(`${await this.collectionsUrl}/${collectionId}`);
});
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/ogc-api/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function parseBaseCollectionInfo(
const formats = links
.filter((link) => link.rel === 'items')
.map((link) => link.type);
return { formats, ...props } as OgcApiCollectionInfo;
const bulkDownloadLinks = links
.filter((link) => link.rel === 'enclosure')
.reduce((acc, link) => {
acc[link.type] = link.href;
return acc;
}, {});
return { formats, bulkDownloadLinks, ...props } as OgcApiCollectionInfo;
}

export function parseCollectionParameters(
Expand Down
3 changes: 2 additions & 1 deletion src/ogc-api/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export interface OgcApiCollectionInfo {
description: string;
id: string;
itemType: 'feature' | 'record';
formats: MimeType[];
formats: MimeType[]; // these formats are accessible through the /items API
bulkDownloadLinks: Record<string, MimeType>; // map between formats and bulk download links (no filtering, pagination etc.)
crs: CrsCode[];
storageCrs?: CrsCode;
itemCount: number;
Expand Down

0 comments on commit 1ac4b12

Please sign in to comment.