Skip to content

Commit

Permalink
Merge pull request #39 from camptocamp/ogc-api-better-itms-url-format
Browse files Browse the repository at this point in the history
OGC API: use actual mimetype to generate an items url
  • Loading branch information
jahow authored Apr 28, 2024
2 parents 0a15aeb + 3f8f064 commit 25992d5
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
"id": "aires-covoiturage",
"title": "aires-covoiturage",
"links": [
{
"href": "https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=html",
"rel": "items",
"type": "text/html",
"title": "aires-covoiturage"
},
{
"href": "https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=geojson",
"rel": "items",
Expand Down
12 changes: 6 additions & 6 deletions fixtures/ogc-api/sample-data/collections/airports.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
"title": "Access a web map with the data",
"href": "https://my.server.org/sample-data/collections/airports/styles/Road?f=html"
},
{
"rel": "items",
"type": "text/html",
"title": "Access the features in the collection 'Airports' as HTML",
"href": "https://my.server.org/sample-data/collections/airports/items?f=html"
},
{
"rel": "items",
"type": "application/vnd.ogc.fg+json",
Expand All @@ -78,12 +84,6 @@
"type": "application/vnd.ogc.fg+json;compatibility=geojson",
"title": "Access the features in the collection 'Airports' as JSON-FG (GeoJSON Compatibility Mode)",
"href": "https://my.server.org/sample-data/collections/airports/items?f=jsonfgc"
},
{
"rel": "items",
"type": "text/html",
"title": "Access the features in the collection 'Airports' as HTML",
"href": "https://my.server.org/sample-data/collections/airports/items?f=html"
}
],
"id": "airports",
Expand Down
61 changes: 54 additions & 7 deletions src/ogc-api/endpoint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,11 @@ describe('OgcApiEndpoint', () => {
'A centre point for all major airports including a name.',
id: 'airports',
itemFormats: [
'text/html',
'application/vnd.ogc.fg+json',
'application/geo+json',
'application/flatgeobuf',
'application/vnd.ogc.fg+json;compatibility=geojson',
'text/html',
],
bulkDownloadLinks: {},
extent: {
Expand Down Expand Up @@ -1549,26 +1549,61 @@ describe('OgcApiEndpoint', () => {
});
});
describe('#getCollectionItemsUrl', () => {
it('returns the correct URL for the collection items', () => {
expect(
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'warn');
});
it('returns the first available URL for the collection items if no mime-type specified', async () => {
await expect(
endpoint.getCollectionItemsUrl('airports')
).resolves.toEqual(
'https://my.server.org/sample-data/collections/airports/items?f=html'
);
});
it('selects the URL using JSON-FG if asJson is specified', async () => {
await expect(
endpoint.getCollectionItemsUrl('airports', {
asJson: true,
})
).resolves.toEqual(
'https://my.server.org/sample-data/collections/airports/items?f=jsonfg'
);
});
it('returns the correct URL for the collection items an a given mime-type', async () => {
await expect(
endpoint.getCollectionItemsUrl('airports', {
limit: 101,
query: 'name=Sumburgh Airport',
outputFormat: 'json',
outputFormat: 'application/geo+json',
})
).resolves.toEqual(
'https://my.server.org/sample-data/collections/airports/items?f=json&name=Sumburgh+Airport&limit=101'
);
});
it('outputs a warning if the required format is not a known mime-type for the collection', async () => {
await expect(
endpoint.getCollectionItemsUrl('airports', {
limit: 101,
query: 'name=Sumburgh Airport',
outputFormat: 'shapefile',
})
).resolves.toEqual(
'https://my.server.org/sample-data/collections/airports/items?f=shapefile&name=Sumburgh+Airport&limit=101'
);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(
'The following output format type was not found in the collection'
)
);
});
});
});
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 @@ -1735,7 +1770,7 @@ The document at http://local/nonexisting?f=json could not be fetched.`
endpoint.getCollectionInfo('aires-covoiturage')
).resolves.toStrictEqual({
crs: ['http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'EPSG:4326'],
itemFormats: ['application/geo+json'],
itemFormats: ['text/html', 'application/geo+json'],
bulkDownloadLinks: {
'application/geo+json':
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=geojson&limit=-1',
Expand All @@ -1756,6 +1791,18 @@ The document at http://local/nonexisting?f=json could not be fetched.`
});
});
});

describe('#getCollectionItemsUrl', () => {
it('selects the URL using GeoJson if asJson is specified', async () => {
await expect(
endpoint.getCollectionItemsUrl('aires-covoiturage', {
asJson: true,
})
).resolves.toEqual(
'https://my.server.org/sample-data-2/collections/aires-covoiturage/items?f=geojson'
);
});
});
});
});
});
39 changes: 32 additions & 7 deletions src/ogc-api/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import {
fetchDocument,
fetchLink,
fetchRoot,
getLinks,
getLinkUrl,
hasLinks,
} from './link-utils.js';
import { EndpointError } from '../shared/errors.js';
import { BoundingBox, CrsCode, MimeType } from '../shared/models.js';
import { isMimeTypeJson, isMimeTypeJsonFg } from '../shared/mime-type.js';

/**
* Represents an OGC API endpoint advertising various collections and services.
Expand Down Expand Up @@ -280,7 +282,8 @@ ${e.message}`);
* @param collectionId - The unique identifier for the collection.
* @param options - An object containing optional parameters:
* - query: Additional query parameters to be included in the URL.
* - outputFormat: The MIME type for the output format. Default is 'json'.
* - asJson: Will query items as GeoJson or JSON-FG if available; takes precedence on `outputFormat`.
* - outputFormat: The MIME type for the output format.
* - limit: The maximum number of features to include.
* - extent: Bounding box to limit the features.
* - offset: Pagination offset for the returned results.
Expand All @@ -292,6 +295,7 @@ ${e.message}`);
collectionId: string,
options: {
query?: string;
asJson?: boolean;
outputFormat?: MimeType;
limit?: number;
offset?: number;
Expand All @@ -303,12 +307,33 @@ ${e.message}`);
return this.getCollectionDocument(collectionId)
.then((collectionDoc) => {
const baseUrl = this.baseUrl || '';
const itemsLink = getLinkUrl(collectionDoc, 'items', baseUrl);
const url = new URL(itemsLink);

// Set the format to JSON if not specified
const format = options.outputFormat || 'json';
url.searchParams.set('f', format);
const itemLinks = getLinks(collectionDoc, 'items');
let linkWithFormat = itemLinks.find(
(link) => link.type === options?.outputFormat
);
let url: URL;
if (options.asJson) {
// try json-fg
linkWithFormat = itemLinks.find((link) =>
isMimeTypeJsonFg(link.type)
);
// try geojson
linkWithFormat =
linkWithFormat ??
itemLinks.find((link) => isMimeTypeJson(link.type));
}
if (options?.outputFormat && !linkWithFormat) {
// do not prevent using this output format, because it still might work! but give a warning at least
console.warn(
`[ogc-client] The following output format type was not found in the collection '${collectionId}': ${options.outputFormat}`
);
url = new URL(itemLinks[0].href, baseUrl);
url.searchParams.set('f', options.outputFormat);
} else if (linkWithFormat) {
url = new URL(linkWithFormat.href, baseUrl);
} else {
url = new URL(itemLinks[0].href, baseUrl);
}

if (options.query !== undefined)
url.search += (url.search ? '&' : '') + options.query;
Expand Down
30 changes: 18 additions & 12 deletions src/ogc-api/link-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OgcApiDocument } from './model.js';
import { OgcApiDocument, OgcApiDocumentLink } from './model.js';
import { EndpointError } from '../shared/errors.js';
import { getFetchOptions } from '../shared/http-utils.js';

Expand Down Expand Up @@ -75,21 +75,27 @@ export function fetchCollectionRoot(
});
}

export function getLinks(
doc: OgcApiDocument,
relType: string | string[]
): OgcApiDocumentLink[] {
return (
doc.links?.filter((link) =>
Array.isArray(relType)
? relType.indexOf(link.rel) > -1
: link.rel === relType
) || []
);
}

export function getLinkUrl(
doc: OgcApiDocument,
relType: string | string[],
baseUrl?: string
): string {
const links = doc.links?.filter((link) =>
Array.isArray(relType)
? relType.indexOf(link.rel) > -1
: link.rel === relType
);
if (!links?.length) return null;
return new URL(
links[0].href,
baseUrl || window.location.toString()
).toString();
): string | null {
const link = getLinks(doc, relType)[0];
if (!link) return null;
return new URL(link.href, baseUrl || window.location.toString()).toString();
}

export function fetchLink(
Expand Down
33 changes: 27 additions & 6 deletions src/ogc-api/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,39 @@ export interface CollectionParameter {
type: CollectionParameterType;
}

/**
* Contains all necessary information about a collection of items
* @property title
* @property description
* @property id
* @property itemType
* @property itemFormats These mime types are available through the `/items` endpoint;
* use the `getCollectionItemsUrl` function to generate a URL using one of those formats
* @property bulkDownloadLinks Map between formats and bulk download links (no filtering, pagination etc.)
* @property crs
* @property storageCrs
* @property itemCount
* @property keywords
* @property language Language is Iso 2-letter code (e.g. 'en')
* @property updated
* @property extent
* @property publisher
* @property license
* @property queryables
* @property sortables
*/
export interface OgcApiCollectionInfo {
title: string;
description: string;
id: string;
itemType: 'feature' | 'record';
itemFormats: MimeType[]; // these formats are accessible through the /items API
bulkDownloadLinks: Record<string, MimeType>; // map between formats and bulk download links (no filtering, pagination etc.)
itemFormats: MimeType[];
bulkDownloadLinks: Record<string, MimeType>;
crs: CrsCode[];
storageCrs?: CrsCode;
itemCount: number;
keywords?: string[];
language?: string; // ISO2
language?: string;
updated?: Date;
extent?: BoundingBox;
publisher?: {
Expand All @@ -56,7 +77,7 @@ export interface OgcApiCollectionInfo {
sortables: CollectionParameter[];
}

export interface OgcApiDocumentLinks {
export interface OgcApiDocumentLink {
rel: string;
type: string;
title: string;
Expand Down Expand Up @@ -92,7 +113,7 @@ interface OgcApiTime {
}
export interface OgcApiRecordContact {
name: string;
links: OgcApiDocumentLinks[];
links: OgcApiDocumentLink[];
contactInstructions: string;
roles: string[];
}
Expand All @@ -118,7 +139,7 @@ export type OgcApiRecord = {
time: OgcApiTime;
geometry: Geometry;
properties: OgcApiRecordProperties;
links: OgcApiDocumentLinks[];
links: OgcApiDocumentLink[];
conformsTo?: string[];
};

Expand Down
22 changes: 22 additions & 0 deletions src/shared/mime-type.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isMimeTypeJson, isMimeTypeJsonFg } from './mime-type.js';

describe('mime type utils', () => {
it('isMimeTypeGeoJson', () => {
expect(isMimeTypeJson('application/geo+json')).toBe(true);
expect(isMimeTypeJson('application/vnd.geo+json')).toBe(true);
expect(isMimeTypeJson('geo+json')).toBe(true);
expect(isMimeTypeJson('geojson')).toBe(true);
expect(isMimeTypeJson('application/json')).toBe(true);
expect(isMimeTypeJson('json')).toBe(true);
});
it('isMimeTypeJsonFg', () => {
expect(isMimeTypeJsonFg('application/vnd.ogc.fg+json')).toBe(true);
expect(isMimeTypeJsonFg('fg+json')).toBe(true);
expect(isMimeTypeJsonFg('jsonfg')).toBe(true);
expect(isMimeTypeJsonFg('json-fg')).toBe(true);
expect(isMimeTypeJsonFg('geo+json')).toBe(false);
expect(isMimeTypeJsonFg('geojson')).toBe(false);
expect(isMimeTypeJsonFg('application/json')).toBe(false);
expect(isMimeTypeJsonFg('json')).toBe(false);
});
});
7 changes: 7 additions & 0 deletions src/shared/mime-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function isMimeTypeJson(mimeType: string): boolean {
return mimeType.toLowerCase().indexOf('json') > -1;
}

export function isMimeTypeJsonFg(mimeType: string): boolean {
return /json.?fg|fg.?json/.test(mimeType);
}
5 changes: 2 additions & 3 deletions src/wfs/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
WfsFeatureTypeSummary,
WfsVersion,
} from './model.js';
import { isMimeTypeJson } from '../shared/mime-type.js';

/**
* Represents a WFS endpoint advertising several feature types
Expand Down Expand Up @@ -221,9 +222,7 @@ export default class WfsEndpoint {
`The following feature type was not found in the service: ${featureType}`
);
}
const candidates = featureTypeInfo.outputFormats.filter(
(f) => f.toLowerCase().indexOf('json') > -1
);
const candidates = featureTypeInfo.outputFormats.filter(isMimeTypeJson);
if (!candidates.length) return null;
return candidates[0];
}
Expand Down

0 comments on commit 25992d5

Please sign in to comment.