Skip to content

Commit

Permalink
feat: support OGC API styles in collections
Browse files Browse the repository at this point in the history
contains smaller improvements:
- remove redundant listAllStyles
- merge  getStyleMetadata into getStyle and handle fallback internally
  • Loading branch information
LukasLohoff committed Jul 11, 2024
1 parent 86bb906 commit e032389
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 97 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"title" : "Tritanopia",
"links" : [ {
"rel" : "self",
"type" : "application/json",
"title" : "This document",
"href" : "https://my.server.org/sample-data/collections/airports/styles/Tritanopia/metadata?f=json"
}, {
"rel" : "alternate",
"type" : "text/html",
"title" : "This document as HTML",
"href" : "https://my.server.org/sample-data/collections/airports/styles/Tritanopia/metadata?f=html"
} ],
"id" : "Tritanopia",
"scope" : "style",
"stylesheets" : [ {
"title" : "QGIS",
"version" : "3.16",
"specification" : "https://docs.qgis.org/3.16/en/docs/user_manual/appendices/qgis_file_formats.html#qml-the-qgis-style-file-format",
"native" : true,
"link" : {
"rel" : "stylesheet",
"type" : "application/vnd.qgis.qml",
"title" : "Style in format 'QGIS'",
"href" : "https://my.server.org/sample-data/collections/airports/styles/Tritanopia?f=qml"
}
}, {
"title" : "SLD 1.0",
"version" : "1.0",
"specification" : "https://www.ogc.org/standards/sld",
"native" : true,
"link" : {
"rel" : "stylesheet",
"type" : "application/vnd.ogc.sld+xml;version=1.0",
"title" : "Style in format 'SLD 1.0'",
"href" : "https://my.server.org/sample-data/collections/airports/styles/Tritanopia?f=sld10"
}
} ]
}
135 changes: 117 additions & 18 deletions src/ogc-api/endpoint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1944,22 +1944,9 @@ The document at http://local/nonexisting?f=json could not be fetched.`
beforeEach(() => {
endpoint = new OgcApiEndpoint('http://local/sample-data/');
});
describe('#listAllStyles', () => {
it('returns a list of style ids', async () => {
await expect(endpoint.listAllStyles).resolves.toEqual([
'Deuteranopia',
'Light',
'Night',
'Outdoor',
'OutdoorHillshade',
'Road',
'Tritanopia',
]);
});
});
describe('#allStyles', () => {
it('returns a list of styles', async () => {
await expect(endpoint.allStyles).resolves.toEqual([
await expect(endpoint.allStyles()).resolves.toEqual([
{
title: 'Deuteranopia',
id: 'Deuteranopia',
Expand Down Expand Up @@ -2010,7 +1997,71 @@ The document at http://local/nonexisting?f=json could not be fetched.`
]);
});
});
describe('#getStyleMetadata', () => {
describe('#allStyles for a given collection', () => {
it('returns a list of styles', async () => {
await expect(endpoint.allStyles('airports')).resolves.toEqual([
{
title: 'Deuteranopia',
id: 'Deuteranopia',
formats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0',
],
},
{
title: 'Light',
id: 'Light',
formats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0',
'application/vnd.mapbox.style+json',
],
},
{
title: 'Night',
id: 'Night',
formats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0',
'application/vnd.mapbox.style+json',
],
},
{
title: 'Outdoor',
id: 'Outdoor',
formats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0',
'application/vnd.mapbox.style+json',
],
},
{
title: 'Road',
id: 'Road',
formats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0',
'application/vnd.mapbox.style+json',
], },
{
title: 'Tritanopia',
id: 'Tritanopia',
formats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0',
],
},
{
title: 'OS Open Zoomstack - Outdoor with Hillshade',
id: 'OutdoorHillshade',
formats: [
'application/vnd.mapbox.style+json',
],
},
]);
});
});
describe('#getStyle', () => {
it('returns style metadata', async () => {
await expect(endpoint.getStyle('Deuteranopia')).resolves.toEqual({
title: 'Deuteranopia',
Expand All @@ -2034,19 +2085,67 @@ The document at http://local/nonexisting?f=json could not be fetched.`
});
});
});
describe('#getStyle for a given collection', () => {
it('returns style metadata', async () => {
await expect(endpoint.getStyle('Tritanopia', 'airports')).resolves.toEqual({
title: 'Tritanopia',
id: 'Tritanopia',
scope: 'style',
stylesheetFormats: [
'application/vnd.qgis.qml',
'application/vnd.ogc.sld+xml;version=1.0'
],
stylesheets: [
{
title: 'QGIS',
version: '3.16',
specification: 'https://docs.qgis.org/3.16/en/docs/user_manual/appendices/qgis_file_formats.html#qml-the-qgis-style-file-format',
native: true,
link: {
rel: 'stylesheet',
type: 'application/vnd.qgis.qml',
title: "Style in format 'QGIS'",
href: 'https://my.server.org/sample-data/collections/airports/styles/Tritanopia?f=qml',
},
},
{
title: 'SLD 1.0',
version: '1.0',
specification: 'https://www.ogc.org/standards/sld',
native: true,
link: {
rel: 'stylesheet',
type: 'application/vnd.ogc.sld+xml;version=1.0',
title: "Style in format 'SLD 1.0'",
href: 'https://my.server.org/sample-data/collections/airports/styles/Tritanopia?f=sld10',
},
},
],
});
});
});
describe('#getStylesheetUrl', () => {
it('returns the correct stylesheet URL', async () => {
await expect(
endpoint.getStylesheetUrl('Deuteranopia', 'application/vnd.esri.lyr')
).resolves.toEqual('http://local/zoomstack/styles/Deuteranopia?f=lyr');
});
});
describe('#getStylesheetUrl with type html', () => {
describe('#getStylesheetUrl with type Mapbox', () => {
it('returns the correct stylesheet URL', async () => {
await expect(
endpoint.getStylesheetUrl('Road', 'application/vnd.mapbox.style+json')
).resolves.toEqual(
'http://local/zoomstack/styles/Road?f=mbs'
);
});
});
describe('#getStylesheetUrl for a given collection', () => {
it('returns the correct stylesheet URL', async () => {
await expect(
endpoint.getStylesheetUrl('Road', 'text/html')
endpoint.getStylesheetUrl('Tritanopia', 'application/vnd.ogc.sld+xml;version=1.0', 'airports')
).resolves.toEqual(
'https://my.server.org/sample-data/styles/Road?f=html'
'https://my.server.org/sample-data/collections/airports/styles/Tritanopia?f=sld10'
);
});
});
Expand Down
125 changes: 68 additions & 57 deletions src/ogc-api/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
parseConformance,
parseEndpointInfo,
parseBasicStyleInfo,
parseStylesAsList,
parseTileMatrixSets,
} from './info.js';
import {
Expand All @@ -21,7 +20,8 @@ import {
OgcApiEndpointInfo,
OgcApiStyleMetadata,
OgcApiStylesDocument,
StyleItem,
OgcStyleBrief,
OgcStyleFull,
TileMatrixSet,
} from './model.js';
import {
Expand Down Expand Up @@ -271,17 +271,32 @@ ${e.message}`);
}

private async getStyleMetadataDocument(
styleId: string
styleId: string,
collectionId?: string
): Promise<OgcApiDocument> {
const styleData = await this.styles;
const doc = collectionId ? await this.getCollectionDocument(collectionId) : await this.root;
const stylesLinkJson = getLinkUrl(
doc as OgcApiDocument,
['styles', 'http://www.opengis.net/def/rel/ogc/1.0/styles'],
this.baseUrl,
'application/json'
);
const stylesLink = getLinkUrl(
doc as OgcApiDocument,
['styles', 'http://www.opengis.net/def/rel/ogc/1.0/styles'],
this.baseUrl
);
const styleData = await fetchDocument(stylesLinkJson ?? stylesLink) as OgcApiStylesDocument;

if (!styleData.styles.some((style) => style.id === styleId)) {
throw new EndpointError(`Style not found: "${styleId}".`);
}
const styleDoc = styleData?.styles?.find((style) => style.id === styleId);
if (hasLinks(styleDoc as OgcApiDocument, ['describedby'])) {
return fetchLink(styleDoc as OgcApiDocument, 'describedby', this.baseUrl);
} else {
return null;
// fallback: return style document
return styleDoc as OgcApiDocument;
}
}

Expand Down Expand Up @@ -597,74 +612,70 @@ ${e.message}`);
});
}

/**
* A Promise which resolves to an array of all style identifiers as strings.
*/
get listAllStyles(): Promise<string[]> {
return this.styles.then(parseStylesAsList());
}

/**
* A Promise which resolves to an array of all style items. This includes the supported style formats.
* @param collectionId - Optional unique identifier for the collection.
*/
get allStyles(): Promise<StyleItem[]> {
return this.styles.then(async (stylesDoc) => {
const metadataPromises = stylesDoc.styles.map((style) =>
this.getStyleMetadataDocument(style.id)
async allStyles(collectionId?: string): Promise<OgcStyleBrief[]> {
const doc = collectionId ? await this.getCollectionDocument(collectionId) : await this.root;
const stylesLink = getLinkUrl(
doc as OgcApiDocument,
['styles', 'http://www.opengis.net/def/rel/ogc/1.0/styles'],
this.baseUrl
);
if (!stylesLink) {
throw new EndpointError(
'Could not get styles: there is no relation of type "styles"'
);
return Promise.all(metadataPromises).then((results) => {
return results.map((r) =>
parseBasicStyleInfo(r as OgcApiStyleMetadata)
);
});
});
}
const styleData = await fetchDocument(stylesLink) as OgcApiStylesDocument;
return styleData.styles.map(parseBasicStyleInfo);
}

/**
* Returns a promise resolving to a document describing the style.
* @param styleId The style identifier
* Returns a promise resolving to a document describing the style. Looks for a relation of type
* "describedby" to fetch metadata. If no relation is found, only basic info will be returned.
* @param styleId - The style identifier
* @param collectionId - Optional unique identifier for the collection.
*/
async getStyle(styleId: string): Promise<OgcApiStyleMetadata> {
const metadataDoc = await this.getStyleMetadataDocument(styleId);
if (!metadataDoc) {
throw new EndpointError(
`Could not get style metadata: there is no relation of type "describedby" for style "${styleId}".`
);
async getStyle(styleId: string, collectionId?: string): Promise<OgcStyleFull | OgcStyleBrief> {
const metadataDoc = await this.getStyleMetadataDocument(styleId, collectionId);
if (!metadataDoc?.stylesheets) {
return parseBasicStyleInfo(metadataDoc as OgcApiStyleMetadata);
}
return parseFullStyleInfo(metadataDoc as OgcApiStyleMetadata);
}

/**
* Returns a promise resolving to a stylesheet URL for a given style and type.
* @param styleId The style identifier
* @param mimeType Stylesheet MIME type
* @param styleId - The style identifier
* @param mimeType - Stylesheet MIME type
* @param collectionId - Optional unique identifier for the collection.
*/
async getStylesheetUrl(styleId: string, mimeType: string): Promise<string> {
const metadataDoc = await this.getStyleMetadataDocument(styleId);
const urlFromMetadata = (
metadataDoc as OgcApiStyleMetadata
)?.stylesheets?.find(
(s) => s.link.type === mimeType && s.link.rel === 'stylesheet'
)?.link?.href;

if (!urlFromMetadata) {
// fallback which retrieves the URL from the style document itself
const style = (await this.styles).styles?.find(
(style) => style.id === styleId
);
const urlFromStyle = getLinkUrl(
style as unknown as OgcApiDocument,
'stylesheet',
this.baseUrl,
mimeType
async getStylesheetUrl(styleId: string, mimeType: string, collectionId?: string): Promise<string> {
const stylesDoc = await this.getStyleMetadataDocument(styleId, collectionId);

if (stylesDoc.stylesheets) {
const urlFromMetadata = (
stylesDoc as OgcApiStyleMetadata
)?.stylesheets?.find(
(s) => s.link.type === mimeType && s.link.rel === 'stylesheet'
)?.link?.href;
return urlFromMetadata;
}

const urlFromStyle = getLinkUrl(
stylesDoc,
'stylesheet',
this.baseUrl,
mimeType
);

if (!urlFromStyle) {
throw new EndpointError(
'Could not find stylesheet URL for given style ID and type.'
);
if (!urlFromStyle) {
throw new EndpointError(
'Could not find stylesheet URL for given style ID and type.'
);
}
return urlFromStyle;
}
return urlFromMetadata;
return urlFromStyle;
}
}
Loading

0 comments on commit e032389

Please sign in to comment.