From 2db1bb4c3a33533585f89d0b0440c53877742440 Mon Sep 17 00:00:00 2001 From: Matthias Mohr Date: Thu, 17 Oct 2024 11:27:59 +0200 Subject: [PATCH] Implement MVP for OGC API - Coverages --- src/api/capabilities.js | 53 +++++--- src/api/collections.js | 114 +++++++++++++++-- src/api/coverages.js | 267 ++++++++++++++++++++++++++++++++++++++++ src/models/catalog.js | 48 +++++--- 4 files changed, 440 insertions(+), 42 deletions(-) create mode 100644 src/api/coverages.js diff --git a/src/api/capabilities.js b/src/api/capabilities.js index 900b584..61945ce 100644 --- a/src/api/capabilities.js +++ b/src/api/capabilities.js @@ -1,5 +1,6 @@ import API from '../utils/API.js'; import Utils from '../utils/utils.js'; +import Coverages from './coverages.js'; const packageInfo = Utils.require('../../package.json'); export default class CapabilitiesAPI { @@ -112,46 +113,62 @@ export default class CapabilitiesAPI { type: 'application/json', title: 'Supported API versions' }, + // STAC { rel: "data", href: API.getUrl("/collections"), type: "application/json", title: "Datasets" }, + // OGC API - Coverages + { + rel: "http://www.opengis.net/def/rel/ogc/1.0/data", + href: API.getUrl("/collections"), + type: "application/json", + title: "Datasets" + }, + // STAC and older OGC APIs { rel: "conformance", href: API.getUrl("/conformance"), type: "application/json", title: "OGC Conformance classes" + }, + // Some newer OGC APIs (including Coverages) + { + rel: "http://www.opengis.net/def/rel/ogc/1.0/conformance", + href: API.getUrl("/conformance"), + type: "application/json", + title: "OGC Conformance classes" } ] }); } async getConformance(req, res) { - res.json({ - "conformsTo": [ - "https://api.openeo.org/1.2.0", - "https://api.stacspec.org/v1.0.0/core", - "https://api.stacspec.org/v1.0.0/collections", - "https://api.stacspec.org/v1.0.0/ogcapi-features", - "https://api.stacspec.org/v1.0.0/ogcapi-features#sort", + let conformsTo = [ + "https://api.openeo.org/1.2.0", + "https://api.stacspec.org/v1.0.0/core", + "https://api.stacspec.org/v1.0.0/collections", + "https://api.stacspec.org/v1.0.0/ogcapi-features", + "https://api.stacspec.org/v1.0.0/ogcapi-features#sort", // Item Filter -// "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", +// "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", // Collection Search -// "https://api.stacspec.org/v1.0.0-rc.1/collection-search", -// "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", +// "https://api.stacspec.org/v1.0.0-rc.1/collection-search", +// "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", // Collection Filter -// "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter", +// "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter", // Collection Sorting -// "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", +// "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", // CQL2 (for Item and Collection Filter) -// "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", -// "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", - ] - }); +// "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", +// "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", + ]; + conformsTo = Coverages.addConformanceClasses(conformsTo); + res.json({ conformsTo }); } async getServices(req, res) { diff --git a/src/api/collections.js b/src/api/collections.js index 39e0c55..517ddd5 100644 --- a/src/api/collections.js +++ b/src/api/collections.js @@ -3,6 +3,7 @@ import Utils from '../utils/utils.js'; import Errors from '../utils/errors.js'; import GeeProcessing from '../processes/utils/processing.js'; import HttpUtils from '../utils/http.js'; +import Coverages from './coverages.js'; const sortPropertyMap = { 'properties.datetime': 'system:time_start', @@ -36,6 +37,8 @@ export default class Data { server.addEndpoint('get', ['/collections/{collection_id}', '/collections/*'], this.getCollectionById.bind(this)); server.addEndpoint('get', '/collections/{collection_id}/queryables', this.getCollectionQueryables.bind(this)); server.addEndpoint('get', '/collections/{collection_id}/items', this.getCollectionItems.bind(this)); + server.addEndpoint('get', '/collections/{collection_id}/schema', this.getCollectionSchema.bind(this)); + server.addEndpoint('get', '/collections/{collection_id}/coverage', this.getCoverage.bind(this)); server.addEndpoint('get', '/collections/{collection_id}/items/{item_id}', this.getCollectionItemById.bind(this)); if (this.context.stacAssetDownloadSize > 0) { server.addEndpoint('get', ['/assets/{asset_id}', '/assets/*'], this.getAssetById.bind(this)); @@ -106,6 +109,12 @@ export default class Data { else if (id.endsWith('/queryables')) { return await this.getCollectionQueryables(req, res); } + else if (id.endsWith('/schema')) { + return await this.getCollectionSchema(req, res); + } + else if (id.endsWith('/coverage')) { + return await this.getCoverage(req, res); + } else if (id.endsWith('/items')) { return await this.getCollectionItems(req, res); } @@ -121,28 +130,113 @@ export default class Data { res.json(collection); } - async getCollectionQueryables(req, res) { + getCollectionId(req, endpoint) { let id = req.params.collection_id; - // Get the ID if this was a redirect from the /collections/{collection_id} endpoint + // Get the ID if this was a redirect from another endpoint if (req.params['*'] && !id) { - id = req.params['*'].replace(/\/queryables$/, ''); + endpoint = '/' + endpoint; + id = req.params['*']; + if (id.endsWith(endpoint)) { + id = id.substring(0, req.params['*'].length - endpoint.length); + } } + return id; + } - const queryables = this.catalog.getSchema(id); + async getCollectionQueryables(req, res) { + const id = this.getCollectionId(req, 'queryables'); + const queryables = this.catalog.getQueryables(id); if (queryables === null) { throw new Errors.CollectionNotFound(); } - res.json(queryables); } - async getCollectionItems(req, res) { - let id = req.params.collection_id; - // Get the ID if this was a redirect from the /collections/{collection_id} endpoint - if (req.params['*'] && !id) { - id = req.params['*'].replace(/\/items$/, ''); + async getCollectionSchema(req, res) { + const id = this.getCollectionId(req, 'schema'); + const schema = this.catalog.getSchema(id); + if (schema === null) { + throw new Errors.CollectionNotFound(); + } + res.json(schema); + } + + async getCoverage(req, res) { + if (!req.user._id) { + throw new Errors.AuthenticationRequired(); + } + + const id = this.getCollectionId(req, 'coverage'); + const collection = this.catalog.getData(id); + if (collection === null) { + throw new Errors.CollectionNotFound(); } + const coverage = new Coverages(collection); + // Subsetting + try { + coverage.setDatetime(req.query.datetime); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'datetime', reason: e.message}); + } + try { + coverage.setBoundingBox(req.query.bbox, req.query['bbox-crs']); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'bbox', reason: e.message}); + } + try { + coverage.setSubset(req.query.subset, req.query['subset-crs']); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'subset', reason: e.message}); + } + // Field selection + try { + coverage.setFields(req.query.properties); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'properties', reason: e.message}); + } + // CRS + try { + coverage.setCrs(req.query.crs); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'crs', reason: e.message}); + } + + const pngMedia = ['image/png']; + const gtiffMedia = ['image/tiff', 'image/tiff; application=geotiff']; + if (req.query.f) { + if (pngMedia.includes(req.query.f)) { + coverage.setFileFormat('PNG'); + } + else if (gtiffMedia.includes(req.query.f)) { + coverage.setFileFormat('GTIFF'); + } + else { + throw new Errors.NotAcceptableError(); + } + } + else { + const isPNG = req.accepts(pngMedia); + const isGTIFF = req.accepts(gtiffMedia); + if (isGTIFF) { + coverage.setFileFormat('GTIFF'); + } + else if (isPNG) { + coverage.setFileFormat('PNG'); + } + else { + throw new Errors.NotAcceptableError(); + } + } + + const response = await coverage.execute(this.context, req); + + res.header('Content-Type', response?.headers?.['content-type'] || 'application/octet-stream'); + response.data.pipe(res); + } + + async getCollectionItems(req, res) { + const id = this.getCollectionId(req, 'items'); const collection = this.catalog.getData(id, true); if (collection === null) { throw new Errors.CollectionNotFound(); diff --git a/src/api/coverages.js b/src/api/coverages.js new file mode 100644 index 0000000..cc5a299 --- /dev/null +++ b/src/api/coverages.js @@ -0,0 +1,267 @@ +// import { DateTime } from 'luxon'; +import API from "../utils/API.js"; +import Utils from "../utils/utils.js"; +import runSync from './worker/sync.js'; + +export default class Coverages { + // OGC:CRS84 as WKT + static CRS84_WKT = `GEOGCS["WGS 84 (CRS84)",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["OGC","CRS84"]]`; + static DEFAULT_SCHEMA_NAME = "var"; + + static addConformanceClasses(list) { + return list.concat([ + "http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/geotiff", + "http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/png", + "http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/crs", + "http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/subsetting", + "http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/fieldselection", + ]); + } + + static updateCollectionLink(link, cid) { + switch(link.rel) { + case 'http://www.opengis.net/def/rel/ogc/1.0/schema': + link.href = API.getUrl(`/collections/${cid}/schema`); + break; + case 'http://www.opengis.net/def/rel/ogc/1.0/coverage': + link.href = API.getUrl(`/collections/${cid}/coverage`); + break; + } + return link; + } + + static fixCollectionOnce(collection) { + collection.extent.spatial.grid = [{},{}]; + collection.extent.spatial.storageCrsBbox = []; + collection.extent.temporal.grid = {}; + + if (Array.isArray(collection.summaries['proj:epsg']) && typeof collection.summaries['proj:epsg'][0] === 'number') { + collection.storageCrs = `http://www.opengis.net/def/crs/EPSG/0/${collection.summaries['proj:epsg'][0]}`; + collection.crs = [ + "EPSG:4326" + ]; + } + + collection.links.push({ + rel: "http://www.opengis.net/def/rel/ogc/1.0/schema", + href: `/collections/${collection.id}/schema`, + type: "application/schema+json" + }); + collection.links.push({ + rel: "http://www.opengis.net/def/rel/ogc/1.0/coverage", + href: `/collections/${collection.id}/coverage`, + type: "image/png" + }); + collection.links.push({ + rel: "http://www.opengis.net/def/rel/ogc/1.0/coverage", + href: `/collections/${collection.id}/coverage`, + type: "image/tiff; application=geotiff" + }); + + return collection; + } + + static getProperties(collection) { + return collection.summaries['eo:bands'] + || collection.summaries['sar:bands'] + || [{name: Coverages.DEFAULT_SCHEMA_NAME}]; + } + + static getSchema(collection) { + const bands = Coverages.getProperties(collection); + const properties = {}; + for(let i = 0; i < bands.length; i++) { + const band = Object.assign({}, bands[i]); + const id = band.name || String(i); + delete band.name; + band.type = "number"; + band.title = id; + band["x-ogc-propertySeq"] = i; + properties[id] = band; + } + const jsonSchema = { + $schema : "https://json-schema.org/draft/2020-12/schema", + $id : API.getUrl(`/collections/${collection.id}/schema`), + title : "Schema", + type : "object", + properties, + additionalProperties: false + }; + return jsonSchema; + } + + static updateCollection(collection) { + collection.links.push({ + rel: "http://www.opengis.net/def/rel/ogc/1.0/schema", + href: API.getUrl(`/collections/${collection.id}/schema`), + type: "application/schema+json" + }); + collection.links.push({ + rel: "http://www.opengis.net/def/rel/ogc/1.0/coverage", + href: API.getUrl(`/collections/${collection.id}/schema`), + type: "image/png" + }); + return collection; + } + + constructor(collection) { + this.collection = collection; + this.crs = "EPSG:4326"; + this.bbox = null; + this.datetime = []; + this.properties = []; + } + + setSubset(subsets/* = [], crs = null*/) { + if (subsets) { + throw new Error("Not supported yet, see https://github.com/opengeospatial/ogcapi-coverages/issues/194"); + } + } + + setBoundingBox(bbox = null, crs = null) { + if (!bbox) { + this.bbox = null; + return; + } + + if (typeof bbox === 'string') { + bbox = bbox.split(",").map(b => parseFloat(b.trim())); + } + if (!Array.isArray(bbox)) { + throw new Error("Invalid input"); + } + else if (bbox.length !== 4) { + throw new Error("Invalid number of coordinates"); + } + this.bbox = { + west: bbox[0], + south: bbox[1], + east: bbox[2], + north: bbox[3], + crs: Coverages.checkCrs(crs) + }; + } + + setDatetime(datetime = null) { + if (!datetime) { + this.datetime = null; + return; + } + + if (typeof datetime === 'string') { + if (datetime.includes("/")) { + datetime = datetime.split("/"); + } + else { + datetime = [datetime, datetime]; + } + } + if (!Array.isArray(datetime) || datetime.length !== 2) { + throw new Error("Invalid input, needs two datetimes or an open range"); + } + + datetime = datetime.map(dt => ((dt === '' || dt === '..') ? null : dt)); + + // if (datetime.find(dt => dt !== null && !DateTime.fromISO(dt, {zone: "utc"}).IsValid)) { + // throw new Error("Invalid datetime(s) specififed"); + // } + + if (datetime[0] === null && this.datetime[1] === null) { + this.datetime = null; + } + else { + this.datetime = datetime; + } + } + + setFields(properties = []) { + if (typeof properties === 'string') { + properties = properties.split(","); + } + if (!Array.isArray(properties)) { + throw new Error("Invalid input"); + } + + const bands = Coverages.getProperties(this.collection); + const bandNames = bands.map(b => b.name); + + properties = properties + .map(p => { + p = p.trim(); + if (p.match(/^[0-9]+$/)) { + p = parseInt(p, 10); + if (p < 0 || p >= bands.length) { + return null; + } + p = bandNames[p]; + } + else if (p !== Coverages.DEFAULT_SCHEMA_NAME && !bandNames.includes(p)) { + return null; + } + return p; + }) + .filter(p => (p !== Coverages.DEFAULT_SCHEMA_NAME)); + + if (properties.find(p => p === null)) { + throw new Error("Invalid band specified"); + } + if (properties.find(p => p === '*')) { + throw new Error("Wildcard * not supported, see https://github.com/opengeospatial/ogcapi-coverages/issues/188"); + } + + if (properties.length > 0) { + this.properties = properties; + } + else { + this.properties = null; + } + } + + setFileFormat(format = null) { + this.format = format || "PNG"; + } + + static checkCrs(crs) { + if (!crs) { + crs = "EPSG:4326"; + } + if (!crs.startsWith("EPSG:")) { + throw new Error("Only EPSG codes are supported"); + } + return parseInt(crs.replace("EPSG:", ""), 10); + } + + setCrs(crs) { + this.crs = Coverages.checkCrs(crs); + } + + async execute(context, req) { + const process_graph = { + loadcollection: { + process_id: "load_collection", + arguments: { + id: this.collection.id, + temporal_extent: this.datetime, + spatial_extent: this.bbox, + bands: this.properties + } + }, + saveresult: { + process_id: "save_result", + arguments: { + data: { from_node: "loadcollection" }, + format: this.format, + options: { + epsgCode: this.crs + } + }, + result: true + } + }; + + const id = Utils.timeId(); + return await runSync(context, req.user, id, {process_graph}, "error"); + } + +} diff --git a/src/models/catalog.js b/src/models/catalog.js index cae28e9..cddb745 100644 --- a/src/models/catalog.js +++ b/src/models/catalog.js @@ -4,6 +4,7 @@ import path from 'path'; import ItemStore from './itemstore.js'; import { Storage } from '@google-cloud/storage'; import API from '../utils/API.js'; +import Coverages from '../api/coverages.js'; const STAC_EXTENSIONS = { cube: "https://stac-extensions.github.io/datacube/v2.2.0/schema.json", @@ -181,7 +182,7 @@ export default class DataCatalog { return await this.readLocalCatalog(); } - getSchema(id) { + getQueryables(id) { const collection = this.getData(id, true); if (!collection) { return null; @@ -211,6 +212,15 @@ export default class DataCatalog { return jsonSchema; } + getSchema(id) { + const collection = this.getData(id, true); + if (!collection) { + return null; + } + + return Coverages.getSchema(collection); + } + getData(id = null, withSchema = false) { if (id !== null) { if (typeof this.collections[id] !== 'undefined') { @@ -254,22 +264,16 @@ export default class DataCatalog { case 'root': l.href = API.getUrl("/"); break; + case 'http://www.opengis.net/def/rel/ogc/1.0/queryables': + l.href = API.getUrl(`/collections/${c.id}/queryables`); + break; + case 'items': + l.href = API.getUrl(`/collections/${c.id}/items`); + break; } + l = Coverages.updateCollectionLink(l, c.id); return l; }); - c.links.push({ - rel: 'http://www.opengis.net/def/rel/ogc/1.0/queryables', - href: API.getUrl(`/collections/${c.id}/queryables`), - title: "Queryables", - type: "application/schema+json" - }); - if (c["gee:type"] === 'image_collection') { - c.links.push({ - rel: 'items', - href: API.getUrl(`/collections/${c.id}/items`), - type: "application/geo+json" - }); - } return c; } @@ -596,6 +600,22 @@ export default class DataCatalog { } }); + c.links.push({ + rel: 'http://www.opengis.net/def/rel/ogc/1.0/queryables', + href: `/collections/${c.id}/queryables`, + title: "Queryables", + type: "application/schema+json" + }); + if (c["gee:type"] === 'image_collection') { + c.links.push({ + rel: 'items', + href: `/collections/${c.id}/items`, + type: "application/geo+json" + }); + } + + c = Coverages.fixCollectionOnce(c); + return c; }