diff --git a/docs/guides/adapting-new-services.md b/docs/guides/adapting-new-services.md index 31726838e..0f4683884 100644 --- a/docs/guides/adapting-new-services.md +++ b/docs/guides/adapting-new-services.md @@ -179,6 +179,9 @@ The structure of an entry in the [services.yml](../../config/services.yml) file temporal: true # Can subset by a time range variable: true # Can subset by UMM-Var variable multiple_variable: true # Can subset multiple variables at once + averaging: + time: true # Can perform averaging over time + area: true # Can perform averaging over area output_formats: # A list of output mime types the service can produce - image/tiff - image/png diff --git a/services/harmony/app/frontends/ogc-coverages/get-coverage-rangeset.ts b/services/harmony/app/frontends/ogc-coverages/get-coverage-rangeset.ts index f8ebe6cbe..5b5ab8434 100644 --- a/services/harmony/app/frontends/ogc-coverages/get-coverage-rangeset.ts +++ b/services/harmony/app/frontends/ogc-coverages/get-coverage-rangeset.ts @@ -2,7 +2,7 @@ import { NextFunction, Response } from 'express'; import DataOperation from '../../models/data-operation'; import HarmonyRequest from '../../models/harmony-request'; import wrap from '../../util/array'; -import { handleCrs, handleExtend, handleFormat, handleGranuleIds, handleGranuleNames, handleHeight, handleScaleExtent, handleScaleSize, handleWidth } from '../../util/parameter-parsers'; +import { handleAveragingType, handleCrs, handleExtend, handleFormat, handleGranuleIds, handleGranuleNames, handleHeight, handleScaleExtent, handleScaleSize, handleWidth } from '../../util/parameter-parsers'; import { createDecrypter, createEncrypter } from '../../util/crypto'; import env from '../../util/env'; import { RequestValidationError } from '../../util/errors'; @@ -41,6 +41,7 @@ export default function getCoverageRangeset( handleScaleSize(operation, query); handleHeight(operation, query); handleWidth(operation, query); + handleAveragingType(operation, query); operation.interpolationMethod = query.interpolation; if (query.forceasync) { diff --git a/services/harmony/app/frontends/ogc-edr/get-data-common.ts b/services/harmony/app/frontends/ogc-edr/get-data-common.ts index 538683ecd..4eb3cca3a 100644 --- a/services/harmony/app/frontends/ogc-edr/get-data-common.ts +++ b/services/harmony/app/frontends/ogc-edr/get-data-common.ts @@ -1,7 +1,7 @@ import DataOperation from '../../models/data-operation'; import HarmonyRequest from '../../models/harmony-request'; import wrap from '../../util/array'; -import { handleCrs, handleExtend, handleFormat, handleGranuleIds, handleGranuleNames, handleScaleExtent, handleScaleSize } from '../../util/parameter-parsers'; +import { handleAveragingType, handleCrs, handleExtend, handleFormat, handleGranuleIds, handleGranuleNames, handleScaleExtent, handleScaleSize } from '../../util/parameter-parsers'; import { createDecrypter, createEncrypter } from '../../util/crypto'; import env from '../../util/env'; import { RequestValidationError } from '../../util/errors'; @@ -35,6 +35,7 @@ export function getDataCommon( handleCrs(operation, query.crs); handleScaleExtent(operation, query); handleScaleSize(operation, query); + handleAveragingType(operation, query); operation.interpolationMethod = query.interpolation; operation.outputWidth = query.width; diff --git a/services/harmony/app/markdown/apis.md b/services/harmony/app/markdown/apis.md index 08218c666..cd3b48763 100644 --- a/services/harmony/app/markdown/apis.md +++ b/services/harmony/app/markdown/apis.md @@ -71,6 +71,7 @@ As such it accepts parameters in the URL path as well as query parameters. | ignoreErrors | if "true", continue processing a request to completion even if some items fail. If "false" immediately fail the request. Defaults to true | | destinationUrl | destination url specified by the client; currently only s3 link urls are supported (e.g. s3://my-bucket-name/mypath) and will result in the job being run asynchronously | | variable | the variable(s) to be used for variable subsetting. Multiple variables can be specified as a comma-separated list. This parameter is only used if the url `variable` path element is "parameter_vars" | +| average | requests the data to be averaged over either time or area | --- **Table {{tableCounter}}** - Harmony OGC Coverages API query parameters @@ -131,6 +132,7 @@ Currently only the `/position`, `/cube`, `/trajectory` and `/area` routes are su | subset | get a subset of the coverage by slicing or trimming along one axis. Harmony supports arbitrary dimension names for subsetting on numeric ranges for that dimension. | | height | number of rows to return in the output coverage | | width | number of columns to return in the output coverage | +| average | requests the data to be averaged over either time or area | --- **Table {{tableCounter}}** - Harmony extended parameters for all OGC EDR API routes diff --git a/services/harmony/app/models/data-operation.ts b/services/harmony/app/models/data-operation.ts index db115f625..195a64160 100644 --- a/services/harmony/app/models/data-operation.ts +++ b/services/harmony/app/models/data-operation.ts @@ -310,6 +310,8 @@ export default class DataOperation { ignoreErrors?: boolean; + average: string; + destinationUrl: string; ummCollections: CmrUmmCollection[]; diff --git a/services/harmony/app/models/services/base-service.ts b/services/harmony/app/models/services/base-service.ts index 4b9945c16..d3c4b0fae 100644 --- a/services/harmony/app/models/services/base-service.ts +++ b/services/harmony/app/models/services/base-service.ts @@ -28,6 +28,10 @@ export interface ServiceCapabilities { variable?: boolean; multiple_variable?: true; }; + averaging?: { + time?: boolean; + area?: boolean; + }; output_formats?: string[]; reprojection?: boolean; extend?: boolean; diff --git a/services/harmony/app/models/services/index.ts b/services/harmony/app/models/services/index.ts index 106207f9f..562b9e4bd 100644 --- a/services/harmony/app/models/services/index.ts +++ b/services/harmony/app/models/services/index.ts @@ -448,6 +448,42 @@ function supportsDimensionSubsetting(configs: ServiceConfig[]): Service return configs.filter((config) => getIn(config, 'capabilities.subsetting.dimension', false)); } +/** + * Returns true if the operation requires time averaging + * @param operation - The operation to perform. + * @returns true if the provided operation requires time averaging and false otherwise + */ +function requiresTimeAveraging(operation: DataOperation): boolean { + return operation.average === 'time'; +} + +/** + * Returns any services that support time averaging from the list of configs + * @param configs - The potential matching service configurations + * @returns Any configurations that support time averaging + */ +function supportsTimeAveraging(configs: ServiceConfig[]): ServiceConfig[] { + return configs.filter((config) => getIn(config, 'capabilities.averaging.time', false)); +} + +/** + * Returns true if the operation requires area averaging + * @param operation - The operation to perform. + * @returns true if the provided operation requires area averaging and false otherwise + */ +function requiresAreaAveraging(operation: DataOperation): boolean { + return operation.average === 'area'; +} + +/** + * Returns any services that support area averaging from the list of configs + * @param configs - The potential matching service configurations + * @returns Any configurations that support area averaging + */ +function supportsAreaAveraging(configs: ServiceConfig[]): ServiceConfig[] { + return configs.filter((config) => getIn(config, 'capabilities.averaging.area', false)); +} + export class UnsupportedOperation extends HttpError { operation: DataOperation; @@ -755,6 +791,64 @@ function filterDimensionSubsettingMatches( return services; } +/** + * Returns any services that support time averaging from the list of configs + * if the operation requires time averaging. + * @param operation - The operation to perform. + * @param context - Additional context that's not part of the operation, but influences the + * choice regarding the service to use + * @param configs - All service configurations that have matched up to this call + * @param requestedOperations - Operations that have been considered in filtering out services up to + * this call + * @returns Any service configurations that could still support the request + */ +function filterTimeAveragingMatches( + operation: DataOperation, + context: RequestContext, + configs: ServiceConfig[], + requestedOperations: string[], +): ServiceConfig[] { + let services = configs; + if (requiresTimeAveraging(operation)) { + requestedOperations.push('time averaging'); + services = supportsTimeAveraging(configs); + } + + if (services.length === 0) { + throw new UnsupportedOperation(operation, requestedOperations); + } + return services; +} + +/** + * Returns any services that support area averaging from the list of configs + * if the operation requires area averaging. + * @param operation - The operation to perform. + * @param context - Additional context that's not part of the operation, but influences the + * choice regarding the service to use + * @param configs - All service configurations that have matched up to this call + * @param requestedOperations - Operations that have been considered in filtering out services up to + * this call + * @returns Any service configurations that could still support the request + */ +function filterAreaAveragingMatches( + operation: DataOperation, + context: RequestContext, + configs: ServiceConfig[], + requestedOperations: string[], +): ServiceConfig[] { + let services = configs; + if (requiresAreaAveraging(operation)) { + requestedOperations.push('area averaging'); + services = supportsAreaAveraging(configs); + } + + if (services.length === 0) { + throw new UnsupportedOperation(operation, requestedOperations); + } + return services; +} + type FilterFunction = ( // The operation to perform operation: DataOperation, @@ -776,11 +870,13 @@ const allFilterFns = [ filterConcatenationMatches, filterVariableSubsettingMatches, filterSpatialSubsettingMatches, - filterShapefileSubsettingMatches, filterTemporalSubsettingMatches, filterDimensionSubsettingMatches, filterReprojectionMatches, filterExtendMatches, + filterAreaAveragingMatches, + filterTimeAveragingMatches, + filterShapefileSubsettingMatches, // This filter must be last because it chooses a format based on the accepted MimeTypes and // the remaining services that could support the operation. If it ran earlier we could // potentially eliminate services that a different accepted MimeType would have allowed. We @@ -798,6 +894,8 @@ const requiredFilterFns = [ filterDimensionSubsettingMatches, filterReprojectionMatches, filterExtendMatches, + filterAreaAveragingMatches, + filterTimeAveragingMatches, // See caveat above in allFilterFns about why this filter must be applied last filterOutputFormatMatches, ]; diff --git a/services/harmony/app/schemas/ogc-api-coverages/1.0.0/ogc-api-coverages-v1.0.0.yml b/services/harmony/app/schemas/ogc-api-coverages/1.0.0/ogc-api-coverages-v1.0.0.yml index 38d330958..2795b2869 100644 --- a/services/harmony/app/schemas/ogc-api-coverages/1.0.0/ogc-api-coverages-v1.0.0.yml +++ b/services/harmony/app/schemas/ogc-api-coverages/1.0.0/ogc-api-coverages-v1.0.0.yml @@ -343,6 +343,7 @@ paths: - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" - $ref: "#/components/parameters/variable" + - $ref: "#/components/parameters/average" responses: "200": description: A coverage's range set. @@ -384,6 +385,7 @@ paths: - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" - $ref: "#/components/parameters/variable" + - $ref: "#/components/parameters/average" requestBody: content: multipart/form-data: @@ -984,3 +986,12 @@ components: items: type: string minLength: 1 + average: + name: average + in: query + description: | + requests the data to be averaged over time or area + required: false + schema: + type: string + diff --git a/services/harmony/app/schemas/ogc-api-edr/1.1.0/ogc-api-edr-v1.1.0.yml b/services/harmony/app/schemas/ogc-api-edr/1.1.0/ogc-api-edr-v1.1.0.yml index 60e94f282..c6cf0c89b 100644 --- a/services/harmony/app/schemas/ogc-api-edr/1.1.0/ogc-api-edr-v1.1.0.yml +++ b/services/harmony/app/schemas/ogc-api-edr/1.1.0/ogc-api-edr-v1.1.0.yml @@ -172,6 +172,7 @@ paths: - $ref: "#/components/parameters/granuleName" - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" + - $ref: "#/components/parameters/average" responses: "200": description: A collection's EDR. @@ -258,6 +259,8 @@ paths: type: string extend: type: string + average: + type: string responses: "200": description: A collection's EDR. @@ -304,6 +307,7 @@ paths: - $ref: "#/components/parameters/granuleName" - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" + - $ref: "#/components/parameters/average" responses: "200": description: A collection's EDR. @@ -396,6 +400,8 @@ paths: type: string extend: type: string + average: + type: string responses: "200": description: A collection's EDR. @@ -442,6 +448,7 @@ paths: - $ref: "#/components/parameters/granuleName" - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" + - $ref: "#/components/parameters/average" responses: "200": description: A collection's EDR. @@ -532,6 +539,8 @@ paths: type: string extend: type: string + average: + type: string responses: "200": description: A edr description. @@ -576,6 +585,7 @@ paths: - $ref: "#/components/parameters/granuleName" - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" + - $ref: "#/components/parameters/average" responses: "200": description: A collection's EDR. @@ -661,6 +671,8 @@ paths: type: string extend: type: string + average: + type: string responses: "200": description: A collection's EDR. @@ -705,6 +717,7 @@ paths: - $ref: "#/components/parameters/granuleName" - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" + - $ref: "#/components/parameters/average" responses: "200": description: A collection's EDR. @@ -791,6 +804,8 @@ paths: type: string extend: type: string + average: + type: string responses: "200": description: A collection's EDR. @@ -841,6 +856,7 @@ paths: - $ref: "#/components/parameters/granuleName" - $ref: "#/components/parameters/grid" - $ref: "#/components/parameters/extend" + - $ref: "#/components/parameters/average" responses: "200": description: A collection's EDR. @@ -943,6 +959,8 @@ paths: type: string extend: type: string + average: + type: string responses: "200": description: A collection's EDR. @@ -1644,3 +1662,11 @@ components: required: false schema: type: string + average: + name: average + in: query + description: | + requests the data to be averaged over time or area + required: false + schema: + type: string diff --git a/services/harmony/app/util/parameter-parsers.ts b/services/harmony/app/util/parameter-parsers.ts index 67b9fc09d..b6aa0fa75 100644 --- a/services/harmony/app/util/parameter-parsers.ts +++ b/services/harmony/app/util/parameter-parsers.ts @@ -201,3 +201,22 @@ export function handleWidth( } } } + +/** + * Handle the averaging parameter in a Harmony query, adding it to the DataOperation + * if necessary. + * + * @param operation - the DataOperation for the request + * @param query - the query for the request + */ +export function handleAveragingType( + operation: DataOperation, + query: Record): void { + if (query.average) { + const value = query.average.toLowerCase(); + if (value !== 'time' && value !== 'area') { + throw new RequestValidationError('query parameter "average" must be either "time" or "area"'); + } + operation.average = value; + } +} diff --git a/services/harmony/test/helpers/util.ts b/services/harmony/test/helpers/util.ts new file mode 100644 index 000000000..ebea35dab --- /dev/null +++ b/services/harmony/test/helpers/util.ts @@ -0,0 +1,13 @@ +/** + * Partially applies a function by pre-filling some arguments. + * + * @param fn - The original function to partially apply. + * @param presetArgs - Arguments to pre-fill when calling the function. + * + * @returns A new function that takes the remaining arguments and calls the original function + * with both the preset and remaining arguments. + * + */ +export function partialApply(fn: (...args: unknown[]) => void, ...presetArgs: unknown[]) { + return (...laterArgs: unknown[]): void => fn(...presetArgs, ...laterArgs); +} \ No newline at end of file diff --git a/services/harmony/test/models/services.ts b/services/harmony/test/models/services.ts index fed36d29f..da6de6f76 100644 --- a/services/harmony/test/models/services.ts +++ b/services/harmony/test/models/services.ts @@ -107,6 +107,28 @@ describe('services.chooseServiceConfig and services.buildService', function () { }, }, }, + { + name: 'time-averaging-service', + type: { name: 'turbo' }, + collections: [{ id: collectionId }], + capabilities: { + averaging: { + time: true, + }, + output_formats: ['application/x-netcdf4'], + }, + }, + { + name: 'area-averaging-service', + type: { name: 'turbo' }, + collections: [{ id: collectionId }], + capabilities: { + averaging: { + area: true, + }, + output_formats: ['application/x-netcdf4'], + }, + }, ]; }); @@ -177,6 +199,28 @@ describe('services.chooseServiceConfig and services.buildService', function () { }); }); + describe('and the request needs area averaging', function () { + beforeEach(function () { + this.operation.average = 'area'; + }); + + it('chooses the service that supports area averaging', function () { + const serviceConfig = chooseServiceConfig(this.operation, {}, this.config); + expect(serviceConfig.name).to.equal('area-averaging-service'); + }); + }); + + describe('and the request needs time averaging', function () { + beforeEach(function () { + this.operation.average = 'time'; + }); + + it('chooses the service that supports time averaging', function () { + const serviceConfig = chooseServiceConfig(this.operation, {}, this.config); + expect(serviceConfig.name).to.equal('time-averaging-service'); + }); + }); + describe('and the request needs both spatial subsetting and netcdf output, but no service supports that combination', function () { beforeEach(function () { this.operation.boundingRectangle = [0, 0, 10, 10]; diff --git a/services/harmony/test/parameters/averaging_type.ts b/services/harmony/test/parameters/averaging_type.ts new file mode 100644 index 000000000..6a2e7fe4a --- /dev/null +++ b/services/harmony/test/parameters/averaging_type.ts @@ -0,0 +1,134 @@ +// Note that there are currently no time or area averaging services in services.yml +// which is why several of the assertions are using `xit`. Once there are services to +// support it we should enable the assertions and uncomment out the code to follow +// the redirects. + +import { expect } from 'chai'; +import { hookRangesetRequest } from '../helpers/ogc-api-coverages'; +import hookServersStartStop from '../helpers/servers'; +import StubService from '../helpers/stub-service'; +import { hookEdrRequest } from '../helpers/ogc-api-edr'; +import { partialApply } from '../helpers/util'; +// import { hookRedirect } from '../helpers/hooks'; + +const collection = 'C1233800302-EEDTEST'; +const edrVersion = '1.1.0'; + +// We want to test the average parameter on each of the following APIs, so we'll +// run the same tests against each in a loop +const endpointFunctions = [{ + label: 'OGC Coverages', + endpointFn: partialApply(hookRangesetRequest, '1.0.0', collection, 'all'), + extraArgs: {}, +}, { + label: 'EDR area', + endpointFn: partialApply(hookEdrRequest, 'area', edrVersion, collection), + extraArgs: { coords: 'POLYGON ((-65.3 -13.2, -29.8 -50.9, 17.9 30.1, -65.3 -13.2))' }, +}, { + label: 'EDR position', + endpointFn: partialApply(hookEdrRequest, 'position', edrVersion, collection), + extraArgs: { coords: 'POINT (-40 10)' }, +}, { + label: 'EDR trajectory', + endpointFn: partialApply(hookEdrRequest, 'trajectory', edrVersion, collection), + extraArgs: { coords: 'LINESTRING (-40 10, 30 10)' }, +}, { + label: 'EDR cube', + endpointFn: partialApply(hookEdrRequest, 'cube', edrVersion, collection), + extraArgs: { bbox: '-20.1,0,20,10' }, +}]; + +for (const { label, endpointFn, extraArgs } of endpointFunctions) { + describe(`average for ${label} API`, function () { + hookServersStartStop(); + + describe('when making a request with average time', function () { + const averagingTimeQuery = { + average: 'time', + }; + + describe('for a collection that can support it', function () { + StubService.hook({ params: { redirect: 'http://example.com' } }); + endpointFn({ query: { ...averagingTimeQuery, ...extraArgs } }); + // hookRedirect('anonymous'); + + xit('returns a 200 status code for the request', async function () { + expect(this.res.status).to.equal(200); + }); + + xit('specifies to perform time averaging in the operation', async function () { + expect(this.service.operation.average).to.equal('time'); + }); + }); + + describe('for a collection that has no service that can support it', function () { + StubService.hook({ params: { redirect: 'http://example.com' } }); + endpointFn({ query: { ...averagingTimeQuery, ...extraArgs } }); + + it('returns a 422 status code for the request', async function () { + expect(this.res.status).to.equal(422); + }); + + it('returns a message indicating that no service supports time averaging', async function () { + const error = this.res.body; + expect(error.code).to.equal('harmony.UnsupportedOperation'); + expect(error.description).to.include('time averaging'); + }); + }); + }); + + describe('when making a request with average area', function () { + const averagingAreaQuery = { + average: 'area', + }; + + describe('for a collection that can support it', function () { + StubService.hook({ params: { redirect: 'http://example.com' } }); + endpointFn({ query: { ...averagingAreaQuery, ...extraArgs } }); + // hookRedirect('anonymous'); + + xit('returns a 200 status code for the request', async function () { + expect(this.res.status).to.equal(200); + }); + + xit('specifies to perform area averaging in the operation', async function () { + expect(this.service.operation.average).to.equal('area'); + }); + }); + + describe('for a collection that has no service that can support it', function () { + StubService.hook({ params: { redirect: 'http://example.com' } }); + endpointFn({ query: { ...averagingAreaQuery, ...extraArgs } }); + + it('returns a 422 status code for the request', async function () { + expect(this.res.status).to.equal(422); + }); + + it('returns a message indicating that no service supports area averaging', async function () { + const error = this.res.body; + expect(error.code).to.equal('harmony.UnsupportedOperation'); + expect(error.description).to.include('area averaging'); + }); + }); + }); + + describe('when making a request with an invalid average', function () { + const badAveragingQuery = { + average: 'no not that', + }; + endpointFn({ query: { ...badAveragingQuery, ...extraArgs } }); + + it('returns a 400 status code for the request', async function () { + expect(this.res.status).to.equal(400); + }); + + it('returns a message indicating that the average value is invalid', async function () { + const errorMessage = { + 'code': 'harmony.RequestValidationError', + 'description': 'Error: query parameter "average" must be either "time" or "area"', + }; + expect(this.res.body).to.eql(errorMessage); + }); + }); + }); +} \ No newline at end of file