From 1734c1890e6f35370aac13db22e955936e482154 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 | 122 +++++++++++++++++++++++++--- src/api/coverages.js | 170 ++++++++++++++++++++++++++++++++++++++++ src/models/catalog.js | 48 ++++++++---- 4 files changed, 351 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..00109f8 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,121 @@ 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}); + } + // Scaling + try { + coverage.setScaleFactor(req.query['scale-factor']); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'scale-factor', reason: e.message}); + } + try { + coverage.setScaleAxes(req.query['scale-axes']); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'scale-axes', reason: e.message}); + } + try { + coverage.setScaleSize(req.query['scale-size']); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'scale-size', reason: e.message}); + } + try { + coverage.setDimensions(req.query.width, req.query.height); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'width/height', reason: e.message}); + } + // CRS + try { + coverage.setCrs(req.query.crs); + } catch (e) { + throw new Errors.ParameterValueInvalid({parameter: 'crs', reason: e.message}); + } + + const isPNG = req.accepts('image/png'); + const isGTIFF = req.headers.accept && req.accepts([ + 'image/tiff', + 'image/tiff; application=geotiff' + ]); + if (!isPNG && !isGTIFF) { + throw new Errors.NotAcceptableError(); + } + else if(isGTIFF) { + coverage.setFileFormat('GTIFF'); + } + else { + coverage.setFileFormat('PNG'); + } + + const response = await coverage.execute(); + + res.header('Content-Type', response?.headers?.['content-type'] || 'application/octet-stream'); + // res.header('Content-Crs', "EPSG:4326"); + // res.header('Content-Bbox', bbox.join(",")); + if (req.query.datetime) { + res.header('Content-Datetime', req.query.datetime); + } + 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..607c4bb --- /dev/null +++ b/src/api/coverages.js @@ -0,0 +1,170 @@ +import API from "../utils/API.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 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/scaling", + "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" + }); + + return collection; + } + + static getSchema(collection) { + const jsonSchema = { + "$schema" : "https://json-schema.org/draft/2019-09/schema", + "$id" : API.getUrl(`/collections/${collection.id}/schema`), + "title" : "Schema", + "type" : "object", + "properties" : { + "var": { + "title": "", + "type": "number", + "description": "", + "x-ogc-propertySeq": 0 + } + }, + "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() { + + } + + setSubset(subsets = [], crs = Coverages.CRS84_WKT) { + if (typeof subsets === 'string') { + subsets = [subsets]; + } + if (subsets.length > 0) { + throw new Error("Not supported"); + } + this.subsets = subsets; + this.subsetCrs = crs; + } + + setScaleAxes(axes = []) { + if (typeof axes === 'string') { + axes = [axes]; + } + if (axes.length > 0) { + throw new Error("Not supported"); + } + this.scaleAxes = axes; + } + + setScaleFactor(factor) { + this.scaleFactor = parseFloat(factor) || 1; + } + + setScaleSize(sizes) { + if (typeof sizes === 'string') { + sizes = [sizes]; + } + if (sizes.length > 0) { + throw new Error("Not supported"); + } + this.scaleSizes = sizes; + } + + setDimensions(w, h) { + this.width = parseInt(w, 10) || null; + this.height = parseInt(h, 10) || null; + } + + setBoundingBox(bbox = null, crs = Coverages.CRS84_WKT) { + if (typeof bbox === 'string') { + bbox = bbox.split(","); + } + this.bbox = bbox; + if (this.bbox.length !== 4) { + throw new Error("Invalid number of coordinates"); + } + this.bboxCrs = crs; + } + + setDatetime(datetime = null) { + if (typeof datetime === 'string') { + if (datetime.includes("/")) { + datetime = datetime.split("/"); + } + else { + datetime = [datetime]; + } + } + this.datetime = datetime; + } + + setFileFormat(format = "PNG") { + this.format = format; + } + + setCrs(crs) { + this.crs = crs; + } + + async execute() { + + } + +} 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; }