From 1d32092f19f40346e9ca485771b8f8594eefd7fe Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 10:37:32 -0700 Subject: [PATCH 01/25] Restructured API paths to conform with OGC spec --- apps/data-management/e2e/specs/sites.spec.ts | 18 +- .../e2e/specs/workspaces.spec.ts | 6 +- apps/data-management/playwright.config.ts | 2 +- apps/docs/docs/references/hydroserver-apis.md | 2 +- .../e2e/plot-no-intended-spacing.spec.ts | 6 +- apps/qc-app/e2e/support/mocks.ts | 30 +- .../{data.openapi.json => ogc.openapi.json} | 2302 ++++++++--------- .../commands/export_openapi_contracts.py | 2 +- django/interfaces/api/urls.py | 60 +- .../api/views/etl/data_connection.py | 10 +- django/interfaces/api/views/etl/mapping.py | 10 +- django/interfaces/api/views/etl/task.py | 16 +- django/interfaces/api/views/iam/role.py | 4 +- django/interfaces/api/views/iam/workspace.py | 20 +- .../interfaces/api/views/monitoring/rule.py | 10 +- .../interfaces/api/views/monitoring/task.py | 16 +- .../api/views/products/rating_curve.py | 10 +- django/interfaces/api/views/products/task.py | 16 +- .../api/views/products/transformation.py | 10 +- .../interfaces/api/views/quality/history.py | 8 +- .../api/views/sta/aggregation_statistic.py | 10 +- django/interfaces/api/views/sta/datastream.py | 20 +- .../api/views/sta/datastream_status.py | 10 +- .../api/views/sta/linked_resource_type.py | 10 +- django/interfaces/api/views/sta/method.py | 10 +- .../interfaces/api/views/sta/method_type.py | 10 +- .../api/views/sta/monitoring_site.py | 18 +- .../api/views/sta/monitoring_site_type.py | 10 +- .../interfaces/api/views/sta/observation.py | 8 +- .../api/views/sta/observed_property.py | 10 +- .../api/views/sta/observed_property_type.py | 10 +- .../api/views/sta/processing_level.py | 10 +- .../api/views/sta/result_qualifier.py | 10 +- .../api/views/sta/sampled_medium.py | 10 +- django/interfaces/api/views/sta/unit.py | 10 +- django/interfaces/api/views/sta/unit_type.py | 10 +- django/tests/hydroserver/test_oidc_auth.py | 2 +- .../api/test_aggregation_statistic_views.py | 2 +- .../interfaces/api/test_collaborator_views.py | 2 +- .../api/test_data_connection_views.py | 2 +- .../api/test_data_product_task_views.py | 2 +- .../test_data_product_transformation_views.py | 2 +- .../api/test_datastream_status_views.py | 2 +- .../interfaces/api/test_datastream_views.py | 7 +- .../interfaces/api/test_etl_mapping_views.py | 2 +- .../interfaces/api/test_etl_task_views.py | 2 +- .../api/test_linked_resource_type_views.py | 2 +- .../interfaces/api/test_method_type_views.py | 2 +- .../tests/interfaces/api/test_method_views.py | 2 +- .../api/test_monitoring_rule_views.py | 2 +- .../api/test_monitoring_site_type_views.py | 2 +- .../api/test_monitoring_site_views.py | 15 +- .../api/test_monitoring_task_views.py | 2 +- .../interfaces/api/test_observation_views.py | 16 +- .../api/test_observed_property_type_views.py | 2 +- .../api/test_observed_property_views.py | 2 +- .../api/test_processing_level_views.py | 2 +- .../interfaces/api/test_qc_history_views.py | 2 +- .../interfaces/api/test_qc_operation_views.py | 2 +- .../interfaces/api/test_qc_session_views.py | 2 +- .../interfaces/api/test_rating_curve_views.py | 2 +- .../api/test_result_qualifier_views.py | 2 +- .../tests/interfaces/api/test_role_views.py | 2 +- .../api/test_sampled_medium_views.py | 2 +- .../api/test_service_account_views.py | 2 +- .../interfaces/api/test_unit_type_views.py | 2 +- .../tests/interfaces/api/test_unit_views.py | 2 +- .../interfaces/api/test_workspace_metadata.py | 14 +- .../interfaces/api/test_workspace_views.py | 2 +- .../interfaces/web/test_site_type_icons.py | 2 +- packages/hydroserver-ts/package.json | 2 +- .../scripts/generate-contracts.data.ts | 101 +- .../scripts/generate-services.ts | 8 +- .../hydroserver-ts/scripts/openapi-paths.ts | 2 +- .../hydroserver-ts/src/api/HydroServer.ts | 2 +- .../src/api/__tests__/apiMethods.spec.ts | 10 +- .../__tests__/data-connection.service.spec.ts | 4 +- ...ata-product-transformation.service.spec.ts | 6 +- .../api/__tests__/etl-mapping.service.spec.ts | 6 +- .../__tests__/monitoring-rule.service.spec.ts | 6 +- ...monitoring-site-datastream.service.spec.ts | 8 +- .../__tests__/quality-control.service.spec.ts | 8 +- .../__tests__/rating-curve.service.spec.ts | 12 +- .../src/api/__tests__/task.service.spec.ts | 2 +- .../api/__tests__/workspace.service.spec.ts | 14 +- .../hydroserver-ts/src/api/services/base.ts | 7 +- .../data-product-transformation.types.ts | 2 +- .../src/api/services/datastream.service.ts | 18 +- .../src/api/services/etl-mapping.types.ts | 2 +- .../src/api/services/link-normalization.ts | 2 +- .../api/services/monitoring-site.service.ts | 12 +- .../api/services/quality-control.service.ts | 8 +- .../services/rating-curve-preview.service.ts | 2 +- .../src/api/services/user.service.ts | 2 +- .../src/api/services/workspace.service.ts | 6 +- .../aggregation-statistics.contract.ts | 6 +- .../contracts/data-connections.contract.ts | 6 +- .../contracts/data-product-tasks.contract.ts | 6 +- .../data-product-transformations.contract.ts | 6 +- .../contracts/datastream-statuses.contract.ts | 6 +- .../contracts/datastreams.contract.ts | 6 +- .../contracts/etl-mappings.contract.ts | 6 +- .../linked-resource-types.contract.ts | 6 +- .../contracts/method-types.contract.ts | 6 +- .../generated/contracts/methods.contract.ts | 6 +- .../contracts/monitoring-rules.contract.ts | 6 +- .../monitoring-site-types.contract.ts | 6 +- .../contracts/monitoring-sites.contract.ts | 6 +- .../contracts/monitoring-tasks.contract.ts | 6 +- .../contracts/observations.contract.ts | 6 +- .../contracts/observed-properties.contract.ts | 6 +- .../observed-property-types.contract.ts | 6 +- .../contracts/processing-levels.contract.ts | 6 +- .../quality-control-histories.contract.ts | 6 +- .../quality-control-operations.contract.ts | 6 +- .../quality-control-sessions.contract.ts | 6 +- .../contracts/rating-curves.contract.ts | 6 +- .../contracts/result-qualifiers.contract.ts | 6 +- .../src/generated/contracts/runs.contract.ts | 4 +- .../contracts/sampled-mediums.contract.ts | 6 +- .../src/generated/contracts/tasks.contract.ts | 6 +- .../contracts/unit-types.contract.ts | 6 +- .../src/generated/contracts/units.contract.ts | 6 +- .../contracts/workspaces.contract.ts | 6 +- .../{data.types.d.ts => ogc.types.d.ts} | 1124 ++++---- .../src/hydroserverpy/api/client.py | 2 +- .../api/models/etl/data_connection.py | 2 +- .../hydroserverpy/api/models/etl/mapping.py | 2 +- .../src/hydroserverpy/api/models/etl/task.py | 2 +- .../src/hydroserverpy/api/models/iam/role.py | 2 +- .../hydroserverpy/api/models/iam/workspace.py | 2 +- .../api/models/monitoring/rule.py | 2 +- .../api/models/monitoring/task.py | 2 +- .../api/models/products/rating_curve.py | 2 +- .../hydroserverpy/api/models/products/task.py | 2 +- .../api/models/sta/datastream.py | 4 +- .../hydroserverpy/api/models/sta/method.py | 2 +- .../api/models/sta/monitoring_site.py | 2 +- .../api/models/sta/observed_property.py | 2 +- .../api/models/sta/processing_level.py | 2 +- .../api/models/sta/result_qualifier.py | 2 +- .../src/hydroserverpy/api/models/sta/unit.py | 2 +- .../api/services/products/transformation.py | 2 +- .../api/services/sta/datastream.py | 6 +- .../tests/api/test_base_service.py | 2 +- .../tests/api/test_collection.py | 2 +- .../tests/api/test_etl_mapping.py | 8 +- .../hydroserverpy/tests/api/test_metadata.py | 8 +- .../tests/api/test_monitoring_rule.py | 8 +- .../test_service_account_and_collaborator.py | 2 +- .../tests/api/test_transformation.py | 10 +- .../hydroserverpy/tests/test_api_client.py | 2 +- 152 files changed, 2235 insertions(+), 2245 deletions(-) rename django/contracts/openapi/{data.openapi.json => ogc.openapi.json} (99%) rename packages/hydroserver-ts/src/generated/{data.types.d.ts => ogc.types.d.ts} (98%) diff --git a/apps/data-management/e2e/specs/sites.spec.ts b/apps/data-management/e2e/specs/sites.spec.ts index d495ad9c..372ab195 100644 --- a/apps/data-management/e2e/specs/sites.spec.ts +++ b/apps/data-management/e2e/specs/sites.spec.ts @@ -309,7 +309,7 @@ test.describe('sites and workspaces', () => { test('Browse metadata controls stay on one line with multiple values', async ({ page, }) => { - await page.route('**/api/data/monitoring-sites/site-summaries*', async (route) => { + await page.route('**/api/ogc/collections/monitoring-sites/site-summaries*', async (route) => { const response = await route.fetch() const summaries = (await response.json()) as Array<{ tags: Record @@ -629,7 +629,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/monitoring-sites/${fixtures.monitoringSites.public.id}` + `${apiBaseUrl}/api/ogc/collections/monitoring-sites/items/${fixtures.monitoringSites.public.id}` ) const monitoringSite = await response.json() return monitoringSite.data.isPrivate @@ -638,7 +638,7 @@ test.describe('sites and workspaces', () => { await page.getByRole('button', { name: 'Close' }).click() const blockedMonitoringSiteRequest = anonymousPage.waitForResponse((response) => - response.url().includes(`/api/data/monitoring-sites/${fixtures.monitoringSites.public.id}`) + response.url().includes(`/api/ogc/collections/monitoring-sites/items/${fixtures.monitoringSites.public.id}`) ) await anonymousPage.goto(`/sites/${fixtures.monitoringSites.public.id}`) const blockedMonitoringSiteResponse = await blockedMonitoringSiteRequest @@ -650,7 +650,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/monitoring-sites/${fixtures.monitoringSites.public.id}` + `${apiBaseUrl}/api/ogc/collections/monitoring-sites/items/${fixtures.monitoringSites.public.id}` ) const monitoringSite = await response.json() return monitoringSite.data.isPrivate @@ -665,7 +665,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/datastreams/${fixtures.datastreams.public.id}` + `${apiBaseUrl}/api/ogc/collections/datastreams/items/${fixtures.datastreams.public.id}` ) const datastream = await response.json() return datastream.data.isPrivate @@ -679,7 +679,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/datastreams/${fixtures.datastreams.public.id}` + `${apiBaseUrl}/api/ogc/collections/datastreams/items/${fixtures.datastreams.public.id}` ) const datastream = await response.json() return datastream.data.isPrivate @@ -688,7 +688,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/datastreams/${fixtures.datastreams.public.id}` + `${apiBaseUrl}/api/ogc/collections/datastreams/items/${fixtures.datastreams.public.id}` ) const datastream = await response.json() return datastream.data.isVisible @@ -699,7 +699,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/datastreams/${fixtures.datastreams.public.id}` + `${apiBaseUrl}/api/ogc/collections/datastreams/items/${fixtures.datastreams.public.id}` ) const datastream = await response.json() return datastream.data.isVisible @@ -715,7 +715,7 @@ test.describe('sites and workspaces', () => { await expect .poll(async () => { const response = await page.request.get( - `${apiBaseUrl}/api/data/datastreams/${fixtures.datastreams.public.id}` + `${apiBaseUrl}/api/ogc/collections/datastreams/items/${fixtures.datastreams.public.id}` ) const datastream = await response.json() return datastream.data.isVisible diff --git a/apps/data-management/e2e/specs/workspaces.spec.ts b/apps/data-management/e2e/specs/workspaces.spec.ts index 1435face..726821ff 100644 --- a/apps/data-management/e2e/specs/workspaces.spec.ts +++ b/apps/data-management/e2e/specs/workspaces.spec.ts @@ -396,7 +396,7 @@ test.describe('workspace management', () => { page, }) => { let failWorkspaceRequests = true - await page.route('**/api/data/workspaces?**', async (route) => { + await page.route('**/api/ogc/collections/workspaces/items?**', async (route) => { if (failWorkspaceRequests) { await route.fulfill({ status: 503, @@ -427,7 +427,7 @@ test.describe('workspace management', () => { page, }) => { await page.route( - `**/api/data/workspaces/${fixtures.workspaces.private.id}/service-accounts?**`, + `**/api/ogc/collections/workspaces/items/${fixtures.workspaces.private.id}/service-accounts?**`, (route) => route.fulfill({ status: 503, @@ -452,7 +452,7 @@ test.describe('workspace management', () => { test('a slow overview response cannot overwrite the newly selected workspace', async ({ page, }) => { - await page.route('**/api/data/monitoring-sites/site-summaries?**', async (route) => { + await page.route('**/api/ogc/collections/monitoring-sites/site-summaries?**', async (route) => { const workspaceId = new URL(route.request().url()).searchParams.get( 'workspace_id' ) diff --git a/apps/data-management/playwright.config.ts b/apps/data-management/playwright.config.ts index 15580b1d..29aaf09e 100644 --- a/apps/data-management/playwright.config.ts +++ b/apps/data-management/playwright.config.ts @@ -108,7 +108,7 @@ export default defineConfig({ E2E_TESTING: 'True', E2E_CONTROL_TOKEN: e2eControlToken, }, - url: `${apiBaseUrl}/api/data/workspaces`, + url: `${apiBaseUrl}/api/ogc/collections/workspaces/items`, // Isolated per-test scenarios make reusing a developer's local API safe // and remove repeated migration/startup cost. CI always starts fresh. reuseExistingServer: !process.env.CI, diff --git a/apps/docs/docs/references/hydroserver-apis.md b/apps/docs/docs/references/hydroserver-apis.md index b7c9271c..f451be91 100644 --- a/apps/docs/docs/references/hydroserver-apis.md +++ b/apps/docs/docs/references/hydroserver-apis.md @@ -14,7 +14,7 @@ HydroServer implements the [OGC SensorThings API](https://www.ogc.org/standard/s The interactive documentation for this API is available at: -https://playground.hydroserver.org/api/data/docs +https://playground.hydroserver.org/api/ogc/docs ### About diff --git a/apps/qc-app/e2e/plot-no-intended-spacing.spec.ts b/apps/qc-app/e2e/plot-no-intended-spacing.spec.ts index fb62e8d0..59dd86d6 100644 --- a/apps/qc-app/e2e/plot-no-intended-spacing.spec.ts +++ b/apps/qc-app/e2e/plot-no-intended-spacing.spec.ts @@ -89,7 +89,7 @@ async function patchDatastreamFixture(route: Route): Promise { processingLevels, units, } - if (/\/api\/data\/datastreams$/.test(path)) { + if (/\/api\/ogc\/collections\/datastreams\/items$/.test(path)) { const patched = datastreams.map(withoutIntendedSpacing) return route.fulfill({ status: 200, @@ -102,7 +102,7 @@ async function patchDatastreamFixture(route: Route): Promise { }), }) } - const single = path.match(/\/api\/data\/datastreams\/([^/]+)$/) + const single = path.match(/\/api\/ogc\/collections\/datastreams\/items\/([^/]+)$/) if (single) { const id = single[1] const ds = datastreams.find((d) => d.id === id) ?? datastreams[0]! @@ -124,7 +124,7 @@ test.describe('plot: datastream without intendedTimeSpacing', () => { // wins for both the list and single-get endpoints. The sub-path // `/observations` is left to fall through. await page.route( - /\/api\/data\/datastreams(\/[^/]+)?(\?.*)?$/, + /\/api\/ogc\/collections\/datastreams\/items(\/[^/]+)?(\?.*)?$/, patchDatastreamFixture ) }) diff --git a/apps/qc-app/e2e/support/mocks.ts b/apps/qc-app/e2e/support/mocks.ts index 30d02f09..4911a2f8 100644 --- a/apps/qc-app/e2e/support/mocks.ts +++ b/apps/qc-app/e2e/support/mocks.ts @@ -1,7 +1,7 @@ /** * Route-level HydroServer mocks for Playwright. * - * The real QC app talks to a HydroServer instance (`/api/data/*` and + * The real QC app talks to a HydroServer instance (`/api/ogc/*` and * `/api/auth/*`). For e2e specs we don't want to depend on a live * backend: tests are slow, flaky, and can't set up the exact dataset * shape each spec needs. This module registers `page.route()` handlers @@ -12,7 +12,7 @@ * await installMocks(page) * await page.goto('/') * - * The handlers match on path fragments (`/api/data/datastreams/...`) + * The handlers match on path fragments (`/api/ogc/collections/datastreams/items/...`) * regardless of host. */ @@ -178,9 +178,9 @@ export async function installMocks( } // --- Bulk observation create (submit) --- - // Observations moved to a top-level resource: POST /observations/bulk-create + // Observations moved to a top-level resource: POST /collections/observations/bulk-create // with datastreamId in the body, not the path. - const bulkCreate = path === '/api/data/observations/bulk-create' + const bulkCreate = path === '/api/ogc/collections/observations/bulk-create' if (bulkCreate && method === 'POST') { const params = new URL(url).searchParams const body = await safeJson(request) @@ -191,7 +191,7 @@ export async function installMocks( // --- Observations list (columnar) --- // Same restructuring: GET /observations?datastream_id={id}, not nested // under /datastreams/{id}/observations. - const obsList = path === '/api/data/observations' + const obsList = path === '/api/ogc/collections/observations/items' if (obsList && method === 'GET') { const params = new URL(url).searchParams const dsId = params.get('datastream_id') ?? '' @@ -216,26 +216,26 @@ export async function installMocks( } // --- Units --- - const unitGet = path.match(/\/api\/data\/units\/([^/]+)$/) + const unitGet = path.match(/\/api\/ogc\/collections\/units\/items\/([^/]+)$/) if (unitGet && method === 'GET') { const id = unitGet[1] const unit = units.find((u) => u.id === id) ?? units[0] return json(route, unit) } - if (path.endsWith('/api/data/units') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/units/items') && method === 'GET') { return json(route, { data: units, meta: listMeta(units.length) }) } // --- Workspaces --- - if (path.endsWith('/api/data/workspaces') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/workspaces/items') && method === 'GET') { return json(route, { data: workspaces, meta: listMeta(workspaces.length) }) } // --- Monitoring sites / datastreams / processing levels / observed properties --- - if (path.endsWith('/api/data/monitoring-sites') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/monitoring-sites/items') && method === 'GET') { return json(route, { data: monitoringSites, meta: listMeta(monitoringSites.length) }) } - if (path.endsWith('/api/data/datastreams') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/datastreams/items') && method === 'GET') { return json(route, { data: datastreams, meta: listMeta(datastreams.length), @@ -249,21 +249,21 @@ export async function installMocks( }, }) } - if (path.endsWith('/api/data/processing-levels') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/processing-levels/items') && method === 'GET') { return json(route, { data: processingLevels, meta: listMeta(processingLevels.length) }) } - if (path.endsWith('/api/data/observed-properties') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/observed-properties/items') && method === 'GET') { return json(route, { data: observedProperties, meta: listMeta(observedProperties.length) }) } - if (path.endsWith('/api/data/methods') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/methods/items') && method === 'GET') { return json(route, { data: methods, meta: listMeta(methods.length) }) } - if (path.endsWith('/api/data/result-qualifiers') && method === 'GET') { + if (path.endsWith('/api/ogc/collections/result-qualifiers/items') && method === 'GET') { return json(route, { data: resultQualifiers, meta: listMeta(resultQualifiers.length) }) } // --- Single datastream --- - const dsGet = path.match(/\/api\/data\/datastreams\/([^/]+)$/) + const dsGet = path.match(/\/api\/ogc\/collections\/datastreams\/items\/([^/]+)$/) if (dsGet && method === 'GET') { const id = dsGet[1] const ds = datastreams.find((d) => d.id === id) ?? datastreams[0] diff --git a/django/contracts/openapi/data.openapi.json b/django/contracts/openapi/ogc.openapi.json similarity index 99% rename from django/contracts/openapi/data.openapi.json rename to django/contracts/openapi/ogc.openapi.json index 385132e7..8ae2f407 100644 --- a/django/contracts/openapi/data.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -14282,7 +14282,7 @@ }, "openapi": "3.1.0", "paths": { - "/api/data/aggregation-statistics": { + "/api/ogc/collections/aggregation-statistics/items": { "get": { "description": "Get Aggregation Statistics.", "operationId": "interfaces_api_views_sta_aggregation_statistic_get_aggregation_statistics", @@ -14533,7 +14533,7 @@ ] } }, - "/api/data/aggregation-statistics/{aggregation_statistic_id}": { + "/api/ogc/collections/aggregation-statistics/items/{aggregation_statistic_id}": { "delete": { "description": "Delete an Aggregation Statistic.", "operationId": "interfaces_api_views_sta_aggregation_statistic_delete_aggregation_statistic", @@ -14767,7 +14767,7 @@ ] } }, - "/api/data/data-product-rating-curves": { + "/api/ogc/collections/data-product-rating-curves/items": { "get": { "description": "Get rating curves accessible to the authenticated user.", "operationId": "interfaces_api_views_products_rating_curve_get_rating_curves", @@ -15077,7 +15077,7 @@ ] } }, - "/api/data/data-product-rating-curves/{rating_curve_id}": { + "/api/ogc/collections/data-product-rating-curves/items/{rating_curve_id}": { "delete": { "description": "Delete a rating curve.", "operationId": "interfaces_api_views_products_rating_curve_delete_rating_curve", @@ -15366,7 +15366,7 @@ ] } }, - "/api/data/data-product-tasks": { + "/api/ogc/collections/data-product-tasks/items": { "get": { "description": "Get data product tasks accessible to the authenticated user.", "operationId": "interfaces_api_views_products_task_get_data_product_tasks", @@ -15768,7 +15768,7 @@ ] } }, - "/api/data/data-product-tasks/{task_id}": { + "/api/ogc/collections/data-product-tasks/items/{task_id}": { "delete": { "description": "Delete a data product task.", "operationId": "interfaces_api_views_products_task_delete_data_product_task", @@ -16059,7 +16059,7 @@ ] } }, - "/api/data/data-product-tasks/{task_id}/runs": { + "/api/ogc/collections/data-product-tasks/items/{task_id}/runs": { "get": { "description": "Get runs for a data product task.", "operationId": "interfaces_api_views_products_task_get_data_product_task_runs", @@ -16357,7 +16357,7 @@ ] } }, - "/api/data/data-product-tasks/{task_id}/runs/{run_id}": { + "/api/ogc/collections/data-product-tasks/items/{task_id}/runs/{run_id}": { "get": { "description": "Get a single run for a data product task.", "operationId": "interfaces_api_views_products_task_get_data_product_task_run", @@ -16448,7 +16448,7 @@ ] } }, - "/api/data/data-product-tasks/{task_id}/trigger": { + "/api/ogc/collections/data-product-tasks/items/{task_id}/trigger": { "post": { "description": "Trigger an immediate run of a data product task on a Celery worker.", "operationId": "interfaces_api_views_products_task_trigger_data_product_task", @@ -16529,7 +16529,7 @@ ] } }, - "/api/data/data-product-transformations": { + "/api/ogc/collections/data-product-transformations/items": { "get": { "description": "Get data product transformations.", "operationId": "interfaces_api_views_products_transformation_get_data_product_transformations", @@ -16891,7 +16891,7 @@ ] } }, - "/api/data/data-product-transformations/{transformation_id}": { + "/api/ogc/collections/data-product-transformations/items/{transformation_id}": { "delete": { "description": "Delete a data product transformation.", "operationId": "interfaces_api_views_products_transformation_delete_data_product_transformation", @@ -17189,7 +17189,7 @@ ] } }, - "/api/data/datastream-statuses": { + "/api/ogc/collections/datastream-statuses/items": { "get": { "description": "Get Datastream Statuses.", "operationId": "interfaces_api_views_sta_datastream_status_get_datastream_statuses", @@ -17440,7 +17440,7 @@ ] } }, - "/api/data/datastream-statuses/{datastream_status_id}": { + "/api/ogc/collections/datastream-statuses/items/{datastream_status_id}": { "delete": { "description": "Delete a Datastream Status.", "operationId": "interfaces_api_views_sta_datastream_status_delete_datastream_status", @@ -17674,7 +17674,7 @@ ] } }, - "/api/data/datastreams": { + "/api/ogc/collections/datastreams/items": { "get": { "description": "Get public Datastreams and Datastreams associated with the authenticated user.", "operationId": "interfaces_api_views_sta_datastream_get_datastreams", @@ -18400,162 +18400,7 @@ ] } }, - "/api/data/datastreams/tags/keys": { - "get": { - "description": "Get all existing unique datastream tag keys.", - "operationId": "interfaces_api_views_sta_datastream_get_datastream_tag_keys", - "parameters": [ - { - "in": "query", - "name": "workspace_id", - "required": false, - "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Workspace Id" - } - }, - { - "in": "query", - "name": "datastream_id", - "required": false, - "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Datastream Id" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "title": "Response", - "type": "object" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Get Datastream Tag Keys", - "tags": [ - "Datastreams" - ] - } - }, - "/api/data/datastreams/visualization-bootstrap": { - "get": { - "description": "Get the lean metadata required to bootstrap the visualization page.", - "operationId": "interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap", - "parameters": [ - { - "description": "Filter visualization bootstrap datastreams by workspace ID.", - "in": "query", - "name": "workspace_id", - "required": false, - "schema": { - "default": [], - "description": "Filter visualization bootstrap datastreams by workspace ID.", - "items": { - "format": "uuid", - "type": "string" - }, - "title": "Workspace Id", - "type": "array" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DatastreamVisualizationBootstrapResponse" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Get Datastream Visualization Bootstrap", - "tags": [ - "Datastreams" - ] - } - }, - "/api/data/datastreams/{datastream_id}": { + "/api/ogc/collections/datastreams/items/{datastream_id}": { "delete": { "description": "Delete a Datastream.", "operationId": "interfaces_api_views_sta_datastream_delete_datastream", @@ -18841,7 +18686,7 @@ ] } }, - "/api/data/datastreams/{datastream_id}/csv": { + "/api/ogc/collections/datastreams/items/{datastream_id}/csv": { "get": { "description": "Get a CSV representation of the Datastream.", "operationId": "interfaces_api_views_sta_datastream_get_datastream_csv", @@ -18904,7 +18749,7 @@ ] } }, - "/api/data/datastreams/{datastream_id}/linked-resources": { + "/api/ogc/collections/datastreams/items/{datastream_id}/linked-resources": { "get": { "description": "Get all linked resources associated with a Datastream.", "operationId": "interfaces_api_views_sta_datastream_get_datastream_linked_resources", @@ -19221,7 +19066,7 @@ ] } }, - "/api/data/datastreams/{datastream_id}/linked-resources/{linked_resource_id}": { + "/api/ogc/collections/datastreams/items/{datastream_id}/linked-resources/{linked_resource_id}": { "delete": { "description": "Remove a linked resource from a datastream.", "operationId": "interfaces_api_views_sta_datastream_remove_datastream_linked_resource", @@ -19478,109 +19323,264 @@ ] } }, - "/api/data/etl-data-connections": { + "/api/ogc/collections/datastreams/tags/keys": { "get": { - "description": "Get ETL Data Connections associated with the authenticated user.", - "operationId": "interfaces_api_views_etl_data_connection_get_data_connections", + "description": "Get all existing unique datastream tag keys.", + "operationId": "interfaces_api_views_sta_datastream_get_datastream_tag_keys", "parameters": [ { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "in": "query", - "name": "properties", - "required": false, - "schema": { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, - "items": { - "enum": [ - "id", - "name", - "description", - "sourceUrl", - "authHeaderName", - "authHeaderValue", - "timezoneType", - "timezone", - "workspaceId", - "payload", - "placeholderVariables", - "notification", - "taskCount", - "taskAttentionCount" - ], - "type": "string" - }, - "style": "form", - "title": "Properties", - "type": "array" - } - }, - { - "description": "Comma-separated list of related resources to include in the response.", - "in": "query", - "name": "include", - "required": false, - "schema": { - "description": "Comma-separated list of related resources to include in the response.", - "explode": false, - "items": { - "enum": [ - "workspace" - ], - "type": "string" - }, - "style": "form", - "title": "Include", - "type": "array" - } - }, - { - "description": "Number of items to skip.", "in": "query", - "name": "offset", + "name": "workspace_id", "required": false, "schema": { "anyOf": [ { - "minimum": 0, - "type": "integer" + "format": "uuid", + "type": "string" }, { "type": "null" } ], - "default": 0, - "description": "Number of items to skip.", - "title": "Offset" + "title": "Workspace Id" } }, { - "description": "The maximum number of items to return.", "in": "query", - "name": "limit", + "name": "datastream_id", "required": false, "schema": { "anyOf": [ { - "maximum": 100000, - "minimum": 0, - "type": "integer" + "format": "uuid", + "type": "string" }, { "type": "null" } ], - "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "title": "Datastream Id" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Response", + "type": "object" + } + } + }, + "description": "OK" }, - { - "description": "Select one or more fields to sort the response by.", - "in": "query", - "name": "sortby", - "required": false, - "schema": { + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Datastream Tag Keys", + "tags": [ + "Datastreams" + ] + } + }, + "/api/ogc/collections/datastreams/visualization-bootstrap": { + "get": { + "description": "Get the lean metadata required to bootstrap the visualization page.", + "operationId": "interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap", + "parameters": [ + { + "description": "Filter visualization bootstrap datastreams by workspace ID.", + "in": "query", + "name": "workspace_id", + "required": false, + "schema": { + "default": [], + "description": "Filter visualization bootstrap datastreams by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatastreamVisualizationBootstrapResponse" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Datastream Visualization Bootstrap", + "tags": [ + "Datastreams" + ] + } + }, + "/api/ogc/collections/etl-data-connections/items": { + "get": { + "description": "Get ETL Data Connections associated with the authenticated user.", + "operationId": "interfaces_api_views_etl_data_connection_get_data_connections", + "parameters": [ + { + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "in": "query", + "name": "properties", + "required": false, + "schema": { + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, + "items": { + "enum": [ + "id", + "name", + "description", + "sourceUrl", + "authHeaderName", + "authHeaderValue", + "timezoneType", + "timezone", + "workspaceId", + "payload", + "placeholderVariables", + "notification", + "taskCount", + "taskAttentionCount" + ], + "type": "string" + }, + "style": "form", + "title": "Properties", + "type": "array" + } + }, + { + "description": "Comma-separated list of related resources to include in the response.", + "in": "query", + "name": "include", + "required": false, + "schema": { + "description": "Comma-separated list of related resources to include in the response.", + "explode": false, + "items": { + "enum": [ + "workspace" + ], + "type": "string" + }, + "style": "form", + "title": "Include", + "type": "array" + } + }, + { + "description": "Number of items to skip.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "description": "Number of items to skip.", + "title": "Offset" + } + }, + { + "description": "The maximum number of items to return.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 100, + "description": "The maximum number of items to return.", + "title": "Limit" + } + }, + { + "description": "Select one or more fields to sort the response by.", + "in": "query", + "name": "sortby", + "required": false, + "schema": { "anyOf": [ { "items": { @@ -19797,7 +19797,7 @@ ] } }, - "/api/data/etl-data-connections/{data_connection_id}": { + "/api/ogc/collections/etl-data-connections/items/{data_connection_id}": { "delete": { "description": "Delete an ETL Data Connection.", "operationId": "interfaces_api_views_etl_data_connection_delete_data_connection", @@ -20083,7 +20083,7 @@ ] } }, - "/api/data/etl-mappings": { + "/api/ogc/collections/etl-mappings/items": { "get": { "description": "Get ETL mappings.", "operationId": "interfaces_api_views_etl_mapping_get_etl_mappings", @@ -20417,7 +20417,7 @@ ] } }, - "/api/data/etl-mappings/{mapping_id}": { + "/api/ogc/collections/etl-mappings/items/{mapping_id}": { "delete": { "description": "Delete an ETL mapping.", "operationId": "interfaces_api_views_etl_mapping_delete_etl_mapping", @@ -20703,7 +20703,7 @@ ] } }, - "/api/data/etl-tasks": { + "/api/ogc/collections/etl-tasks/items": { "get": { "description": "Get ETL Tasks accessible to the authenticated user.", "operationId": "interfaces_api_views_etl_task_get_etl_tasks", @@ -21147,7 +21147,7 @@ ] } }, - "/api/data/etl-tasks/{task_id}": { + "/api/ogc/collections/etl-tasks/items/{task_id}": { "delete": { "description": "Delete an ETL Task.", "operationId": "interfaces_api_views_etl_task_delete_etl_task", @@ -21439,7 +21439,7 @@ ] } }, - "/api/data/etl-tasks/{task_id}/runs": { + "/api/ogc/collections/etl-tasks/items/{task_id}/runs": { "get": { "description": "Get runs for an ETL Task.", "operationId": "interfaces_api_views_etl_task_get_etl_task_runs", @@ -21737,7 +21737,7 @@ ] } }, - "/api/data/etl-tasks/{task_id}/runs/{run_id}": { + "/api/ogc/collections/etl-tasks/items/{task_id}/runs/{run_id}": { "get": { "description": "Get a single run for an ETL Task.", "operationId": "interfaces_api_views_etl_task_get_etl_task_run", @@ -21828,7 +21828,7 @@ ] } }, - "/api/data/etl-tasks/{task_id}/trigger": { + "/api/ogc/collections/etl-tasks/items/{task_id}/trigger": { "post": { "description": "Trigger an immediate run of an ETL Task on a Celery worker.", "operationId": "interfaces_api_views_etl_task_trigger_etl_task", @@ -21909,7 +21909,7 @@ ] } }, - "/api/data/linked-resource-types": { + "/api/ogc/collections/linked-resource-types/items": { "get": { "description": "Get Linked Resource Types.", "operationId": "interfaces_api_views_sta_linked_resource_type_get_linked_resource_types", @@ -22160,7 +22160,7 @@ ] } }, - "/api/data/linked-resource-types/{linked_resource_type_id}": { + "/api/ogc/collections/linked-resource-types/items/{linked_resource_type_id}": { "delete": { "description": "Delete a Linked Resource Type.", "operationId": "interfaces_api_views_sta_linked_resource_type_delete_linked_resource_type", @@ -22394,7 +22394,7 @@ ] } }, - "/api/data/method-types": { + "/api/ogc/collections/method-types/items": { "get": { "description": "Get Method Types.", "operationId": "interfaces_api_views_sta_method_type_get_method_types", @@ -22645,7 +22645,7 @@ ] } }, - "/api/data/method-types/{method_type_id}": { + "/api/ogc/collections/method-types/items/{method_type_id}": { "delete": { "description": "Delete a Method Type.", "operationId": "interfaces_api_views_sta_method_type_delete_method_type", @@ -22879,7 +22879,7 @@ ] } }, - "/api/data/methods": { + "/api/ogc/collections/methods/items": { "get": { "description": "Get public Methods and Methods associated with the authenticated user.", "operationId": "interfaces_api_views_sta_method_get_methods", @@ -23279,7 +23279,7 @@ ] } }, - "/api/data/methods/{method_id}": { + "/api/ogc/collections/methods/items/{method_id}": { "delete": { "description": "Delete a Method.", "operationId": "interfaces_api_views_sta_method_delete_method", @@ -23562,7 +23562,7 @@ ] } }, - "/api/data/monitoring-rules": { + "/api/ogc/collections/monitoring-rules/items": { "get": { "description": "Get monitoring rules.", "operationId": "interfaces_api_views_monitoring_rule_get_monitoring_rules", @@ -23903,7 +23903,7 @@ ] } }, - "/api/data/monitoring-rules/{rule_id}": { + "/api/ogc/collections/monitoring-rules/items/{rule_id}": { "delete": { "description": "Delete a monitoring rule.", "operationId": "interfaces_api_views_monitoring_rule_delete_monitoring_rule", @@ -24194,7 +24194,7 @@ ] } }, - "/api/data/monitoring-site-types": { + "/api/ogc/collections/monitoring-site-types/items": { "get": { "description": "Get Monitoring Site Types.", "operationId": "interfaces_api_views_sta_monitoring_site_type_get_monitoring_site_types", @@ -24445,7 +24445,7 @@ ] } }, - "/api/data/monitoring-site-types/{monitoring_site_type_id}": { + "/api/ogc/collections/monitoring-site-types/items/{monitoring_site_type_id}": { "delete": { "description": "Delete a Monitoring Site Type.", "operationId": "interfaces_api_views_sta_monitoring_site_type_delete_monitoring_site_type", @@ -24679,7 +24679,7 @@ ] } }, - "/api/data/monitoring-sites": { + "/api/ogc/collections/monitoring-sites/items": { "get": { "description": "Get public MonitoringSites and MonitoringSites associated with the authenticated user.", "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_sites", @@ -25079,74 +25079,38 @@ ] } }, - "/api/data/monitoring-sites/markers": { - "get": { - "description": "Get lean marker data for public MonitoringSites plus private MonitoringSites visible to the authenticated user.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers", + "/api/ogc/collections/monitoring-sites/items/{monitoring_site_id}": { + "delete": { + "description": "Delete a MonitoringSite.", + "operationId": "interfaces_api_views_sta_monitoring_site_delete_monitoring_site", "parameters": [ { - "description": "Filter markers by workspace ID.", - "in": "query", - "name": "workspace_id", - "required": false, - "schema": { - "default": [], - "description": "Filter markers by workspace ID.", - "items": { - "format": "uuid", - "type": "string" - }, - "title": "Workspace Id", - "type": "array" - } - }, - { - "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", - "in": "query", - "name": "bbox", - "required": false, - "schema": { - "default": [], - "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", - "items": { - "type": "string" - }, - "title": "Bbox", - "type": "array" - } - }, - { - "description": "Filter markers by monitoring site type.", - "in": "query", - "name": "type", - "required": false, + "in": "path", + "name": "monitoring_site_id", + "required": true, "schema": { - "default": [], - "description": "Filter markers by monitoring site type.", - "items": { - "type": "string" - }, - "title": "Type", - "type": "array" + "format": "uuid", + "title": "Monitoring Site Id", + "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/MonitoringSiteMarkerResponse" - }, + "204": { + "description": "No Content" + }, + "401": { + "content": { + "application/json": { + "schema": { "title": "Response", - "type": "array" + "type": "string" } } }, - "description": "OK" + "description": "Unauthorized" }, - "401": { + "403": { "content": { "application/json": { "schema": { @@ -25155,7 +25119,7 @@ } } }, - "description": "Unauthorized" + "description": "Forbidden" } }, "security": [ @@ -25172,45 +25136,77 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Site Markers", + "summary": "Delete Monitoring Site", "tags": [ "Monitoring Sites" ] - } - }, - "/api/data/monitoring-sites/site-summaries": { + }, "get": { - "description": "Get lean site summary data for public MonitoringSites and MonitoringSites associated with the authenticated user.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_summaries", + "description": "Get a MonitoringSite.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site", "parameters": [ { - "description": "Filter site summaries by workspace ID.", + "in": "path", + "name": "monitoring_site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Monitoring Site Id", + "type": "string" + } + }, + { + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "in": "query", - "name": "workspace_id", + "name": "properties", "required": false, "schema": { - "default": [], - "description": "Filter site summaries by workspace ID.", + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "items": { - "format": "uuid", + "enum": [ + "id", + "workspaceId", + "name", + "description", + "code", + "type", + "latitude", + "longitude", + "elevation_m", + "elevationDatum", + "adminArea1", + "adminArea2", + "country", + "dataDisclaimer", + "isPrivate", + "tags", + "linkedResources" + ], "type": "string" }, - "title": "Workspace Id", + "style": "form", + "title": "Properties", "type": "array" } }, { - "description": "Filter summaries by monitoring site type.", + "description": "Comma-separated list of related resources to include in the response.", "in": "query", - "name": "type", + "name": "include", "required": false, "schema": { - "default": [], - "description": "Filter summaries by monitoring site type.", + "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "items": { + "enum": [ + "workspace", + "type" + ], "type": "string" }, - "title": "Type", + "style": "form", + "title": "Include", "type": "array" } } @@ -25220,11 +25216,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/MonitoringSiteMapSummaryResponse" - }, - "title": "Response", - "type": "array" + "$ref": "#/components/schemas/ItemResponse_MonitoringSiteResponse_" } } }, @@ -25240,6 +25232,17 @@ } }, "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" } }, "security": [ @@ -25256,96 +25259,50 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Site Summaries", - "tags": [ - "Monitoring Sites" - ] - } - }, - "/api/data/monitoring-sites/site-type-icons": { - "get": { - "description": "Get the configured site type icon mappings.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_site_type_icons", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SiteTypeIconResponse" - }, - "title": "Response", - "type": "array" - } - } - }, - "description": "OK" - } - }, - "summary": "Get Site Type Icons", + "summary": "Get Monitoring Site", "tags": [ "Monitoring Sites" ] - } - }, - "/api/data/monitoring-sites/tags/keys": { - "get": { - "description": "Get all existing unique monitoring_site tag keys.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_tag_keys", + }, + "patch": { + "description": "Update a MonitoringSite.", + "operationId": "interfaces_api_views_sta_monitoring_site_update_monitoring_site", "parameters": [ { - "in": "query", - "name": "workspace_id", - "required": false, - "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Workspace Id" - } - }, - { - "in": "query", + "in": "path", "name": "monitoring_site_id", - "required": false, + "required": true, "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Monitoring Site Id" + "format": "uuid", + "title": "Monitoring Site Id", + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MonitoringSitePatchBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "204": { + "description": "No Content" + }, + "400": { "content": { "application/json": { "schema": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, "title": "Response", - "type": "object" + "type": "string" } } }, - "description": "OK" + "description": "Bad Request" }, "401": { "content": { @@ -25357,6 +25314,17 @@ } }, "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" } }, "security": [ @@ -25373,46 +25341,117 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Site Tag Keys", + "summary": "Update Monitoring Site", "tags": [ "Monitoring Sites" ] } }, - "/api/data/monitoring-sites/task-summaries": { + "/api/ogc/collections/monitoring-sites/items/{monitoring_site_id}/linked-resources": { "get": { - "description": "Get task count summaries for MonitoringSites associated with the authenticated user.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_task_summaries", + "description": "Get all linked resources associated with a MonitoringSite.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resources", "parameters": [ { - "description": "Filter task summaries by workspace ID.", + "in": "path", + "name": "monitoring_site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Monitoring Site Id", + "type": "string" + } + }, + { + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "in": "query", - "name": "workspace_id", + "name": "properties", "required": false, "schema": { - "default": [], - "description": "Filter task summaries by workspace ID.", - "items": { - "format": "uuid", - "type": "string" - }, - "title": "Workspace Id", - "type": "array" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "title": "Properties" } }, { - "description": "Filter summaries by monitoring site type.", + "description": "Comma-separated list of related resources to include in the response.", "in": "query", - "name": "type", + "name": "include", "required": false, "schema": { - "default": [], - "description": "Filter summaries by monitoring site type.", - "items": { - "type": "string" - }, - "title": "Type", - "type": "array" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Comma-separated list of related resources to include in the response.", + "title": "Include" + } + }, + { + "description": "Number of items to skip.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "description": "Number of items to skip.", + "title": "Offset" + } + }, + { + "description": "The maximum number of items to return.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 100, + "description": "The maximum number of items to return.", + "title": "Limit" + } + }, + { + "description": "Filter by linked resource type.", + "in": "query", + "name": "type", + "required": false, + "schema": { + "default": [], + "description": "Filter by linked resource type.", + "items": { + "type": "string" + }, + "title": "Type", + "type": "array" } } ], @@ -25422,7 +25461,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/MonitoringSiteTaskSummaryResponse" + "$ref": "#/components/schemas/LinkedResourceGetResponse" }, "title": "Response", "type": "array" @@ -25441,6 +25480,17 @@ } }, "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" } }, "security": [ @@ -25457,16 +25507,14 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Site Task Summaries", + "summary": "Get Monitoring Site Linked Resources", "tags": [ "Monitoring Sites" ] - } - }, - "/api/data/monitoring-sites/{monitoring_site_id}": { - "delete": { - "description": "Delete a MonitoringSite.", - "operationId": "interfaces_api_views_sta_monitoring_site_delete_monitoring_site", + }, + "post": { + "description": "Add a linked resource to a monitoring_site.", + "operationId": "interfaces_api_views_sta_monitoring_site_add_monitoring_site_linked_resource", "parameters": [ { "in": "path", @@ -25479,9 +25527,88 @@ } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "file": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "File" + }, + "link": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Link" + }, + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "type": { + "minLength": 1, + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "title": "MultiPartBodyParams", + "type": "object" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedResponse" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Bad Request" }, "401": { "content": { @@ -25504,6 +25631,17 @@ } }, "description": "Forbidden" + }, + "413": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Content Too Large" } }, "security": [ @@ -25520,14 +25658,16 @@ "BasicAuth": [] } ], - "summary": "Delete Monitoring Site", + "summary": "Add Monitoring Site Linked Resource", "tags": [ "Monitoring Sites" ] - }, - "get": { - "description": "Get a MonitoringSite.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site", + } + }, + "/api/ogc/collections/monitoring-sites/items/{monitoring_site_id}/linked-resources/{linked_resource_id}": { + "delete": { + "description": "Remove a linked resource from a monitoring_site.", + "operationId": "interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource", "parameters": [ { "in": "path", @@ -25540,71 +25680,19 @@ } }, { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "in": "query", - "name": "properties", - "required": false, - "schema": { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, - "items": { - "enum": [ - "id", - "workspaceId", - "name", - "description", - "code", - "type", - "latitude", - "longitude", - "elevation_m", - "elevationDatum", - "adminArea1", - "adminArea2", - "country", - "dataDisclaimer", - "isPrivate", - "tags", - "linkedResources" - ], - "type": "string" - }, - "style": "form", - "title": "Properties", - "type": "array" - } - }, - { - "description": "Comma-separated list of related resources to include in the response.", - "in": "query", - "name": "include", - "required": false, + "in": "path", + "name": "linked_resource_id", + "required": true, "schema": { - "description": "Comma-separated list of related resources to include in the response.", - "explode": false, - "items": { - "enum": [ - "workspace", - "type" - ], - "type": "string" - }, - "style": "form", - "title": "Include", - "type": "array" + "format": "uuid", + "title": "Linked Resource Id", + "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ItemResponse_MonitoringSiteResponse_" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "401": { "content": { @@ -25627,6 +25715,17 @@ } }, "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" } }, "security": [ @@ -25643,14 +25742,14 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Site", + "summary": "Remove Monitoring Site Linked Resource", "tags": [ "Monitoring Sites" ] }, "patch": { - "description": "Update a MonitoringSite.", - "operationId": "interfaces_api_views_sta_monitoring_site_update_monitoring_site", + "description": "Update a linked resource for a monitoring_site. A linked resource's mode (hosted file vs.\nexternal URL) cannot be changed in place \u2014 delete it and create a new one instead.", + "operationId": "interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource", "parameters": [ { "in": "path", @@ -25661,13 +25760,82 @@ "title": "Monitoring Site Id", "type": "string" } + }, + { + "in": "path", + "name": "linked_resource_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Linked Resource Id", + "type": "string" + } } ], "requestBody": { "content": { - "application/json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MonitoringSitePatchBody" + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "file": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "File" + }, + "link": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Link" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + } + }, + "title": "MultiPartBodyParams", + "type": "object" } } }, @@ -25709,6 +25877,28 @@ } }, "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Content Too Large" } }, "security": [ @@ -25725,112 +25915,56 @@ "BasicAuth": [] } ], - "summary": "Update Monitoring Site", + "summary": "Update Monitoring Site Linked Resource", "tags": [ "Monitoring Sites" ] } }, - "/api/data/monitoring-sites/{monitoring_site_id}/linked-resources": { + "/api/ogc/collections/monitoring-sites/markers": { "get": { - "description": "Get all linked resources associated with a MonitoringSite.", - "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resources", + "description": "Get lean marker data for public MonitoringSites plus private MonitoringSites visible to the authenticated user.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers", "parameters": [ { - "in": "path", - "name": "monitoring_site_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Monitoring Site Id", - "type": "string" - } - }, - { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "in": "query", - "name": "properties", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "title": "Properties" - } - }, - { - "description": "Comma-separated list of related resources to include in the response.", - "in": "query", - "name": "include", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Comma-separated list of related resources to include in the response.", - "title": "Include" - } - }, - { - "description": "Number of items to skip.", + "description": "Filter markers by workspace ID.", "in": "query", - "name": "offset", + "name": "workspace_id", "required": false, "schema": { - "anyOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": 0, - "description": "Number of items to skip.", - "title": "Offset" + "default": [], + "description": "Filter markers by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" } }, { - "description": "The maximum number of items to return.", + "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", "in": "query", - "name": "limit", + "name": "bbox", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "default": [], + "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "items": { + "type": "string" + }, + "title": "Bbox", + "type": "array" } }, { - "description": "Filter by linked resource type.", + "description": "Filter markers by monitoring site type.", "in": "query", "name": "type", "required": false, "schema": { "default": [], - "description": "Filter by linked resource type.", + "description": "Filter markers by monitoring site type.", "items": { "type": "string" }, @@ -25845,7 +25979,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/LinkedResourceGetResponse" + "$ref": "#/components/schemas/MonitoringSiteMarkerResponse" }, "title": "Response", "type": "array" @@ -25864,17 +25998,6 @@ } }, "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" } }, "security": [ @@ -25891,108 +26014,63 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Site Linked Resources", + "summary": "Get Monitoring Site Markers", "tags": [ "Monitoring Sites" ] - }, - "post": { - "description": "Add a linked resource to a monitoring_site.", - "operationId": "interfaces_api_views_sta_monitoring_site_add_monitoring_site_linked_resource", + } + }, + "/api/ogc/collections/monitoring-sites/site-summaries": { + "get": { + "description": "Get lean site summary data for public MonitoringSites and MonitoringSites associated with the authenticated user.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_summaries", "parameters": [ { - "in": "path", - "name": "monitoring_site_id", - "required": true, + "description": "Filter site summaries by workspace ID.", + "in": "query", + "name": "workspace_id", + "required": false, "schema": { - "format": "uuid", - "title": "Monitoring Site Id", - "type": "string" + "default": [], + "description": "Filter site summaries by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" + } + }, + { + "description": "Filter summaries by monitoring site type.", + "in": "query", + "name": "type", + "required": false, + "schema": { + "default": [], + "description": "Filter summaries by monitoring site type.", + "items": { + "type": "string" + }, + "title": "Type", + "type": "array" } } ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "file": { - "anyOf": [ - { - "format": "binary", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "File" - }, - "link": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Link" - }, - "name": { - "minLength": 1, - "title": "Name", - "type": "string" - }, - "type": { - "minLength": 1, - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "type" - ], - "title": "MultiPartBodyParams", - "type": "object" - } - } - }, - "required": true - }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatedResponse" - } - } - }, - "description": "Created" - }, - "400": { + "200": { "content": { "application/json": { "schema": { + "items": { + "$ref": "#/components/schemas/MonitoringSiteMapSummaryResponse" + }, "title": "Response", - "type": "string" + "type": "array" } } }, - "description": "Bad Request" + "description": "OK" }, "401": { "content": { @@ -26004,28 +26082,6 @@ } }, "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - }, - "413": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Content Too Large" } }, "security": [ @@ -26042,203 +26098,96 @@ "BasicAuth": [] } ], - "summary": "Add Monitoring Site Linked Resource", + "summary": "Get Monitoring Site Summaries", "tags": [ "Monitoring Sites" ] } }, - "/api/data/monitoring-sites/{monitoring_site_id}/linked-resources/{linked_resource_id}": { - "delete": { - "description": "Remove a linked resource from a monitoring_site.", - "operationId": "interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource", - "parameters": [ - { - "in": "path", - "name": "monitoring_site_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Monitoring Site Id", - "type": "string" - } - }, - { - "in": "path", - "name": "linked_resource_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Linked Resource Id", - "type": "string" - } - } - ], + "/api/ogc/collections/monitoring-sites/site-type-icons": { + "get": { + "description": "Get the configured site type icon mappings.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_site_type_icons", + "parameters": [], "responses": { - "204": { - "description": "No Content" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - }, - "404": { + "200": { "content": { "application/json": { "schema": { + "items": { + "$ref": "#/components/schemas/SiteTypeIconResponse" + }, "title": "Response", - "type": "string" + "type": "array" } } }, - "description": "Not Found" + "description": "OK" } }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Remove Monitoring Site Linked Resource", + "summary": "Get Site Type Icons", "tags": [ "Monitoring Sites" ] - }, - "patch": { - "description": "Update a linked resource for a monitoring_site. A linked resource's mode (hosted file vs.\nexternal URL) cannot be changed in place \u2014 delete it and create a new one instead.", - "operationId": "interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource", + } + }, + "/api/ogc/collections/monitoring-sites/tags/keys": { + "get": { + "description": "Get all existing unique monitoring_site tag keys.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_tag_keys", "parameters": [ { - "in": "path", - "name": "monitoring_site_id", - "required": true, + "in": "query", + "name": "workspace_id", + "required": false, "schema": { - "format": "uuid", - "title": "Monitoring Site Id", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workspace Id" } }, { - "in": "path", - "name": "linked_resource_id", - "required": true, + "in": "query", + "name": "monitoring_site_id", + "required": false, "schema": { - "format": "uuid", - "title": "Linked Resource Id", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Monitoring Site Id" } } ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "file": { - "anyOf": [ - { - "format": "binary", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "File" - }, - "link": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Link" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Type" - } - }, - "title": "MultiPartBodyParams", - "type": "object" - } - } - }, - "required": true - }, "responses": { - "204": { - "description": "No Content" - }, - "400": { + "200": { "content": { "application/json": { "schema": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, "title": "Response", - "type": "string" + "type": "object" } } }, - "description": "Bad Request" + "description": "OK" }, "401": { "content": { @@ -26250,30 +26199,81 @@ } }, "description": "Unauthorized" + } + }, + "security": [ + { + "SessionAuth": [] }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" + { + "OIDCAuth": [] }, - "404": { + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Monitoring Site Tag Keys", + "tags": [ + "Monitoring Sites" + ] + } + }, + "/api/ogc/collections/monitoring-sites/task-summaries": { + "get": { + "description": "Get task count summaries for MonitoringSites associated with the authenticated user.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_task_summaries", + "parameters": [ + { + "description": "Filter task summaries by workspace ID.", + "in": "query", + "name": "workspace_id", + "required": false, + "schema": { + "default": [], + "description": "Filter task summaries by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" + } + }, + { + "description": "Filter summaries by monitoring site type.", + "in": "query", + "name": "type", + "required": false, + "schema": { + "default": [], + "description": "Filter summaries by monitoring site type.", + "items": { + "type": "string" + }, + "title": "Type", + "type": "array" + } + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { + "items": { + "$ref": "#/components/schemas/MonitoringSiteTaskSummaryResponse" + }, "title": "Response", - "type": "string" + "type": "array" } } }, - "description": "Not Found" + "description": "OK" }, - "413": { + "401": { "content": { "application/json": { "schema": { @@ -26282,7 +26282,7 @@ } } }, - "description": "Content Too Large" + "description": "Unauthorized" } }, "security": [ @@ -26299,13 +26299,13 @@ "BasicAuth": [] } ], - "summary": "Update Monitoring Site Linked Resource", + "summary": "Get Monitoring Site Task Summaries", "tags": [ "Monitoring Sites" ] } }, - "/api/data/monitoring-tasks": { + "/api/ogc/collections/monitoring-tasks/items": { "get": { "description": "Get monitoring tasks accessible to the authenticated user.", "operationId": "interfaces_api_views_monitoring_task_get_monitoring_tasks", @@ -26676,7 +26676,7 @@ ] } }, - "/api/data/monitoring-tasks/{task_id}": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}": { "delete": { "description": "Delete a monitoring task.", "operationId": "interfaces_api_views_monitoring_task_delete_monitoring_task", @@ -26968,7 +26968,7 @@ ] } }, - "/api/data/monitoring-tasks/{task_id}/runs": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}/runs": { "get": { "description": "Get runs for a monitoring task.", "operationId": "interfaces_api_views_monitoring_task_get_monitoring_task_runs", @@ -27266,7 +27266,7 @@ ] } }, - "/api/data/monitoring-tasks/{task_id}/runs/{run_id}": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}/runs/{run_id}": { "get": { "description": "Get a single run for a monitoring task.", "operationId": "interfaces_api_views_monitoring_task_get_monitoring_task_run", @@ -27292,18 +27292,205 @@ } } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaskRunResponse" - } + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRunResponse" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Monitoring Task Run", + "tags": [ + "Monitoring Tasks" + ] + } + }, + "/api/ogc/collections/monitoring-tasks/items/{task_id}/trigger": { + "post": { + "description": "Trigger an immediate run of a monitoring task on a Celery worker.", + "operationId": "interfaces_api_views_monitoring_task_trigger_monitoring_task", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRunResponse" + } + } + }, + "description": "Accepted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Trigger Monitoring Task", + "tags": [ + "Monitoring Tasks" + ] + } + }, + "/api/ogc/collections/observations/bulk-create": { + "post": { + "description": "Insert Observations.", + "operationId": "interfaces_api_views_sta_observation_insert_observations", + "parameters": [ + { + "description": "Specifies how new observations are added to the datastream. `insert` allows observations at any timestamp. `append` adds only future observations (after the latest existing timestamp). `backfill` adds only historical observations (before the earliest existing timestamp). `replace` deletes all observations in the range of provided observations before inserting new ones.", + "in": "query", + "name": "mode", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "insert", + "append", + "backfill", + "replace" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Specifies how new observations are added to the datastream. `insert` allows observations at any timestamp. `append` adds only future observations (after the latest existing timestamp). `backfill` adds only historical observations (before the earliest existing timestamp). `replace` deletes all observations in the range of provided observations before inserting new ones.", + "title": "Mode" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ObservationBulkPostBody" + }, + { + "$ref": "#/components/schemas/ObservationBulkColumnarPostBody" + } + ], + "title": "Data" } - }, - "description": "OK" + } }, - "401": { + "required": true + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { "content": { "application/json": { "schema": { @@ -27312,7 +27499,7 @@ } } }, - "description": "Unauthorized" + "description": "Bad Request" }, "403": { "content": { @@ -27351,49 +27538,30 @@ "BasicAuth": [] } ], - "summary": "Get Monitoring Task Run", + "summary": "Insert Observations", "tags": [ - "Monitoring Tasks" + "Observations" ] } }, - "/api/data/monitoring-tasks/{task_id}/trigger": { + "/api/ogc/collections/observations/bulk-delete": { "post": { - "description": "Trigger an immediate run of a monitoring task on a Celery worker.", - "operationId": "interfaces_api_views_monitoring_task_trigger_monitoring_task", - "parameters": [ - { - "in": "path", - "name": "task_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Task Id", - "type": "string" - } - } - ], - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaskRunResponse" - } + "description": "Delete Observations between the given phenomenon start and end times.", + "operationId": "interfaces_api_views_sta_observation_delete_observations", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObservationBulkDeleteBody" } - }, - "description": "Accepted" + } }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" + "required": true + }, + "responses": { + "204": { + "description": "No Content" }, "403": { "content": { @@ -27432,13 +27600,13 @@ "BasicAuth": [] } ], - "summary": "Trigger Monitoring Task", + "summary": "Delete Observations", "tags": [ - "Monitoring Tasks" + "Observations" ] } }, - "/api/data/observations": { + "/api/ogc/collections/observations/items": { "get": { "description": "Get Observations.", "operationId": "interfaces_api_views_sta_observation_get_observations", @@ -27810,175 +27978,7 @@ ] } }, - "/api/data/observations/bulk-create": { - "post": { - "description": "Insert Observations.", - "operationId": "interfaces_api_views_sta_observation_insert_observations", - "parameters": [ - { - "description": "Specifies how new observations are added to the datastream. `insert` allows observations at any timestamp. `append` adds only future observations (after the latest existing timestamp). `backfill` adds only historical observations (before the earliest existing timestamp). `replace` deletes all observations in the range of provided observations before inserting new ones.", - "in": "query", - "name": "mode", - "required": false, - "schema": { - "anyOf": [ - { - "enum": [ - "insert", - "append", - "backfill", - "replace" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Specifies how new observations are added to the datastream. `insert` allows observations at any timestamp. `append` adds only future observations (after the latest existing timestamp). `backfill` adds only historical observations (before the earliest existing timestamp). `replace` deletes all observations in the range of provided observations before inserting new ones.", - "title": "Mode" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ObservationBulkPostBody" - }, - { - "$ref": "#/components/schemas/ObservationBulkColumnarPostBody" - } - ], - "title": "Data" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Created" - }, - "400": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Bad Request" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Not Found" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Insert Observations", - "tags": [ - "Observations" - ] - } - }, - "/api/data/observations/bulk-delete": { - "post": { - "description": "Delete Observations between the given phenomenon start and end times.", - "operationId": "interfaces_api_views_sta_observation_delete_observations", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservationBulkDeleteBody" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "No Content" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Not Found" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Delete Observations", - "tags": [ - "Observations" - ] - } - }, - "/api/data/observations/{observation_id}": { + "/api/ogc/collections/observations/items/{observation_id}": { "delete": { "description": "Delete an Observation.", "operationId": "interfaces_api_views_sta_observation_delete_observation", @@ -28165,7 +28165,7 @@ ] } }, - "/api/data/observed-properties": { + "/api/ogc/collections/observed-properties/items": { "get": { "description": "Get public Observed Properties and Observed Properties associated with the authenticated user.", "operationId": "interfaces_api_views_sta_observed_property_get_observed_properties", @@ -28524,7 +28524,7 @@ ] } }, - "/api/data/observed-properties/{observed_property_id}": { + "/api/ogc/collections/observed-properties/items/{observed_property_id}": { "delete": { "description": "Delete an Observed Property.", "operationId": "interfaces_api_views_sta_observed_property_delete_observed_property", @@ -28804,7 +28804,7 @@ ] } }, - "/api/data/observed-property-types": { + "/api/ogc/collections/observed-property-types/items": { "get": { "description": "Get Observed Property Types.", "operationId": "interfaces_api_views_sta_observed_property_type_get_observed_property_types", @@ -29055,7 +29055,7 @@ ] } }, - "/api/data/observed-property-types/{observed_property_type_id}": { + "/api/ogc/collections/observed-property-types/items/{observed_property_type_id}": { "delete": { "description": "Delete an Observed Property Type.", "operationId": "interfaces_api_views_sta_observed_property_type_delete_observed_property_type", @@ -29289,7 +29289,7 @@ ] } }, - "/api/data/processing-levels": { + "/api/ogc/collections/processing-levels/items": { "get": { "description": "Get public Processing Levels and Processing Levels associated with the authenticated user.", "operationId": "interfaces_api_views_sta_processing_level_get_processing_levels", @@ -29629,7 +29629,7 @@ ] } }, - "/api/data/processing-levels/{processing_level_id}": { + "/api/ogc/collections/processing-levels/items/{processing_level_id}": { "delete": { "description": "Delete a Processing Level.", "operationId": "interfaces_api_views_sta_processing_level_delete_processing_level", @@ -29907,7 +29907,7 @@ ] } }, - "/api/data/quality-control/histories": { + "/api/ogc/collections/quality-control-histories/items": { "get": { "description": "Get QC histories.", "operationId": "interfaces_api_views_quality_history_get_qc_histories", @@ -30207,7 +30207,7 @@ ] } }, - "/api/data/quality-control/histories/{history_id}": { + "/api/ogc/collections/quality-control-histories/items/{history_id}": { "delete": { "description": "Delete a QC history and all associated sessions.", "operationId": "interfaces_api_views_quality_history_delete_qc_history", @@ -30406,7 +30406,7 @@ ] } }, - "/api/data/quality-control/histories/{history_id}/sessions": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions": { "get": { "description": "Get sessions for a QC history. Supports range_start/range_end overlap filtering, ancestor_of, and include_ancestors.", "operationId": "interfaces_api_views_quality_session_get_qc_sessions", @@ -30807,7 +30807,7 @@ ] } }, - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}": { "delete": { "description": "Delete an in-progress session.", "operationId": "interfaces_api_views_quality_session_delete_qc_session", @@ -31113,7 +31113,7 @@ ] } }, - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}/commit": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}/commit": { "post": { "description": "Commit an in-progress session after observations have been pushed to the managed datastream.", "operationId": "interfaces_api_views_quality_session_commit_qc_session", @@ -31208,7 +31208,7 @@ ] } }, - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}/operations": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}/operations": { "get": { "description": "Get all operations for a QC session in execution order.", "operationId": "interfaces_api_views_quality_operation_get_qc_operations", @@ -31533,7 +31533,7 @@ ] } }, - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}/operations/{operation_id}": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}/operations/{operation_id}": { "delete": { "description": "Delete an operation from an in-progress session.", "operationId": "interfaces_api_views_quality_operation_delete_qc_operation", @@ -31864,7 +31864,7 @@ ] } }, - "/api/data/result-qualifiers": { + "/api/ogc/collections/result-qualifiers/items": { "get": { "description": "Get public Result Qualifiers and Result Qualifiers associated with the authenticated user.", "operationId": "interfaces_api_views_sta_result_qualifier_get_result_qualifiers", @@ -32141,7 +32141,7 @@ ] } }, - "/api/data/result-qualifiers/{result_qualifier_id}": { + "/api/ogc/collections/result-qualifiers/items/{result_qualifier_id}": { "delete": { "description": "Delete a Result Qualifier.", "operationId": "interfaces_api_views_sta_result_qualifier_delete_result_qualifier", @@ -32395,7 +32395,7 @@ ] } }, - "/api/data/roles": { + "/api/ogc/collections/roles/items": { "get": { "description": "Get public Roles and Roles associated with the authenticated user.", "operationId": "interfaces_api_views_iam_role_get_roles", @@ -32576,7 +32576,7 @@ ] } }, - "/api/data/roles/{role_id}": { + "/api/ogc/collections/roles/items/{role_id}": { "get": { "description": "Get a Role.", "operationId": "interfaces_api_views_iam_role_get_role", @@ -32669,7 +32669,7 @@ ] } }, - "/api/data/sampled-mediums": { + "/api/ogc/collections/sampled-mediums/items": { "get": { "description": "Get Sampled Mediums.", "operationId": "interfaces_api_views_sta_sampled_medium_get_sampled_mediums", @@ -32920,7 +32920,7 @@ ] } }, - "/api/data/sampled-mediums/{sampled_medium_id}": { + "/api/ogc/collections/sampled-mediums/items/{sampled_medium_id}": { "delete": { "description": "Delete a Sampled Medium.", "operationId": "interfaces_api_views_sta_sampled_medium_delete_sampled_medium", @@ -33154,7 +33154,7 @@ ] } }, - "/api/data/unit-types": { + "/api/ogc/collections/unit-types/items": { "get": { "description": "Get Unit Types.", "operationId": "interfaces_api_views_sta_unit_type_get_unit_types", @@ -33405,7 +33405,7 @@ ] } }, - "/api/data/unit-types/{unit_type_id}": { + "/api/ogc/collections/unit-types/items/{unit_type_id}": { "delete": { "description": "Delete a Unit Type.", "operationId": "interfaces_api_views_sta_unit_type_delete_unit_type", @@ -33639,7 +33639,7 @@ ] } }, - "/api/data/units": { + "/api/ogc/collections/units/items": { "get": { "description": "Get public Units and Units associated with the authenticated user.", "operationId": "interfaces_api_views_sta_unit_get_units", @@ -33986,7 +33986,7 @@ ] } }, - "/api/data/units/{unit_id}": { + "/api/ogc/collections/units/items/{unit_id}": { "delete": { "description": "Delete a Unit.", "operationId": "interfaces_api_views_sta_unit_delete_unit", @@ -34265,7 +34265,7 @@ ] } }, - "/api/data/workspaces": { + "/api/ogc/collections/workspaces/items": { "get": { "description": "Get public workspaces and workspaces associated with the authenticated user.", "operationId": "interfaces_api_views_iam_workspace_get_workspaces", @@ -34560,7 +34560,7 @@ ] } }, - "/api/data/workspaces/{workspace_id}": { + "/api/ogc/collections/workspaces/items/{workspace_id}": { "delete": { "description": "Delete a workspace owned by the authenticated user.", "operationId": "interfaces_api_views_iam_workspace_delete_workspace", @@ -34823,7 +34823,7 @@ ] } }, - "/api/data/workspaces/{workspace_id}/collaborators": { + "/api/ogc/collections/workspaces/items/{workspace_id}/collaborators": { "delete": { "description": "Remove a collaborator from a workspace.", "operationId": "interfaces_api_views_iam_collaborator_remove_collaborator", @@ -35264,7 +35264,7 @@ ] } }, - "/api/data/workspaces/{workspace_id}/service-accounts": { + "/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts": { "get": { "description": "Get service accounts associated with the authenticated user.", "operationId": "interfaces_api_views_iam_service_account_get_service_accounts", @@ -35511,7 +35511,7 @@ ] } }, - "/api/data/workspaces/{workspace_id}/service-accounts/{service_account_id}": { + "/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts/{service_account_id}": { "delete": { "description": "Delete a service account.", "operationId": "interfaces_api_views_iam_service_account_delete_service_account", @@ -35771,7 +35771,7 @@ ] } }, - "/api/data/workspaces/{workspace_id}/service-accounts/{service_account_id}/regenerate": { + "/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts/{service_account_id}/regenerate": { "put": { "description": "Regenerate a service account's key using existing settings.", "operationId": "interfaces_api_views_iam_service_account_regenerate_service_account_key", @@ -35856,7 +35856,7 @@ ] } }, - "/api/data/workspaces/{workspace_id}/transfer": { + "/api/ogc/collections/workspaces/items/{workspace_id}/transfer": { "delete": { "description": "Reject a pending workspace transfer.", "operationId": "interfaces_api_views_iam_workspace_reject_workspace_transfer", diff --git a/django/interfaces/actions/management/commands/export_openapi_contracts.py b/django/interfaces/actions/management/commands/export_openapi_contracts.py index f4896c0a..7edcd2c8 100644 --- a/django/interfaces/actions/management/commands/export_openapi_contracts.py +++ b/django/interfaces/actions/management/commands/export_openapi_contracts.py @@ -8,7 +8,7 @@ SCHEMA_TARGETS = { - "data.openapi.json": (api, "/api/data/"), + "ogc.openapi.json": (api, "/api/ogc/"), } diff --git a/django/interfaces/api/urls.py b/django/interfaces/api/urls.py index 2b31a088..912c6706 100644 --- a/django/interfaces/api/urls.py +++ b/django/interfaces/api/urls.py @@ -47,7 +47,7 @@ api = NinjaAPI( title="HydroServer Data Management API", version=__version__, - urls_namespace="data", + urls_namespace="ogc", docs_decorator=ensure_csrf_cookie, renderer=ORJSONRenderer(), throttle=[cls(rate_limits[k]) for k, cls in throttle_classes.items() if rate_limits.get(k)], @@ -55,46 +55,46 @@ handlers.register(api) -api.add_router("workspaces", workspace_router) -api.add_router("roles", role_router) +api.add_router("collections/workspaces", workspace_router) +api.add_router("collections/roles", role_router) -api.add_router("monitoring-sites", monitoring_site_router) -api.add_router("monitoring-site-types", monitoring_site_type_router) -api.add_router("linked-resource-types", linked_resource_type_router) -api.add_router("datastreams", datastream_router) -api.add_router("datastream-statuses", datastream_status_router) -api.add_router("aggregation-statistics", aggregation_statistic_router) -api.add_router("observations", observation_router) -api.add_router("observed-properties", observed_property_router) -api.add_router("observed-property-types", observed_property_type_router) -api.add_router("units", unit_router) -api.add_router("unit-types", unit_type_router) -api.add_router("methods", method_router) -api.add_router("method-types", method_type_router) -api.add_router("processing-levels", processing_level_router) -api.add_router("result-qualifiers", result_qualifier_router) -api.add_router("sampled-mediums", sampled_medium_router) +api.add_router("collections/monitoring-sites", monitoring_site_router) +api.add_router("collections/monitoring-site-types", monitoring_site_type_router) +api.add_router("collections/linked-resource-types", linked_resource_type_router) +api.add_router("collections/datastreams", datastream_router) +api.add_router("collections/datastream-statuses", datastream_status_router) +api.add_router("collections/aggregation-statistics", aggregation_statistic_router) +api.add_router("collections/observations", observation_router) +api.add_router("collections/observed-properties", observed_property_router) +api.add_router("collections/observed-property-types", observed_property_type_router) +api.add_router("collections/units", unit_router) +api.add_router("collections/unit-types", unit_type_router) +api.add_router("collections/methods", method_router) +api.add_router("collections/method-types", method_type_router) +api.add_router("collections/processing-levels", processing_level_router) +api.add_router("collections/result-qualifiers", result_qualifier_router) +api.add_router("collections/sampled-mediums", sampled_medium_router) -api.add_router("etl-data-connections", data_connection_router) -api.add_router("etl-tasks", etl_task_router) -api.add_router("etl-mappings", etl_mapping_router) +api.add_router("collections/etl-data-connections", data_connection_router) +api.add_router("collections/etl-tasks", etl_task_router) +api.add_router("collections/etl-mappings", etl_mapping_router) -api.add_router("data-product-rating-curves", rating_curve_router) -api.add_router("data-product-tasks", data_product_task_router) -api.add_router("data-product-transformations", data_product_transformation_router) +api.add_router("collections/data-product-rating-curves", rating_curve_router) +api.add_router("collections/data-product-tasks", data_product_task_router) +api.add_router("collections/data-product-transformations", data_product_transformation_router) -api.add_router("monitoring-tasks", monitoring_task_router) -api.add_router("monitoring-rules", monitoring_rule_router) +api.add_router("collections/monitoring-tasks", monitoring_task_router) +api.add_router("collections/monitoring-rules", monitoring_rule_router) -api.add_router("quality-control/histories", qc_history_router) +api.add_router("collections/quality-control-histories", qc_history_router) qc_history_router.add_router( - "/{history_id}/sessions", qc_session_router, tags=["Quality Control Sessions"] + "/items/{history_id}/sessions", qc_session_router, tags=["Quality Control Sessions"] ) qc_session_router.add_router( "/{session_id}/operations", qc_operation_router, tags=["Quality Control Operations"] ) urlpatterns = [ - path("data/", api.urls), + path("ogc/", api.urls), path("sensorthings/", include("sensorthings.versions.v1_1.urls")), ] diff --git a/django/interfaces/api/views/etl/data_connection.py b/django/interfaces/api/views/etl/data_connection.py index cc66f48f..107ead4e 100644 --- a/django/interfaces/api/views/etl/data_connection.py +++ b/django/interfaces/api/views/etl/data_connection.py @@ -22,7 +22,7 @@ @data_connection_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[DataConnectionResponse], @@ -49,7 +49,7 @@ def get_data_connections( @data_connection_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -87,7 +87,7 @@ def create_data_connection( @data_connection_router.get( - "/{data_connection_id}", + "/items/{data_connection_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[DataConnectionResponse], @@ -114,7 +114,7 @@ def get_data_connection( @data_connection_router.patch( - "/{data_connection_id}", + "/items/{data_connection_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -155,7 +155,7 @@ def update_data_connection( @data_connection_router.delete( - "/{data_connection_id}", + "/items/{data_connection_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/etl/mapping.py b/django/interfaces/api/views/etl/mapping.py index db4c981e..ac0501e7 100644 --- a/django/interfaces/api/views/etl/mapping.py +++ b/django/interfaces/api/views/etl/mapping.py @@ -19,7 +19,7 @@ @etl_mapping_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[EtlMappingResponse], @@ -46,7 +46,7 @@ def get_etl_mappings( @etl_mapping_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -70,7 +70,7 @@ def create_etl_mapping( @etl_mapping_router.get( - "/{mapping_id}", + "/items/{mapping_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[EtlMappingResponse], @@ -95,7 +95,7 @@ def get_etl_mapping( @etl_mapping_router.patch( - "/{mapping_id}", + "/items/{mapping_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -123,7 +123,7 @@ def update_etl_mapping( @etl_mapping_router.delete( - "/{mapping_id}", + "/items/{mapping_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/etl/task.py b/django/interfaces/api/views/etl/task.py index 88faa972..91cffa4f 100644 --- a/django/interfaces/api/views/etl/task.py +++ b/django/interfaces/api/views/etl/task.py @@ -22,7 +22,7 @@ @etl_task_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[EtlTaskResponse], @@ -50,7 +50,7 @@ def get_etl_tasks( @etl_task_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -73,7 +73,7 @@ def create_etl_task( @etl_task_router.get( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[EtlTaskResponse], @@ -98,7 +98,7 @@ def get_etl_task( @etl_task_router.patch( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -124,7 +124,7 @@ def update_etl_task( @etl_task_router.delete( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -151,7 +151,7 @@ def delete_etl_task( @etl_task_router.post( - "/{task_id}/trigger", + "/items/{task_id}/trigger", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 202: TaskRunResponse, @@ -180,7 +180,7 @@ def trigger_etl_task( @etl_task_router.get( - "/{task_id}/runs", + "/items/{task_id}/runs", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[TaskRunResponse], @@ -222,7 +222,7 @@ def get_etl_task_runs( @etl_task_router.get( - "/{task_id}/runs/{run_id}", + "/items/{task_id}/runs/{run_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: TaskRunResponse, diff --git a/django/interfaces/api/views/iam/role.py b/django/interfaces/api/views/iam/role.py index 62af2be4..46a89e28 100644 --- a/django/interfaces/api/views/iam/role.py +++ b/django/interfaces/api/views/iam/role.py @@ -18,7 +18,7 @@ @role_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[RoleResponse], @@ -44,7 +44,7 @@ def get_roles( @role_router.get( - "/{role_id}", + "/items/{role_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[RoleResponse], diff --git a/django/interfaces/api/views/iam/workspace.py b/django/interfaces/api/views/iam/workspace.py index 2a2133fe..bac3ed16 100644 --- a/django/interfaces/api/views/iam/workspace.py +++ b/django/interfaces/api/views/iam/workspace.py @@ -25,7 +25,7 @@ @workspace_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[WorkspaceResponse], @@ -53,7 +53,7 @@ def get_workspaces( @workspace_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -75,7 +75,7 @@ def create_workspace( @workspace_router.get( - "/{workspace_id}", + "/items/{workspace_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[WorkspaceResponse], @@ -101,7 +101,7 @@ def get_workspace( @workspace_router.patch( - "/{workspace_id}", + "/items/{workspace_id}", auth=[session_auth, oidc_auth, basic_auth], response={ 204: None, @@ -131,7 +131,7 @@ def update_workspace( @workspace_router.delete( - "/{workspace_id}", + "/items/{workspace_id}", auth=[session_auth, oidc_auth, basic_auth], response={ 204: None, @@ -150,7 +150,7 @@ def delete_workspace(request: HydroServerHttpRequest, workspace_id: Path[uuid.UU @workspace_router.post( - "/{workspace_id}/transfer", + "/items/{workspace_id}/transfer", auth=[session_auth, oidc_auth, basic_auth], response={ 201: str, @@ -176,7 +176,7 @@ def transfer_workspace( @workspace_router.put( - "/{workspace_id}/transfer", + "/items/{workspace_id}/transfer", auth=[session_auth, oidc_auth, basic_auth], response={ 200: str, @@ -200,7 +200,7 @@ def accept_workspace_transfer( @workspace_router.delete( - "/{workspace_id}/transfer", + "/items/{workspace_id}/transfer", auth=[session_auth, oidc_auth, basic_auth], response={ 200: str, @@ -224,8 +224,8 @@ def reject_workspace_transfer( workspace_router.add_router( - "{workspace_id}/collaborators", collaborator_router, tags=["Collaborators"] + "items/{workspace_id}/collaborators", collaborator_router, tags=["Collaborators"] ) workspace_router.add_router( - "{workspace_id}/service-accounts", service_account_router, tags=["Service Accounts"] + "items/{workspace_id}/service-accounts", service_account_router, tags=["Service Accounts"] ) diff --git a/django/interfaces/api/views/monitoring/rule.py b/django/interfaces/api/views/monitoring/rule.py index 2a4f3971..032ff305 100644 --- a/django/interfaces/api/views/monitoring/rule.py +++ b/django/interfaces/api/views/monitoring/rule.py @@ -19,7 +19,7 @@ @monitoring_rule_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[MonitoringRuleResponse], @@ -48,7 +48,7 @@ def get_monitoring_rules( @monitoring_rule_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_monitoring_rule( @monitoring_rule_router.get( - "/{rule_id}", + "/items/{rule_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[MonitoringRuleResponse], @@ -101,7 +101,7 @@ def get_monitoring_rule( @monitoring_rule_router.patch( - "/{rule_id}", + "/items/{rule_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -131,7 +131,7 @@ def update_monitoring_rule( @monitoring_rule_router.delete( - "/{rule_id}", + "/items/{rule_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/monitoring/task.py b/django/interfaces/api/views/monitoring/task.py index 2d5f50f2..618471a1 100644 --- a/django/interfaces/api/views/monitoring/task.py +++ b/django/interfaces/api/views/monitoring/task.py @@ -22,7 +22,7 @@ @monitoring_task_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[MonitoringTaskResponse], @@ -50,7 +50,7 @@ def get_monitoring_tasks( @monitoring_task_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -73,7 +73,7 @@ def create_monitoring_task( @monitoring_task_router.get( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[MonitoringTaskResponse], @@ -98,7 +98,7 @@ def get_monitoring_task( @monitoring_task_router.patch( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -124,7 +124,7 @@ def update_monitoring_task( @monitoring_task_router.delete( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -151,7 +151,7 @@ def delete_monitoring_task( @monitoring_task_router.post( - "/{task_id}/trigger", + "/items/{task_id}/trigger", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 202: TaskRunResponse, @@ -180,7 +180,7 @@ def trigger_monitoring_task( @monitoring_task_router.get( - "/{task_id}/runs", + "/items/{task_id}/runs", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[TaskRunResponse], @@ -222,7 +222,7 @@ def get_monitoring_task_runs( @monitoring_task_router.get( - "/{task_id}/runs/{run_id}", + "/items/{task_id}/runs/{run_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: TaskRunResponse, diff --git a/django/interfaces/api/views/products/rating_curve.py b/django/interfaces/api/views/products/rating_curve.py index 8fd15dc4..e10ec023 100644 --- a/django/interfaces/api/views/products/rating_curve.py +++ b/django/interfaces/api/views/products/rating_curve.py @@ -19,7 +19,7 @@ @rating_curve_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[RatingCurveResponse], @@ -46,7 +46,7 @@ def get_rating_curves( @rating_curve_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -72,7 +72,7 @@ def create_rating_curve( @rating_curve_router.get( - "/{rating_curve_id}", + "/items/{rating_curve_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[RatingCurveResponse], @@ -99,7 +99,7 @@ def get_rating_curve( @rating_curve_router.patch( - "/{rating_curve_id}", + "/items/{rating_curve_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_rating_curve( @rating_curve_router.delete( - "/{rating_curve_id}", + "/items/{rating_curve_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/products/task.py b/django/interfaces/api/views/products/task.py index 4e99a0a1..f128c822 100644 --- a/django/interfaces/api/views/products/task.py +++ b/django/interfaces/api/views/products/task.py @@ -22,7 +22,7 @@ @data_product_task_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[DataProductTaskResponse], @@ -50,7 +50,7 @@ def get_data_product_tasks( @data_product_task_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -73,7 +73,7 @@ def create_data_product_task( @data_product_task_router.get( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[DataProductTaskResponse], @@ -98,7 +98,7 @@ def get_data_product_task( @data_product_task_router.patch( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -126,7 +126,7 @@ def update_data_product_task( @data_product_task_router.delete( - "/{task_id}", + "/items/{task_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -153,7 +153,7 @@ def delete_data_product_task( @data_product_task_router.post( - "/{task_id}/trigger", + "/items/{task_id}/trigger", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 202: TaskRunResponse, @@ -182,7 +182,7 @@ def trigger_data_product_task( @data_product_task_router.get( - "/{task_id}/runs", + "/items/{task_id}/runs", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[TaskRunResponse], @@ -224,7 +224,7 @@ def get_data_product_task_runs( @data_product_task_router.get( - "/{task_id}/runs/{run_id}", + "/items/{task_id}/runs/{run_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: TaskRunResponse, diff --git a/django/interfaces/api/views/products/transformation.py b/django/interfaces/api/views/products/transformation.py index 5477599f..8b004a7c 100644 --- a/django/interfaces/api/views/products/transformation.py +++ b/django/interfaces/api/views/products/transformation.py @@ -19,7 +19,7 @@ @data_product_transformation_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[DataProductTransformationResponse], @@ -46,7 +46,7 @@ def get_data_product_transformations( @data_product_transformation_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -70,7 +70,7 @@ def create_data_product_transformation( @data_product_transformation_router.get( - "/{transformation_id}", + "/items/{transformation_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[DataProductTransformationResponse], @@ -95,7 +95,7 @@ def get_data_product_transformation( @data_product_transformation_router.patch( - "/{transformation_id}", + "/items/{transformation_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -123,7 +123,7 @@ def update_data_product_transformation( @data_product_transformation_router.delete( - "/{transformation_id}", + "/items/{transformation_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/quality/history.py b/django/interfaces/api/views/quality/history.py index 138e9cc1..d61729ea 100644 --- a/django/interfaces/api/views/quality/history.py +++ b/django/interfaces/api/views/quality/history.py @@ -18,7 +18,7 @@ @qc_history_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: PaginatedResponse[QualityControlHistoryResponse], @@ -44,7 +44,7 @@ def get_qc_histories( @qc_history_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={201: CreatedResponse, 400: str, 401: str, 403: str, 404: str}, by_alias=True, @@ -59,7 +59,7 @@ def create_qc_history( @qc_history_router.get( - "/{history_id}", + "/items/{history_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 200: ItemResponse[QualityControlHistoryResponse], @@ -82,7 +82,7 @@ def get_qc_history( @qc_history_router.delete( - "/{history_id}", + "/items/{history_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={204: None, 401: str, 403: str, 404: str}, by_alias=True, diff --git a/django/interfaces/api/views/sta/aggregation_statistic.py b/django/interfaces/api/views/sta/aggregation_statistic.py index 86c1349a..3a0205ff 100644 --- a/django/interfaces/api/views/sta/aggregation_statistic.py +++ b/django/interfaces/api/views/sta/aggregation_statistic.py @@ -22,7 +22,7 @@ @aggregation_statistic_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[AggregationStatisticResponse], @@ -49,7 +49,7 @@ def get_aggregation_statistics( @aggregation_statistic_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_aggregation_statistic( @aggregation_statistic_router.get( - "/{aggregation_statistic_id}", + "/items/{aggregation_statistic_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[AggregationStatisticResponse], @@ -101,7 +101,7 @@ def get_aggregation_statistic( @aggregation_statistic_router.patch( - "/{aggregation_statistic_id}", + "/items/{aggregation_statistic_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_aggregation_statistic( @aggregation_statistic_router.delete( - "/{aggregation_statistic_id}", + "/items/{aggregation_statistic_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/datastream.py b/django/interfaces/api/views/sta/datastream.py index 1933e672..a6e0c133 100644 --- a/django/interfaces/api/views/sta/datastream.py +++ b/django/interfaces/api/views/sta/datastream.py @@ -29,7 +29,7 @@ @datastream_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[DatastreamResponse], @@ -79,7 +79,7 @@ def get_datastream_visualization_bootstrap( @datastream_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -126,7 +126,7 @@ def get_datastream_tag_keys( @datastream_router.get( - "/{datastream_id}", + "/items/{datastream_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[DatastreamResponse], @@ -150,7 +150,7 @@ def get_datastream( @datastream_router.patch( - "/{datastream_id}", + "/items/{datastream_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -180,7 +180,7 @@ def update_datastream( @datastream_router.delete( - "/{datastream_id}", + "/items/{datastream_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -201,7 +201,7 @@ def delete_datastream(request: HydroServerHttpRequest, datastream_id: Path[uuid. @datastream_router.get( - "/{datastream_id}/linked-resources", + "/items/{datastream_id}/linked-resources", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: list[LinkedResourceGetResponse], @@ -227,7 +227,7 @@ def get_datastream_linked_resources( @datastream_router.post( - "/{datastream_id}/linked-resources", + "/items/{datastream_id}/linked-resources", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -265,7 +265,7 @@ def add_datastream_linked_resource( @datastream_router.patch( - "/{datastream_id}/linked-resources/{linked_resource_id}", + "/items/{datastream_id}/linked-resources/{linked_resource_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -307,7 +307,7 @@ def update_datastream_linked_resource( @datastream_router.delete( - "/{datastream_id}/linked-resources/{linked_resource_id}", + "/items/{datastream_id}/linked-resources/{linked_resource_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -334,7 +334,7 @@ def remove_datastream_linked_resource( @datastream_router.get( - "/{datastream_id}/csv", + "/items/{datastream_id}/csv", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={200: None, 403: str, 404: str}, ) diff --git a/django/interfaces/api/views/sta/datastream_status.py b/django/interfaces/api/views/sta/datastream_status.py index e7d463f7..67db7dbc 100644 --- a/django/interfaces/api/views/sta/datastream_status.py +++ b/django/interfaces/api/views/sta/datastream_status.py @@ -22,7 +22,7 @@ @datastream_status_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[DatastreamStatusResponse], @@ -49,7 +49,7 @@ def get_datastream_statuses( @datastream_status_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_datastream_status( @datastream_status_router.get( - "/{datastream_status_id}", + "/items/{datastream_status_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[DatastreamStatusResponse], @@ -101,7 +101,7 @@ def get_datastream_status( @datastream_status_router.patch( - "/{datastream_status_id}", + "/items/{datastream_status_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_datastream_status( @datastream_status_router.delete( - "/{datastream_status_id}", + "/items/{datastream_status_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/linked_resource_type.py b/django/interfaces/api/views/sta/linked_resource_type.py index e2438fb2..8cd0744d 100644 --- a/django/interfaces/api/views/sta/linked_resource_type.py +++ b/django/interfaces/api/views/sta/linked_resource_type.py @@ -22,7 +22,7 @@ @linked_resource_type_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[LinkedResourceTypeResponse], @@ -49,7 +49,7 @@ def get_linked_resource_types( @linked_resource_type_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_linked_resource_type( @linked_resource_type_router.get( - "/{linked_resource_type_id}", + "/items/{linked_resource_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[LinkedResourceTypeResponse], @@ -101,7 +101,7 @@ def get_linked_resource_type( @linked_resource_type_router.patch( - "/{linked_resource_type_id}", + "/items/{linked_resource_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_linked_resource_type( @linked_resource_type_router.delete( - "/{linked_resource_type_id}", + "/items/{linked_resource_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/method.py b/django/interfaces/api/views/sta/method.py index bc9b9d97..547542b4 100644 --- a/django/interfaces/api/views/sta/method.py +++ b/django/interfaces/api/views/sta/method.py @@ -22,7 +22,7 @@ @method_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[MethodResponse], @@ -50,7 +50,7 @@ def get_methods( @method_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -76,7 +76,7 @@ def create_method( @method_router.get( - "/{method_id}", + "/items/{method_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[MethodResponse], @@ -104,7 +104,7 @@ def get_method( @method_router.patch( - "/{method_id}", + "/items/{method_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -134,7 +134,7 @@ def update_method( @method_router.delete( - "/{method_id}", + "/items/{method_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/method_type.py b/django/interfaces/api/views/sta/method_type.py index 66894cea..1232167e 100644 --- a/django/interfaces/api/views/sta/method_type.py +++ b/django/interfaces/api/views/sta/method_type.py @@ -22,7 +22,7 @@ @method_type_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[MethodTypeResponse], @@ -49,7 +49,7 @@ def get_method_types( @method_type_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_method_type( @method_type_router.get( - "/{method_type_id}", + "/items/{method_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[MethodTypeResponse], @@ -101,7 +101,7 @@ def get_method_type( @method_type_router.patch( - "/{method_type_id}", + "/items/{method_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_method_type( @method_type_router.delete( - "/{method_type_id}", + "/items/{method_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/monitoring_site.py b/django/interfaces/api/views/sta/monitoring_site.py index c0549ed3..0d90c634 100644 --- a/django/interfaces/api/views/sta/monitoring_site.py +++ b/django/interfaces/api/views/sta/monitoring_site.py @@ -35,7 +35,7 @@ @monitoring_site_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[MonitoringSiteResponse], @@ -132,7 +132,7 @@ def get_monitoring_site_task_summaries( @monitoring_site_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -191,7 +191,7 @@ def get_site_type_icons(request: HydroServerHttpRequest): @monitoring_site_router.get( - "/{monitoring_site_id}", + "/items/{monitoring_site_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[MonitoringSiteResponse], @@ -215,7 +215,7 @@ def get_monitoring_site( @monitoring_site_router.patch( - "/{monitoring_site_id}", + "/items/{monitoring_site_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -245,7 +245,7 @@ def update_monitoring_site( @monitoring_site_router.delete( - "/{monitoring_site_id}", + "/items/{monitoring_site_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -264,7 +264,7 @@ def delete_monitoring_site(request: HydroServerHttpRequest, monitoring_site_id: @monitoring_site_router.get( - "/{monitoring_site_id}/linked-resources", + "/items/{monitoring_site_id}/linked-resources", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: list[LinkedResourceGetResponse], @@ -290,7 +290,7 @@ def get_monitoring_site_linked_resources( @monitoring_site_router.post( - "/{monitoring_site_id}/linked-resources", + "/items/{monitoring_site_id}/linked-resources", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -328,7 +328,7 @@ def add_monitoring_site_linked_resource( @monitoring_site_router.patch( - "/{monitoring_site_id}/linked-resources/{linked_resource_id}", + "/items/{monitoring_site_id}/linked-resources/{linked_resource_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -370,7 +370,7 @@ def update_monitoring_site_linked_resource( @monitoring_site_router.delete( - "/{monitoring_site_id}/linked-resources/{linked_resource_id}", + "/items/{monitoring_site_id}/linked-resources/{linked_resource_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/monitoring_site_type.py b/django/interfaces/api/views/sta/monitoring_site_type.py index e0634dd2..2e770bc6 100644 --- a/django/interfaces/api/views/sta/monitoring_site_type.py +++ b/django/interfaces/api/views/sta/monitoring_site_type.py @@ -22,7 +22,7 @@ @monitoring_site_type_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[MonitoringSiteTypeResponse], @@ -49,7 +49,7 @@ def get_monitoring_site_types( @monitoring_site_type_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_monitoring_site_type( @monitoring_site_type_router.get( - "/{monitoring_site_type_id}", + "/items/{monitoring_site_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[MonitoringSiteTypeResponse], @@ -101,7 +101,7 @@ def get_monitoring_site_type( @monitoring_site_type_router.patch( - "/{monitoring_site_type_id}", + "/items/{monitoring_site_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_monitoring_site_type( @monitoring_site_type_router.delete( - "/{monitoring_site_type_id}", + "/items/{monitoring_site_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/observation.py b/django/interfaces/api/views/sta/observation.py index d96d11f2..4176175d 100644 --- a/django/interfaces/api/views/sta/observation.py +++ b/django/interfaces/api/views/sta/observation.py @@ -28,7 +28,7 @@ @observation_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[ObservationResponse] @@ -62,7 +62,7 @@ def get_observations( @observation_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -131,7 +131,7 @@ def delete_observations( @observation_router.get( - "/{observation_id}", + "/items/{observation_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[ObservationResponse], @@ -157,7 +157,7 @@ def get_observation( @observation_router.delete( - "/{observation_id}", + "/items/{observation_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/observed_property.py b/django/interfaces/api/views/sta/observed_property.py index 2102a526..7480c57e 100644 --- a/django/interfaces/api/views/sta/observed_property.py +++ b/django/interfaces/api/views/sta/observed_property.py @@ -22,7 +22,7 @@ @observed_property_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[ObservedPropertyResponse], @@ -50,7 +50,7 @@ def get_observed_properties( @observed_property_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -76,7 +76,7 @@ def create_observed_property( @observed_property_router.get( - "/{observed_property_id}", + "/items/{observed_property_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[ObservedPropertyResponse], @@ -104,7 +104,7 @@ def get_observed_property( @observed_property_router.patch( - "/{observed_property_id}", + "/items/{observed_property_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -134,7 +134,7 @@ def update_observed_property( @observed_property_router.delete( - "/{observed_property_id}", + "/items/{observed_property_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/observed_property_type.py b/django/interfaces/api/views/sta/observed_property_type.py index e686f4dc..d4425e55 100644 --- a/django/interfaces/api/views/sta/observed_property_type.py +++ b/django/interfaces/api/views/sta/observed_property_type.py @@ -22,7 +22,7 @@ @observed_property_type_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[ObservedPropertyTypeResponse], @@ -49,7 +49,7 @@ def get_observed_property_types( @observed_property_type_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_observed_property_type( @observed_property_type_router.get( - "/{observed_property_type_id}", + "/items/{observed_property_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[ObservedPropertyTypeResponse], @@ -101,7 +101,7 @@ def get_observed_property_type( @observed_property_type_router.patch( - "/{observed_property_type_id}", + "/items/{observed_property_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_observed_property_type( @observed_property_type_router.delete( - "/{observed_property_type_id}", + "/items/{observed_property_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/processing_level.py b/django/interfaces/api/views/sta/processing_level.py index 388e3f34..b15c0797 100644 --- a/django/interfaces/api/views/sta/processing_level.py +++ b/django/interfaces/api/views/sta/processing_level.py @@ -22,7 +22,7 @@ @processing_level_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[ProcessingLevelResponse], @@ -50,7 +50,7 @@ def get_processing_levels( @processing_level_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -75,7 +75,7 @@ def create_processing_level( @processing_level_router.get( - "/{processing_level_id}", + "/items/{processing_level_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[ProcessingLevelResponse], @@ -103,7 +103,7 @@ def get_processing_level( @processing_level_router.patch( - "/{processing_level_id}", + "/items/{processing_level_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -133,7 +133,7 @@ def update_processing_level( @processing_level_router.delete( - "/{processing_level_id}", + "/items/{processing_level_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/result_qualifier.py b/django/interfaces/api/views/sta/result_qualifier.py index 88874554..771cc771 100644 --- a/django/interfaces/api/views/sta/result_qualifier.py +++ b/django/interfaces/api/views/sta/result_qualifier.py @@ -24,7 +24,7 @@ @result_qualifier_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[ResultQualifierResponse], @@ -52,7 +52,7 @@ def get_result_qualifiers( @result_qualifier_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -77,7 +77,7 @@ def create_result_qualifier( @result_qualifier_router.get( - "/{result_qualifier_id}", + "/items/{result_qualifier_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[ResultQualifierResponse], @@ -105,7 +105,7 @@ def get_result_qualifier( @result_qualifier_router.patch( - "/{result_qualifier_id}", + "/items/{result_qualifier_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -134,7 +134,7 @@ def update_result_qualifier( @result_qualifier_router.delete( - "/{result_qualifier_id}", + "/items/{result_qualifier_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/sampled_medium.py b/django/interfaces/api/views/sta/sampled_medium.py index dce84f5a..949cfbb9 100644 --- a/django/interfaces/api/views/sta/sampled_medium.py +++ b/django/interfaces/api/views/sta/sampled_medium.py @@ -22,7 +22,7 @@ @sampled_medium_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[SampledMediumResponse], @@ -49,7 +49,7 @@ def get_sampled_mediums( @sampled_medium_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_sampled_medium( @sampled_medium_router.get( - "/{sampled_medium_id}", + "/items/{sampled_medium_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[SampledMediumResponse], @@ -101,7 +101,7 @@ def get_sampled_medium( @sampled_medium_router.patch( - "/{sampled_medium_id}", + "/items/{sampled_medium_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_sampled_medium( @sampled_medium_router.delete( - "/{sampled_medium_id}", + "/items/{sampled_medium_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/unit.py b/django/interfaces/api/views/sta/unit.py index 58504b76..3e7e71ef 100644 --- a/django/interfaces/api/views/sta/unit.py +++ b/django/interfaces/api/views/sta/unit.py @@ -22,7 +22,7 @@ @unit_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[UnitResponse], @@ -50,7 +50,7 @@ def get_units( @unit_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -75,7 +75,7 @@ def create_unit( @unit_router.get( - "/{unit_id}", + "/items/{unit_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[UnitResponse], @@ -103,7 +103,7 @@ def get_unit( @unit_router.patch( - "/{unit_id}", + "/items/{unit_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -133,7 +133,7 @@ def update_unit( @unit_router.delete( - "/{unit_id}", + "/items/{unit_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/interfaces/api/views/sta/unit_type.py b/django/interfaces/api/views/sta/unit_type.py index df921ce3..bf712a62 100644 --- a/django/interfaces/api/views/sta/unit_type.py +++ b/django/interfaces/api/views/sta/unit_type.py @@ -22,7 +22,7 @@ @unit_type_router.get( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: PaginatedResponse[UnitTypeResponse], @@ -49,7 +49,7 @@ def get_unit_types( @unit_type_router.post( - "", + "/items", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 201: CreatedResponse, @@ -74,7 +74,7 @@ def create_unit_type( @unit_type_router.get( - "/{unit_type_id}", + "/items/{unit_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ 200: ItemResponse[UnitTypeResponse], @@ -101,7 +101,7 @@ def get_unit_type( @unit_type_router.patch( - "/{unit_type_id}", + "/items/{unit_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, @@ -129,7 +129,7 @@ def update_unit_type( @unit_type_router.delete( - "/{unit_type_id}", + "/items/{unit_type_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ 204: None, diff --git a/django/tests/hydroserver/test_oidc_auth.py b/django/tests/hydroserver/test_oidc_auth.py index a93f5b20..6d4cc657 100644 --- a/django/tests/hydroserver/test_oidc_auth.py +++ b/django/tests/hydroserver/test_oidc_auth.py @@ -45,7 +45,7 @@ def _access_token(oidc_client, user, value, scopes=None, expires_at=None): def _bearer_request(token_value=None): - request = RequestFactory().get("/api/data/workspaces") + request = RequestFactory().get("/api/ogc/collections/workspaces/items") if token_value is not None: request.META["HTTP_AUTHORIZATION"] = f"Bearer {token_value}" return request diff --git a/django/tests/interfaces/api/test_aggregation_statistic_views.py b/django/tests/interfaces/api/test_aggregation_statistic_views.py index 5b91771b..52cf58f5 100644 --- a/django/tests/interfaces/api/test_aggregation_statistic_views.py +++ b/django/tests/interfaces/api/test_aggregation_statistic_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -AGGREGATION_STATISTICS_URL = "/api/data/aggregation-statistics" +AGGREGATION_STATISTICS_URL = "/api/ogc/collections/aggregation-statistics/items" def _detail_url(aggregation_statistic_id): diff --git a/django/tests/interfaces/api/test_collaborator_views.py b/django/tests/interfaces/api/test_collaborator_views.py index 76ce21b9..7c38144c 100644 --- a/django/tests/interfaces/api/test_collaborator_views.py +++ b/django/tests/interfaces/api/test_collaborator_views.py @@ -15,7 +15,7 @@ def _collaborators_url(workspace_id): - return f"/api/data/workspaces/{workspace_id}/collaborators" + return f"/api/ogc/collections/workspaces/items/{workspace_id}/collaborators" def _collaborator_with_permission(workspace, resource_type, **permissions): diff --git a/django/tests/interfaces/api/test_data_connection_views.py b/django/tests/interfaces/api/test_data_connection_views.py index d6f67754..6463414d 100644 --- a/django/tests/interfaces/api/test_data_connection_views.py +++ b/django/tests/interfaces/api/test_data_connection_views.py @@ -13,7 +13,7 @@ pytestmark = pytest.mark.django_db -DATA_CONNECTIONS_URL = "/api/data/etl-data-connections" +DATA_CONNECTIONS_URL = "/api/ogc/collections/etl-data-connections/items" def _detail_url(data_connection_id): diff --git a/django/tests/interfaces/api/test_data_product_task_views.py b/django/tests/interfaces/api/test_data_product_task_views.py index bc268fe8..93244349 100644 --- a/django/tests/interfaces/api/test_data_product_task_views.py +++ b/django/tests/interfaces/api/test_data_product_task_views.py @@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db -DATA_PRODUCT_TASKS_URL = "/api/data/data-product-tasks" +DATA_PRODUCT_TASKS_URL = "/api/ogc/collections/data-product-tasks/items" def _detail_url(task_id): diff --git a/django/tests/interfaces/api/test_data_product_transformation_views.py b/django/tests/interfaces/api/test_data_product_transformation_views.py index c7c865a4..9764e5c1 100644 --- a/django/tests/interfaces/api/test_data_product_transformation_views.py +++ b/django/tests/interfaces/api/test_data_product_transformation_views.py @@ -20,7 +20,7 @@ pytestmark = pytest.mark.django_db -TRANSFORMATIONS_URL = "/api/data/data-product-transformations" +TRANSFORMATIONS_URL = "/api/ogc/collections/data-product-transformations/items" TRANSFORMATION_FIELDS = { "id", diff --git a/django/tests/interfaces/api/test_datastream_status_views.py b/django/tests/interfaces/api/test_datastream_status_views.py index 6d6f174b..8b4ce8cd 100644 --- a/django/tests/interfaces/api/test_datastream_status_views.py +++ b/django/tests/interfaces/api/test_datastream_status_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -DATASTREAM_STATUSES_URL = "/api/data/datastream-statuses" +DATASTREAM_STATUSES_URL = "/api/ogc/collections/datastream-statuses/items" def _detail_url(datastream_status_id): diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index e09f3ec1..badd7b91 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -26,7 +26,8 @@ pytestmark = pytest.mark.django_db -DATASTREAMS_URL = "/api/data/datastreams" +DATASTREAMS_COLLECTION_URL = "/api/ogc/collections/datastreams" +DATASTREAMS_URL = f"{DATASTREAMS_COLLECTION_URL}/items" def _detail_url(datastream_id): @@ -692,7 +693,7 @@ def test_get_datastream_visualization_bootstrap_returns_public_datastream(client ), ) - response = client.get(f"{DATASTREAMS_URL}/visualization-bootstrap") + response = client.get(f"{DATASTREAMS_COLLECTION_URL}/visualization-bootstrap") assert response.status_code == 200 body = response.json() @@ -715,7 +716,7 @@ def test_get_datastream_tag_keys_returns_keys_for_workspace_owner(client): datastream.save() client.force_login(owner) - response = client.get(f"{DATASTREAMS_URL}/tags/keys") + response = client.get(f"{DATASTREAMS_COLLECTION_URL}/tags/keys") assert response.status_code == 200 assert response.json()["season"] == ["summer"] diff --git a/django/tests/interfaces/api/test_etl_mapping_views.py b/django/tests/interfaces/api/test_etl_mapping_views.py index 0c17df76..0b85bf86 100644 --- a/django/tests/interfaces/api/test_etl_mapping_views.py +++ b/django/tests/interfaces/api/test_etl_mapping_views.py @@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db -MAPPINGS_URL = "/api/data/etl-mappings" +MAPPINGS_URL = "/api/ogc/collections/etl-mappings/items" MAPPING_FIELDS = {"id", "sourceIdentifier", "targetDatastreamId", "etlTaskId"} diff --git a/django/tests/interfaces/api/test_etl_task_views.py b/django/tests/interfaces/api/test_etl_task_views.py index d5a0ef23..45a80429 100644 --- a/django/tests/interfaces/api/test_etl_task_views.py +++ b/django/tests/interfaces/api/test_etl_task_views.py @@ -18,7 +18,7 @@ pytestmark = pytest.mark.django_db -ETL_TASKS_URL = "/api/data/etl-tasks" +ETL_TASKS_URL = "/api/ogc/collections/etl-tasks/items" def _detail_url(task_id): diff --git a/django/tests/interfaces/api/test_linked_resource_type_views.py b/django/tests/interfaces/api/test_linked_resource_type_views.py index d1017f24..ac1aa864 100644 --- a/django/tests/interfaces/api/test_linked_resource_type_views.py +++ b/django/tests/interfaces/api/test_linked_resource_type_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -LINKED_RESOURCE_TYPES_URL = "/api/data/linked-resource-types" +LINKED_RESOURCE_TYPES_URL = "/api/ogc/collections/linked-resource-types/items" def _detail_url(linked_resource_type_id): diff --git a/django/tests/interfaces/api/test_method_type_views.py b/django/tests/interfaces/api/test_method_type_views.py index a4225849..1afb73d5 100644 --- a/django/tests/interfaces/api/test_method_type_views.py +++ b/django/tests/interfaces/api/test_method_type_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -METHOD_TYPES_URL = "/api/data/method-types" +METHOD_TYPES_URL = "/api/ogc/collections/method-types/items" def _detail_url(method_type_id): diff --git a/django/tests/interfaces/api/test_method_views.py b/django/tests/interfaces/api/test_method_views.py index 705448fe..5fd80150 100644 --- a/django/tests/interfaces/api/test_method_views.py +++ b/django/tests/interfaces/api/test_method_views.py @@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db -METHODS_URL = "/api/data/methods" +METHODS_URL = "/api/ogc/collections/methods/items" def _detail_url(method_id): diff --git a/django/tests/interfaces/api/test_monitoring_rule_views.py b/django/tests/interfaces/api/test_monitoring_rule_views.py index 6a144f91..3a48b897 100644 --- a/django/tests/interfaces/api/test_monitoring_rule_views.py +++ b/django/tests/interfaces/api/test_monitoring_rule_views.py @@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db -RULES_URL = "/api/data/monitoring-rules" +RULES_URL = "/api/ogc/collections/monitoring-rules/items" MONITORING_RULE_FIELDS = { "id", diff --git a/django/tests/interfaces/api/test_monitoring_site_type_views.py b/django/tests/interfaces/api/test_monitoring_site_type_views.py index 50d822b4..dffbbd4f 100644 --- a/django/tests/interfaces/api/test_monitoring_site_type_views.py +++ b/django/tests/interfaces/api/test_monitoring_site_type_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -MONITORING_SITE_TYPES_URL = "/api/data/monitoring-site-types" +MONITORING_SITE_TYPES_URL = "/api/ogc/collections/monitoring-site-types/items" def _detail_url(monitoring_site_type_id): diff --git a/django/tests/interfaces/api/test_monitoring_site_views.py b/django/tests/interfaces/api/test_monitoring_site_views.py index cb63b77c..a70398f6 100644 --- a/django/tests/interfaces/api/test_monitoring_site_views.py +++ b/django/tests/interfaces/api/test_monitoring_site_views.py @@ -15,7 +15,8 @@ pytestmark = pytest.mark.django_db -MONITORING_SITES_URL = "/api/data/monitoring-sites" +MONITORING_SITES_COLLECTION_URL = "/api/ogc/collections/monitoring-sites" +MONITORING_SITES_URL = f"{MONITORING_SITES_COLLECTION_URL}/items" def _detail_url(monitoring_site_id): @@ -277,7 +278,7 @@ def test_create_monitoring_site_returns_403_without_create_permission(client): def test_get_site_type_icons_returns_configured_icon_mappings(client): - response = client.get(f"{MONITORING_SITES_URL}/site-type-icons") + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/site-type-icons") assert response.status_code == 200 icons = {entry["icon"] for entry in response.json()} @@ -682,14 +683,14 @@ def test_get_monitoring_site_markers_returns_marker_for_public_monitoring_site(c workspace = WorkspaceFactory() monitoring_site = MonitoringSiteFactory(workspace=workspace) - response = client.get(f"{MONITORING_SITES_URL}/markers") + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/markers") assert response.status_code == 200 assert str(monitoring_site.id) in [m["id"] for m in response.json()] def test_get_monitoring_site_markers_returns_400_for_malformed_bbox(client): - response = client.get(f"{MONITORING_SITES_URL}/markers", {"bbox": "not,a,valid,bbox"}) + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/markers", {"bbox": "not,a,valid,bbox"}) assert response.status_code == 400 @@ -698,7 +699,7 @@ def test_get_monitoring_site_site_summaries_returns_summary_for_public_monitorin workspace = WorkspaceFactory() monitoring_site = MonitoringSiteFactory(workspace=workspace) - response = client.get(f"{MONITORING_SITES_URL}/site-summaries") + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/site-summaries") assert response.status_code == 200 assert str(monitoring_site.id) in [s["id"] for s in response.json()] @@ -710,7 +711,7 @@ def test_get_monitoring_site_task_summaries_returns_summary_for_workspace_owner( monitoring_site = MonitoringSiteFactory(workspace=workspace) client.force_login(owner) - response = client.get(f"{MONITORING_SITES_URL}/task-summaries") + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/task-summaries") assert response.status_code == 200 summary = next(s for s in response.json() if s["id"] == str(monitoring_site.id)) @@ -726,7 +727,7 @@ def test_get_monitoring_site_tag_keys_returns_keys_for_workspace_owner(client): monitoring_site.save() client.force_login(owner) - response = client.get(f"{MONITORING_SITES_URL}/tags/keys") + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/tags/keys") assert response.status_code == 200 assert response.json()["season"] == ["summer"] diff --git a/django/tests/interfaces/api/test_monitoring_task_views.py b/django/tests/interfaces/api/test_monitoring_task_views.py index dd08fffc..0c39ba5c 100644 --- a/django/tests/interfaces/api/test_monitoring_task_views.py +++ b/django/tests/interfaces/api/test_monitoring_task_views.py @@ -17,7 +17,7 @@ pytestmark = pytest.mark.django_db -MONITORING_TASKS_URL = "/api/data/monitoring-tasks" +MONITORING_TASKS_URL = "/api/ogc/collections/monitoring-tasks/items" def _detail_url(task_id): diff --git a/django/tests/interfaces/api/test_observation_views.py b/django/tests/interfaces/api/test_observation_views.py index d98f058b..e37d7a39 100644 --- a/django/tests/interfaces/api/test_observation_views.py +++ b/django/tests/interfaces/api/test_observation_views.py @@ -29,16 +29,16 @@ def _observations_url(datastream_id=None, **params): if datastream_id is not None: query["datastream_id"] = datastream_id if query: - return f"/api/data/observations?{urlencode(query, doseq=True)}" - return "/api/data/observations" + return f"/api/ogc/collections/observations/items?{urlencode(query, doseq=True)}" + return "/api/ogc/collections/observations/items" def _detail_url(observation_id): - return f"/api/data/observations/{observation_id}" + return f"/api/ogc/collections/observations/items/{observation_id}" -_BULK_CREATE_URL = "/api/data/observations/bulk-create" -_BULK_DELETE_URL = "/api/data/observations/bulk-delete" +_BULK_CREATE_URL = "/api/ogc/collections/observations/bulk-create" +_BULK_DELETE_URL = "/api/ogc/collections/observations/bulk-delete" def _with_datastream(datastream_id, body): @@ -152,7 +152,7 @@ def test_get_observations_with_multiple_datastream_ids_unions_results(client): client.force_login(owner) response = client.get( - f"/api/data/observations?{urlencode({'datastream_id': [str(datastream_a.id), str(datastream_b.id)]}, doseq=True)}" + f"/api/ogc/collections/observations/items?{urlencode({'datastream_id': [str(datastream_a.id), str(datastream_b.id)]}, doseq=True)}" ) assert response.status_code == 200 @@ -198,7 +198,7 @@ def test_get_observations_column_format_returns_400_with_multiple_datastream_ids datastream_b = _make_datastream(workspace) response = client.get( - f"/api/data/observations?{urlencode({'datastream_id': [str(datastream_a.id), str(datastream_b.id)], 'format': 'column'}, doseq=True)}" + f"/api/ogc/collections/observations/items?{urlencode({'datastream_id': [str(datastream_a.id), str(datastream_b.id)], 'format': 'column'}, doseq=True)}" ) assert response.status_code == 400 @@ -285,7 +285,7 @@ def test_get_observations_default_order_groups_by_datastream_then_time(client): client.force_login(owner) response = client.get( - f"/api/data/observations?{urlencode({'datastream_id': [str(datastream_a.id), str(datastream_b.id)]}, doseq=True)}" + f"/api/ogc/collections/observations/items?{urlencode({'datastream_id': [str(datastream_a.id), str(datastream_b.id)]}, doseq=True)}" ) assert response.status_code == 200 diff --git a/django/tests/interfaces/api/test_observed_property_type_views.py b/django/tests/interfaces/api/test_observed_property_type_views.py index 79b00f82..fb5f6ba4 100644 --- a/django/tests/interfaces/api/test_observed_property_type_views.py +++ b/django/tests/interfaces/api/test_observed_property_type_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -OBSERVED_PROPERTY_TYPES_URL = "/api/data/observed-property-types" +OBSERVED_PROPERTY_TYPES_URL = "/api/ogc/collections/observed-property-types/items" def _detail_url(observed_property_type_id): diff --git a/django/tests/interfaces/api/test_observed_property_views.py b/django/tests/interfaces/api/test_observed_property_views.py index 82ff4eb6..aa4f9f8d 100644 --- a/django/tests/interfaces/api/test_observed_property_views.py +++ b/django/tests/interfaces/api/test_observed_property_views.py @@ -18,7 +18,7 @@ pytestmark = pytest.mark.django_db -OBSERVED_PROPERTIES_URL = "/api/data/observed-properties" +OBSERVED_PROPERTIES_URL = "/api/ogc/collections/observed-properties/items" def _detail_url(observed_property_id): diff --git a/django/tests/interfaces/api/test_processing_level_views.py b/django/tests/interfaces/api/test_processing_level_views.py index e5570378..588f782b 100644 --- a/django/tests/interfaces/api/test_processing_level_views.py +++ b/django/tests/interfaces/api/test_processing_level_views.py @@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db -PROCESSING_LEVELS_URL = "/api/data/processing-levels" +PROCESSING_LEVELS_URL = "/api/ogc/collections/processing-levels/items" def _detail_url(processing_level_id): diff --git a/django/tests/interfaces/api/test_qc_history_views.py b/django/tests/interfaces/api/test_qc_history_views.py index a7cff45b..d6d3ad25 100644 --- a/django/tests/interfaces/api/test_qc_history_views.py +++ b/django/tests/interfaces/api/test_qc_history_views.py @@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db -QC_HISTORIES_URL = "/api/data/quality-control/histories" +QC_HISTORIES_URL = "/api/ogc/collections/quality-control-histories/items" def _detail_url(history_id): diff --git a/django/tests/interfaces/api/test_qc_operation_views.py b/django/tests/interfaces/api/test_qc_operation_views.py index 929490fa..6382eb87 100644 --- a/django/tests/interfaces/api/test_qc_operation_views.py +++ b/django/tests/interfaces/api/test_qc_operation_views.py @@ -16,7 +16,7 @@ pytestmark = pytest.mark.django_db -QC_HISTORIES_URL = "/api/data/quality-control/histories" +QC_HISTORIES_URL = "/api/ogc/collections/quality-control-histories/items" def _operations_url(history_id, session_id): diff --git a/django/tests/interfaces/api/test_qc_session_views.py b/django/tests/interfaces/api/test_qc_session_views.py index 2db3aaaa..beab4f5f 100644 --- a/django/tests/interfaces/api/test_qc_session_views.py +++ b/django/tests/interfaces/api/test_qc_session_views.py @@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db -QC_HISTORIES_URL = "/api/data/quality-control/histories" +QC_HISTORIES_URL = "/api/ogc/collections/quality-control-histories/items" def _sessions_url(history_id): diff --git a/django/tests/interfaces/api/test_rating_curve_views.py b/django/tests/interfaces/api/test_rating_curve_views.py index f575b5e1..c2cc74b7 100644 --- a/django/tests/interfaces/api/test_rating_curve_views.py +++ b/django/tests/interfaces/api/test_rating_curve_views.py @@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db -RATING_CURVES_URL = "/api/data/data-product-rating-curves" +RATING_CURVES_URL = "/api/ogc/collections/data-product-rating-curves/items" RATING_CURVE_FIELDS = { "id", diff --git a/django/tests/interfaces/api/test_result_qualifier_views.py b/django/tests/interfaces/api/test_result_qualifier_views.py index d1d6512d..6d30a8bc 100644 --- a/django/tests/interfaces/api/test_result_qualifier_views.py +++ b/django/tests/interfaces/api/test_result_qualifier_views.py @@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db -RESULT_QUALIFIERS_URL = "/api/data/result-qualifiers" +RESULT_QUALIFIERS_URL = "/api/ogc/collections/result-qualifiers/items" def _detail_url(result_qualifier_id): diff --git a/django/tests/interfaces/api/test_role_views.py b/django/tests/interfaces/api/test_role_views.py index 5f06f9fc..23333ff1 100644 --- a/django/tests/interfaces/api/test_role_views.py +++ b/django/tests/interfaces/api/test_role_views.py @@ -9,7 +9,7 @@ pytestmark = pytest.mark.django_db -ROLES_URL = "/api/data/roles" +ROLES_URL = "/api/ogc/collections/roles/items" def _detail_url(role_id): diff --git a/django/tests/interfaces/api/test_sampled_medium_views.py b/django/tests/interfaces/api/test_sampled_medium_views.py index a792fb59..7c0bbe58 100644 --- a/django/tests/interfaces/api/test_sampled_medium_views.py +++ b/django/tests/interfaces/api/test_sampled_medium_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -SAMPLED_MEDIUMS_URL = "/api/data/sampled-mediums" +SAMPLED_MEDIUMS_URL = "/api/ogc/collections/sampled-mediums/items" def _detail_url(sampled_medium_id): diff --git a/django/tests/interfaces/api/test_service_account_views.py b/django/tests/interfaces/api/test_service_account_views.py index 84f1c5a5..2fe9ba03 100644 --- a/django/tests/interfaces/api/test_service_account_views.py +++ b/django/tests/interfaces/api/test_service_account_views.py @@ -30,7 +30,7 @@ def _service_accounts_url(workspace_id): - return f"/api/data/workspaces/{workspace_id}/service-accounts" + return f"/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts" def _detail_url(workspace_id, service_account_id): diff --git a/django/tests/interfaces/api/test_unit_type_views.py b/django/tests/interfaces/api/test_unit_type_views.py index 6df13639..b668f96f 100644 --- a/django/tests/interfaces/api/test_unit_type_views.py +++ b/django/tests/interfaces/api/test_unit_type_views.py @@ -5,7 +5,7 @@ pytestmark = pytest.mark.django_db -UNIT_TYPES_URL = "/api/data/unit-types" +UNIT_TYPES_URL = "/api/ogc/collections/unit-types/items" def _detail_url(unit_type_id): diff --git a/django/tests/interfaces/api/test_unit_views.py b/django/tests/interfaces/api/test_unit_views.py index 66be0448..4eaed4ab 100644 --- a/django/tests/interfaces/api/test_unit_views.py +++ b/django/tests/interfaces/api/test_unit_views.py @@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db -UNITS_URL = "/api/data/units" +UNITS_URL = "/api/ogc/collections/units/items" def _detail_url(unit_id): diff --git a/django/tests/interfaces/api/test_workspace_metadata.py b/django/tests/interfaces/api/test_workspace_metadata.py index 4d7c8c37..afe0095a 100644 --- a/django/tests/interfaces/api/test_workspace_metadata.py +++ b/django/tests/interfaces/api/test_workspace_metadata.py @@ -25,7 +25,7 @@ def owner_workspace(client): @pytest.mark.parametrize("resource,fields", METADATA) def test_metadata_optional_fields_and_limits(client, owner_workspace, resource, fields): - url = f"/api/data/{resource}" + url = f"/api/ogc/collections/{resource}/items" body = {**fields, "name": "N" * 255, "workspaceId": str(owner_workspace.id)} response = client.post(url, data=body, content_type="application/json") assert response.status_code == 201, response.content @@ -76,11 +76,11 @@ def test_metadata_optional_fields_and_limits(client, owner_workspace, resource, @pytest.mark.parametrize("resource,fields", METADATA) def test_metadata_requires_name_and_description(client, owner_workspace, resource, fields): body = {**fields, "workspaceId": str(owner_workspace.id)} - assert client.post(f"/api/data/{resource}", data=body, content_type="application/json").status_code == 400 + assert client.post(f"/api/ogc/collections/{resource}/items", data=body, content_type="application/json").status_code == 400 if resource != "units": body["name"] = "Name" del body["description"] - assert client.post(f"/api/data/{resource}", data=body, content_type="application/json").status_code == 400 + assert client.post(f"/api/ogc/collections/{resource}/items", data=body, content_type="application/json").status_code == 400 @pytest.mark.parametrize("global_", [False, True]) @@ -106,9 +106,9 @@ def test_observation_codes_use_qualifier_names(client, owner_workspace, global_) "datastreamId": str(datastream.id), "phenomenonTime": "2026-01-01T00:00:00Z", "result": 1.0, "resultQualifierCodes": [qualifier.name], } - response = client.post("/api/data/observations", data=body, content_type="application/json") + response = client.post("/api/ogc/collections/observations/items", data=body, content_type="application/json") assert response.status_code == 201, response.content - response = client.get("/api/data/observations", {"datastream_id": str(datastream.id), "result_qualifier_code": qualifier.name}) + response = client.get("/api/ogc/collections/observations/items", {"datastream_id": str(datastream.id), "result_qualifier_code": qualifier.name}) assert response.status_code == 200 assert response.json()["data"][0]["resultQualifierCodes"] == [qualifier.name] @@ -118,7 +118,7 @@ def test_visualization_allows_observed_property_without_code(client, owner_works monitoring_site__workspace=owner_workspace, observed_property__code=None, processing_level__code=None, ) - response = client.get("/api/data/datastreams/visualization-bootstrap") + response = client.get("/api/ogc/collections/datastreams/visualization-bootstrap") assert response.status_code == 200, response.content properties = response.json()["observedProperties"] assert next(p for p in properties if p["id"] == str(datastream.observed_property_id))["code"] is None @@ -132,6 +132,6 @@ def test_bulk_observation_codes_use_qualifier_names(client, owner_workspace): "fields": ["phenomenonTime", "result", "resultQualifierCodes"], "data": [["2026-01-01T00:00:00Z", 1.0, [qualifier.name]]], } - response = client.post("/api/data/observations/bulk-create?mode=insert", data=body, content_type="application/json") + response = client.post("/api/ogc/collections/observations/bulk-create?mode=insert", data=body, content_type="application/json") assert response.status_code == 201, response.content assert datastream.observation_set.get().result_qualifiers == [qualifier.name] diff --git a/django/tests/interfaces/api/test_workspace_views.py b/django/tests/interfaces/api/test_workspace_views.py index 765a4b93..d0640344 100644 --- a/django/tests/interfaces/api/test_workspace_views.py +++ b/django/tests/interfaces/api/test_workspace_views.py @@ -13,7 +13,7 @@ pytestmark = pytest.mark.django_db -WORKSPACES_URL = "/api/data/workspaces" +WORKSPACES_URL = "/api/ogc/collections/workspaces/items" WORKSPACE_FIELDS = { "id", diff --git a/django/tests/interfaces/web/test_site_type_icons.py b/django/tests/interfaces/web/test_site_type_icons.py index 59bb5e86..e5fc262c 100644 --- a/django/tests/interfaces/web/test_site_type_icons.py +++ b/django/tests/interfaces/web/test_site_type_icons.py @@ -71,7 +71,7 @@ def test_django_and_data_management_app_icon_lists_stay_in_sync(): @pytest.mark.django_db def test_default_site_type_icon_mappings_are_available(client): - response = client.get("/api/data/monitoring-sites/site-type-icons") + response = client.get("/api/ogc/collections/monitoring-sites/site-type-icons") assert response.status_code == 200 mappings = response.json() diff --git a/packages/hydroserver-ts/package.json b/packages/hydroserver-ts/package.json index ba8bf36d..86287cfb 100644 --- a/packages/hydroserver-ts/package.json +++ b/packages/hydroserver-ts/package.json @@ -27,7 +27,7 @@ "test": "vitest", "coverage": "vitest run --coverage --environment jsdom", "gen:schemas": "DEBUG=True DEPLOYMENT_BACKEND=dev PROXY_BASE_URL=http://127.0.0.1:8000 python3.14 ../../django/manage.py export_openapi_contracts", - "gen:types": "npx --yes openapi-typescript@7.13.0 ../../django/contracts/openapi/data.openapi.json --output ./src/generated/data.types.d.ts", + "gen:types": "npx --yes openapi-typescript@7.13.0 ../../django/contracts/openapi/ogc.openapi.json --output ./src/generated/ogc.types.d.ts", "gen:contracts": "tsx ./scripts/generate-contracts.data.ts", "gen": "npm run gen:schemas && npm run gen:types && npm run gen:contracts", "check:contract": "npm run gen && git diff --exit-code -- ../../django/contracts/openapi ./src/generated" diff --git a/packages/hydroserver-ts/scripts/generate-contracts.data.ts b/packages/hydroserver-ts/scripts/generate-contracts.data.ts index ce0bb92c..74005e2f 100644 --- a/packages/hydroserver-ts/scripts/generate-contracts.data.ts +++ b/packages/hydroserver-ts/scripts/generate-contracts.data.ts @@ -1,88 +1,71 @@ /* eslint-disable no-console */ import path from 'node:path' import { generateContracts } from './generate-contracts.shared' -import { DATA_OPENAPI_FILE } from './openapi-paths' +import { OGC_OPENAPI_FILE } from './openapi-paths' -const SCHEMA_FILE = DATA_OPENAPI_FILE +const SCHEMA_FILE = OGC_OPENAPI_FILE const OUT_DIR = path.resolve('src/generated/contracts') +/** An OGC API collection: list/create at /collections/{id}/items, items at /items/{itemId}. */ +const collection = (collectionId: string, resource = collectionId) => ({ + resource, + pathSuffix: `/collections/${collectionId}/items`, + route: `collections/${collectionId}/items`, +}) + +const QC_HISTORY_ITEM = 'collections/quality-control-histories/items/{history_id}' + const resources = [ - 'workspaces', - 'monitoring-sites', - 'monitoring-site-types', - 'linked-resource-types', - 'datastreams', - 'datastream-statuses', - 'aggregation-statistics', - 'units', - 'unit-types', - 'methods', - 'method-types', - 'observed-properties', - 'observed-property-types', - 'processing-levels', - 'result-qualifiers', - 'sampled-mediums', - 'observations', + collection('workspaces'), + collection('monitoring-sites'), + collection('monitoring-site-types'), + collection('linked-resource-types'), + collection('datastreams'), + collection('datastream-statuses'), + collection('aggregation-statistics'), + collection('units'), + collection('unit-types'), + collection('methods'), + collection('method-types'), + collection('observed-properties'), + collection('observed-property-types'), + collection('processing-levels'), + collection('result-qualifiers'), + collection('sampled-mediums'), + collection('observations'), // ETL - { - resource: 'data-connections', - pathSuffix: '/etl-data-connections', - route: 'etl-data-connections', - }, - { - resource: 'tasks', - pathSuffix: '/etl-tasks', - route: 'etl-tasks', - }, + collection('etl-data-connections', 'data-connections'), + collection('etl-tasks', 'tasks'), 'runs', - 'etl-mappings', + collection('etl-mappings'), // Monitoring - { - resource: 'monitoring-tasks', - pathSuffix: '/monitoring-tasks', - route: 'monitoring-tasks', - }, - 'monitoring-rules', + collection('monitoring-tasks'), + collection('monitoring-rules'), // Products - { - resource: 'data-product-tasks', - pathSuffix: '/data-product-tasks', - route: 'data-product-tasks', - }, - { - resource: 'rating-curves', - pathSuffix: '/data-product-rating-curves', - route: 'data-product-rating-curves', - }, - 'data-product-transformations', + collection('data-product-tasks'), + collection('data-product-rating-curves', 'rating-curves'), + collection('data-product-transformations'), // Quality control - { - resource: 'quality-control-histories', - pathSuffix: '/quality-control/histories', - route: 'quality-control/histories', - }, + collection('quality-control-histories'), { resource: 'quality-control-sessions', - pathSuffix: '/quality-control/histories/{history_id}/sessions', - route: 'quality-control/histories/{history_id}/sessions', + pathSuffix: `/${QC_HISTORY_ITEM}/sessions`, + route: `${QC_HISTORY_ITEM}/sessions`, }, { resource: 'quality-control-operations', - pathSuffix: - '/quality-control/histories/{history_id}/sessions/{session_id}/operations', - route: - 'quality-control/histories/{history_id}/sessions/{session_id}/operations', + pathSuffix: `/${QC_HISTORY_ITEM}/sessions/{session_id}/operations`, + route: `${QC_HISTORY_ITEM}/sessions/{session_id}/operations`, }, ] generateContracts({ schemaFile: SCHEMA_FILE, outDir: OUT_DIR, - typesImportPath: '../data.types', + typesImportPath: '../ogc.types', explicitResources: resources, }) diff --git a/packages/hydroserver-ts/scripts/generate-services.ts b/packages/hydroserver-ts/scripts/generate-services.ts index 1a4a073c..8d09b4e5 100644 --- a/packages/hydroserver-ts/scripts/generate-services.ts +++ b/packages/hydroserver-ts/scripts/generate-services.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import fs from 'node:fs' import path from 'node:path' -import { DATA_OPENAPI_FILE } from './openapi-paths' +import { OGC_OPENAPI_FILE } from './openapi-paths' type OAS = { servers?: any[] @@ -10,7 +10,7 @@ type OAS = { } const ROOT = process.cwd() -const SCHEMA_FILE = DATA_OPENAPI_FILE +const SCHEMA_FILE = OGC_OPENAPI_FILE const OUT_DIR = path.resolve('src/generated/services') fs.mkdirSync(OUT_DIR, { recursive: true }) @@ -40,7 +40,7 @@ const toCamel = (s: string) => .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')) .replace(/^(.)/, (m) => m.toLowerCase()) -/** Return "/api/data/" path root if present */ +/** Return "/api/ogc/" path root if present */ function findCollectionRoot(resource: string) { const key = Object.keys(spec.paths).find((p) => p.endsWith(`/${resource}`)) return key || null @@ -205,7 +205,7 @@ const header = `/* AUTO-GENERATED. DO NOT EDIT. import type { ApiResponse } from '../../api/responseInterceptor' import { apiMethods } from '../../api/apiMethods' import type { HydroServer } from '../../api/HydroServer' -import type * as Data from '../data.types' +import type * as Data from '../ogc.types' import { ${resources .map((r) => `${toPascal(singularizeKebab(r))}Contract`) .join(', ')} } from '../contracts' diff --git a/packages/hydroserver-ts/scripts/openapi-paths.ts b/packages/hydroserver-ts/scripts/openapi-paths.ts index da8ba7c5..ddb95a19 100644 --- a/packages/hydroserver-ts/scripts/openapi-paths.ts +++ b/packages/hydroserver-ts/scripts/openapi-paths.ts @@ -1,4 +1,4 @@ import path from 'node:path' export const OPENAPI_DIR = path.resolve('../../django/contracts/openapi') -export const DATA_OPENAPI_FILE = path.join(OPENAPI_DIR, 'data.openapi.json') +export const OGC_OPENAPI_FILE = path.join(OPENAPI_DIR, 'ogc.openapi.json') diff --git a/packages/hydroserver-ts/src/api/HydroServer.ts b/packages/hydroserver-ts/src/api/HydroServer.ts index c2473f45..4051e72d 100644 --- a/packages/hydroserver-ts/src/api/HydroServer.ts +++ b/packages/hydroserver-ts/src/api/HydroServer.ts @@ -85,7 +85,7 @@ export class HydroServer { const { host, oidc } = opts this.host = host.trim().replace(/\/+$/, '') this.resolvedHost = this.host || globalThis.location?.origin || '' - this.baseRoute = `${this.host}/api/data` + this.baseRoute = `${this.host}/api/ogc` this.oidc = oidc ? { clientId: oidc.clientId, diff --git a/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts b/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts index 2382cb17..5829a434 100644 --- a/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts @@ -42,7 +42,7 @@ describe('paginatedFetch', () => { const response = await apiMethods.paginatedFetch< { id: string; ownerEmail: string }[] - >('https://hydro.example.com/api/data/workspaces?limit=1') + >('https://hydro.example.com/api/ogc/collections/workspaces/items?limit=1') expect(response.ok).toBe(true) if (!response.ok) return @@ -86,7 +86,7 @@ describe('paginatedFetch', () => { const response = await apiMethods.paginatedFetch<{ fields: string[] rows: unknown[][] - }>('https://hydro.example.com/api/data/observations?format=row&limit=1') + }>('https://hydro.example.com/api/ogc/collections/observations/items?format=row&limit=1') expect(response.ok).toBe(true) if (!response.ok) return @@ -109,7 +109,7 @@ describe('paginatedFetch', () => { const response = await apiMethods.paginatedFetch< { id: string; ownerEmail: string }[] - >('https://hydro.example.com/api/data/workspaces') + >('https://hydro.example.com/api/ogc/collections/workspaces/items') expect(response.ok).toBe(true) if (!response.ok) return @@ -139,7 +139,7 @@ describe('paginatedFetch', () => { vi.stubGlobal('fetch', fetchMock) const response = await apiMethods.paginatedFetch<{ id: string }[]>( - 'https://hydro.example.com/api/data/observations?limit=2' + 'https://hydro.example.com/api/ogc/collections/observations/items?limit=2' ) expect(response.ok).toBe(true) @@ -167,7 +167,7 @@ describe('paginatedFetch', () => { vi.stubGlobal('fetch', fetchMock) const response = await apiMethods.paginatedFetch<{ id: string }[]>( - 'https://hydro.example.com/api/data/observations?limit=2' + 'https://hydro.example.com/api/ogc/collections/observations/items?limit=2' ) expect(response.ok).toBe(true) diff --git a/packages/hydroserver-ts/src/api/__tests__/data-connection.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/data-connection.service.spec.ts index 72493a52..7de3e3bf 100644 --- a/packages/hydroserver-ts/src/api/__tests__/data-connection.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/data-connection.service.spec.ts @@ -39,7 +39,7 @@ describe('DataConnectionService', () => { ) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/etl-data-connections' + 'https://hydro.example.com/api/ogc/collections/etl-data-connections/items' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ @@ -68,7 +68,7 @@ describe('DataConnectionService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/etl-data-connections/data-connection-1' + 'https://hydro.example.com/api/ogc/collections/etl-data-connections/items/data-connection-1' ) expect(fetchMock.mock.calls[0][1].method).toBe('PATCH') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ diff --git a/packages/hydroserver-ts/src/api/__tests__/data-product-transformation.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/data-product-transformation.service.spec.ts index 657eca18..7864d754 100644 --- a/packages/hydroserver-ts/src/api/__tests__/data-product-transformation.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/data-product-transformation.service.spec.ts @@ -50,14 +50,14 @@ describe('DataProductTransformationService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/data-product-transformations' + 'https://hydro.example.com/api/ogc/collections/data-product-transformations/items' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body) expect(sentBody.taskId).toBe('task-1') expect(fetchMock.mock.calls[1][0]).toBe( - 'https://hydro.example.com/api/data/data-product-transformations/transformation-1' + 'https://hydro.example.com/api/ogc/collections/data-product-transformations/items/transformation-1' ) expect(response.ok).toBe(true) @@ -95,7 +95,7 @@ describe('DataProductTransformationService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/data-product-transformations/transformation-1' + 'https://hydro.example.com/api/ogc/collections/data-product-transformations/items/transformation-1' ) expect(fetchMock.mock.calls[0][1].method).toBe('PATCH') expect(response.ok).toBe(true) diff --git a/packages/hydroserver-ts/src/api/__tests__/etl-mapping.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/etl-mapping.service.spec.ts index f79cacf2..423be2c2 100644 --- a/packages/hydroserver-ts/src/api/__tests__/etl-mapping.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/etl-mapping.service.spec.ts @@ -38,14 +38,14 @@ describe('EtlMappingService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/etl-mappings' + 'https://hydro.example.com/api/ogc/collections/etl-mappings/items' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body) expect(sentBody.etlTaskId).toBe('task-1') expect(fetchMock.mock.calls[1][0]).toBe( - 'https://hydro.example.com/api/data/etl-mappings/mapping-1' + 'https://hydro.example.com/api/ogc/collections/etl-mappings/items/mapping-1' ) expect(response.ok).toBe(true) @@ -77,7 +77,7 @@ describe('EtlMappingService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/etl-mappings/mapping-1' + 'https://hydro.example.com/api/ogc/collections/etl-mappings/items/mapping-1' ) expect(fetchMock.mock.calls[0][1].method).toBe('PATCH') expect(response.ok).toBe(true) diff --git a/packages/hydroserver-ts/src/api/__tests__/monitoring-rule.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/monitoring-rule.service.spec.ts index c80484ea..b39dcfbe 100644 --- a/packages/hydroserver-ts/src/api/__tests__/monitoring-rule.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/monitoring-rule.service.spec.ts @@ -42,14 +42,14 @@ describe('MonitoringRuleService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/monitoring-rules' + 'https://hydro.example.com/api/ogc/collections/monitoring-rules/items' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body) expect(sentBody.taskId).toBe('task-1') expect(fetchMock.mock.calls[1][0]).toBe( - 'https://hydro.example.com/api/data/monitoring-rules/rule-1' + 'https://hydro.example.com/api/ogc/collections/monitoring-rules/items/rule-1' ) expect(response.ok).toBe(true) @@ -83,7 +83,7 @@ describe('MonitoringRuleService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/monitoring-rules/rule-1' + 'https://hydro.example.com/api/ogc/collections/monitoring-rules/items/rule-1' ) expect(fetchMock.mock.calls[0][1].method).toBe('PATCH') expect(response.ok).toBe(true) diff --git a/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts index be57cb65..51b29ee6 100644 --- a/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts @@ -36,7 +36,7 @@ describe('MonitoringSiteService', () => { expect(res.ok).toBe(true) expect(res.data).toEqual(payload) const [url] = (fetch as any).mock.calls[0] - expect(url).toMatch(/\/api\/data\/monitoring-sites\/markers$/) + expect(url).toMatch(/\/api\/ogc\/collections\/monitoring-sites\/markers$/) }) it('returns ok:false on a failed request', async () => { @@ -208,7 +208,7 @@ describe('MonitoringSiteService', () => { expect(res.ok).toBe(true) const [url] = (fetch as any).mock.calls[0] - expect(url).toMatch(/\/api\/data\/monitoring-sites\/site-summaries$/) + expect(url).toMatch(/\/api\/ogc\/collections\/monitoring-sites\/site-summaries$/) }) it('passes workspace_id as a query param and returns summaries', async () => { @@ -235,7 +235,7 @@ describe('MonitoringSiteService', () => { const [url] = (fetch as any).mock.calls[0] const parsed = new URL(url) - expect(parsed.pathname).toBe('/api/data/monitoring-sites/site-summaries') + expect(parsed.pathname).toBe('/api/ogc/collections/monitoring-sites/site-summaries') expect(parsed.searchParams.get('workspace_id')).toBe('workspace id') }) @@ -323,7 +323,7 @@ describe('DatastreamService', () => { expect(res.ok).toBe(true) const [url] = (fetch as any).mock.calls[0] - expect(url).toMatch(/\/api\/data\/datastreams\/visualization-bootstrap$/) + expect(url).toMatch(/\/api\/ogc\/collections\/datastreams\/visualization-bootstrap$/) expect(res.data.monitoringSites[0]).toBeInstanceOf(MonitoringSite) expect(res.data.datastreams[0]).toBeInstanceOf(Datastream) diff --git a/packages/hydroserver-ts/src/api/__tests__/quality-control.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/quality-control.service.spec.ts index 5b3cfd3d..4843eec4 100644 --- a/packages/hydroserver-ts/src/api/__tests__/quality-control.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/quality-control.service.spec.ts @@ -39,7 +39,7 @@ describe('QualityControl services', () => { const url = new URL(fetchMock.mock.calls[0][0]) expect(url.href).toBe( - 'https://hydro.example.com/api/data/quality-control/histories?managed_datastream_id=managed-1&offset=0&limit=200' + 'https://hydro.example.com/api/ogc/collections/quality-control-histories/items?managed_datastream_id=managed-1&offset=0&limit=200' ) }) @@ -63,7 +63,7 @@ describe('QualityControl services', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/quality-control/histories/history-1/sessions' + 'https://hydro.example.com/api/ogc/collections/quality-control-histories/items/history-1/sessions' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ @@ -102,7 +102,7 @@ describe('QualityControl services', () => { expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/quality-control/histories/history-1/sessions/session-1/commit' + 'https://hydro.example.com/api/ogc/collections/quality-control-histories/items/history-1/sessions/session-1/commit' ) expect(response.ok).toBe(true) if (!response.ok) return @@ -134,7 +134,7 @@ describe('QualityControl services', () => { ]) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/quality-control/histories/history-1/sessions/session-1/operations' + 'https://hydro.example.com/api/ogc/collections/quality-control-histories/items/history-1/sessions/session-1/operations' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual([ diff --git a/packages/hydroserver-ts/src/api/__tests__/rating-curve.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/rating-curve.service.spec.ts index a87cb62b..70da99b8 100644 --- a/packages/hydroserver-ts/src/api/__tests__/rating-curve.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/rating-curve.service.spec.ts @@ -43,7 +43,7 @@ describe('RatingCurveService', () => { expect(response).toHaveLength(1) const url = new URL(fetchMock.mock.calls[0][0]) expect(url.href).toBe( - 'https://hydro.example.com/api/data/data-product-rating-curves?sortby=name&monitoring_site_id=monitoringSite-1&offset=0&limit=200' + 'https://hydro.example.com/api/ogc/collections/data-product-rating-curves/items?sortby=name&monitoring_site_id=monitoringSite-1&offset=0&limit=200' ) }) @@ -77,7 +77,7 @@ describe('RatingCurveService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/data-product-rating-curves' + 'https://hydro.example.com/api/ogc/collections/data-product-rating-curves/items' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ @@ -88,7 +88,7 @@ describe('RatingCurveService', () => { points: [[1, 2]], }) expect(String(fetchMock.mock.calls[1][0])).toBe( - 'https://hydro.example.com/api/data/data-product-rating-curves/rating-curve-1' + 'https://hydro.example.com/api/ogc/collections/data-product-rating-curves/items/rating-curve-1' ) expect(response.ok).toBe(true) if (!response.ok) return @@ -127,7 +127,7 @@ describe('RatingCurveService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/data-product-rating-curves/rating-curve-1' + 'https://hydro.example.com/api/ogc/collections/data-product-rating-curves/items/rating-curve-1' ) expect(fetchMock.mock.calls[0][1].method).toBe('PATCH') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ @@ -137,7 +137,7 @@ describe('RatingCurveService', () => { points: [[2, 3]], }) expect(String(fetchMock.mock.calls[1][0])).toBe( - 'https://hydro.example.com/api/data/data-product-rating-curves/rating-curve-1' + 'https://hydro.example.com/api/ogc/collections/data-product-rating-curves/items/rating-curve-1' ) expect(response.ok).toBe(true) if (!response.ok) return @@ -155,7 +155,7 @@ describe('RatingCurveService', () => { await client.ratingCurves.delete('rating-curve-1') expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/data-product-rating-curves/rating-curve-1' + 'https://hydro.example.com/api/ogc/collections/data-product-rating-curves/items/rating-curve-1' ) expect(fetchMock.mock.calls[0][1].method).toBe('DELETE') }) diff --git a/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts index 5c8f19a3..01c0eef3 100644 --- a/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts @@ -124,7 +124,7 @@ describe('TaskService', () => { }) expect(fetchMock.mock.calls[0][0]).toBe( - 'https://hydro.example.com/api/data/monitoring-tasks' + 'https://hydro.example.com/api/ogc/collections/monitoring-tasks/items' ) expect(fetchMock.mock.calls[0][1].method).toBe('POST') expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ diff --git a/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts index b99a932f..7ce776f9 100644 --- a/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts @@ -20,7 +20,7 @@ describe('WorkspaceService', () => { await client.workspaces.getServiceAccounts('workspace-1') expect(String(fetchMock.mock.calls[0][0])).toBe( - 'https://hydro.example.com/api/data/workspaces/workspace-1/service-accounts?offset=0&limit=200' + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1/service-accounts?offset=0&limit=200' ) }) @@ -69,8 +69,8 @@ describe('WorkspaceService', () => { expect(response.data.every((c) => c.role?.id === 'role-1')).toBe(true) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ - 'https://hydro.example.com/api/data/workspaces/workspace-1/collaborators?include=role%2Cuser%2CserviceAccount&offset=0&limit=200', - 'https://hydro.example.com/api/data/workspaces/workspace-1/collaborators?include=role%2Cuser%2CserviceAccount&offset=200&limit=200', + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1/collaborators?include=role%2Cuser%2CserviceAccount&offset=0&limit=200', + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1/collaborators?include=role%2Cuser%2CserviceAccount&offset=200&limit=200', ]) }) @@ -139,8 +139,8 @@ describe('WorkspaceService', () => { expect(response.data).toEqual([{ id: 'account-1' }, { id: 'account-2' }]) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ - 'https://hydro.example.com/api/data/workspaces/workspace-1/service-accounts?offset=0&limit=200', - 'https://hydro.example.com/api/data/workspaces/workspace-1/service-accounts?offset=200&limit=200', + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1/service-accounts?offset=0&limit=200', + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1/service-accounts?offset=200&limit=200', ]) }) @@ -175,7 +175,7 @@ describe('WorkspaceService', () => { const response = await client.workspaces.get('workspace-1') expect(String(fetchMock.mock.calls[0][0])).toBe( - 'https://hydro.example.com/api/data/workspaces/workspace-1?include=owner%2CpendingTransferTo%2CcollaboratorRole' + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1?include=owner%2CpendingTransferTo%2CcollaboratorRole' ) expect(response.ok).toBe(true) if (!response.ok) return @@ -355,7 +355,7 @@ describe('WorkspaceService', () => { expect(fetchMock).toHaveBeenCalledTimes(1) expect(String(fetchMock.mock.calls[0][0])).toBe( - 'https://hydro.example.com/api/data/workspaces/workspace-1/service-accounts/account-1/regenerate' + 'https://hydro.example.com/api/ogc/collections/workspaces/items/workspace-1/service-accounts/account-1/regenerate' ) expect(response.ok).toBe(true) if (!response.ok) return diff --git a/packages/hydroserver-ts/src/api/services/base.ts b/packages/hydroserver-ts/src/api/services/base.ts index f8b04512..255f57b5 100644 --- a/packages/hydroserver-ts/src/api/services/base.ts +++ b/packages/hydroserver-ts/src/api/services/base.ts @@ -47,10 +47,15 @@ export abstract class HydroServerBaseService< this._ModelCtor = ctor.Model } - /** Not all resources are at api/data/ so provide this function to allow a service to override */ + /** Not all resources are at api/ogc/ so provide this function to allow a service to override */ protected getBaseUrl(): string { return this._client.baseRoute } + + /** Collection root (the route without its trailing `/items`), for collection-level endpoints such as tag keys. */ + protected get _collectionRoute(): string { + return this._route.replace(/\/items$/, '') + } async list( params: Partial> & { fetch_all?: boolean diff --git a/packages/hydroserver-ts/src/api/services/data-product-transformation.types.ts b/packages/hydroserver-ts/src/api/services/data-product-transformation.types.ts index bf547800..5fa1014f 100644 --- a/packages/hydroserver-ts/src/api/services/data-product-transformation.types.ts +++ b/packages/hydroserver-ts/src/api/services/data-product-transformation.types.ts @@ -1,4 +1,4 @@ -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' export type DataProductTransformation = Data.components['schemas']['DataProductTransformationResponse'] diff --git a/packages/hydroserver-ts/src/api/services/datastream.service.ts b/packages/hydroserver-ts/src/api/services/datastream.service.ts index 6b492ad4..7bb09d47 100644 --- a/packages/hydroserver-ts/src/api/services/datastream.service.ts +++ b/packages/hydroserver-ts/src/api/services/datastream.service.ts @@ -4,7 +4,7 @@ import { DatastreamContract as C, ObservationContract, } from '../../generated/contracts' -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' import type { ApiResponse } from '../responseInterceptor' import { Datastream as M, @@ -190,7 +190,7 @@ export class DatastreamService extends HydroServerBaseService { /* ----------------------- Sub-resources: Tags ----------------------- */ getTagKeys(params: { workspace_id?: string; datastream_id?: string }) { - const url = this.withQuery(`${this._route}/tags/keys`, params) + const url = this.withQuery(`${this._collectionRoute}/tags/keys`, params) return apiMethods.fetch(url) } @@ -281,7 +281,7 @@ export class DatastreamService extends HydroServerBaseService { datastreamId: string, params: ObservationContract.QueryParameters ) { - const url = this.withQuery(`${this._client.baseRoute}/observations`, { + const url = this.withQuery(`${this._client.baseRoute}/collections/observations/items`, { ...params, datastream_id: datastreamId, }) @@ -289,7 +289,7 @@ export class DatastreamService extends HydroServerBaseService { } createObservation(datastreamId: string, body: ObservationPostBody) { - const url = `${this._client.baseRoute}/observations` + const url = `${this._client.baseRoute}/collections/observations/items` return apiMethods.post(url, { ...body, datastreamId, @@ -302,14 +302,14 @@ export class DatastreamService extends HydroServerBaseService { params?: ObservationBulkPostQueryParameters ) { const url = this.withQuery( - `${this._client.baseRoute}/observations/bulk-create`, + `${this._client.baseRoute}/collections/observations/bulk-create`, params ) return apiMethods.post(url, { ...body, datastreamId }) } deleteObservations(datastreamId: string, body?: ObservationBulkDeleteBody) { - const url = `${this._client.baseRoute}/observations/bulk-delete` + const url = `${this._client.baseRoute}/collections/observations/bulk-delete` return apiMethods.post(url, { ...(body || { phenomenonTimeStart: null, phenomenonTimeEnd: null }), datastreamId, @@ -317,14 +317,14 @@ export class DatastreamService extends HydroServerBaseService { } getObservation(_datastreamId: string, observationId: string) { - const url = `${this._client.baseRoute}/observations/${encodeURIComponent( + const url = `${this._client.baseRoute}/collections/observations/items/${encodeURIComponent( observationId )}` return apiMethods.fetch(url) } deleteObservation(_datastreamId: string, observationId: string) { - const url = `${this._client.baseRoute}/observations/${observationId}` + const url = `${this._client.baseRoute}/collections/observations/items/${observationId}` return apiMethods.delete(url) } @@ -332,7 +332,7 @@ export class DatastreamService extends HydroServerBaseService { ApiResponse > { const res = await apiMethods.fetch( - `${this._route}/visualization-bootstrap` + `${this._collectionRoute}/visualization-bootstrap` ) if (!res.ok) return res diff --git a/packages/hydroserver-ts/src/api/services/etl-mapping.types.ts b/packages/hydroserver-ts/src/api/services/etl-mapping.types.ts index 40814763..ed390067 100644 --- a/packages/hydroserver-ts/src/api/services/etl-mapping.types.ts +++ b/packages/hydroserver-ts/src/api/services/etl-mapping.types.ts @@ -1,4 +1,4 @@ -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' export type EtlMapping = Data.components['schemas']['EtlMappingResponse'] diff --git a/packages/hydroserver-ts/src/api/services/link-normalization.ts b/packages/hydroserver-ts/src/api/services/link-normalization.ts index d88f378f..9382b793 100644 --- a/packages/hydroserver-ts/src/api/services/link-normalization.ts +++ b/packages/hydroserver-ts/src/api/services/link-normalization.ts @@ -3,7 +3,7 @@ type LinkLike = { } function pathShouldUseApiOrigin(pathname: string) { - return pathname.startsWith('/media/') || pathname.startsWith('/api/data/') + return pathname.startsWith('/media/') || pathname.startsWith('/api/ogc/') } export function preferredApiOrigin(host?: string | null) { diff --git a/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts b/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts index c0e78227..d465014b 100644 --- a/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts +++ b/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts @@ -1,7 +1,7 @@ import { apiMethods } from '../apiMethods' import { HydroServerBaseService } from './base' import { MonitoringSiteContract as C } from '../../generated/contracts' -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' import { MonitoringSite, PostHydroShareArchive, @@ -22,14 +22,14 @@ export class MonitoringSiteService extends HydroServerBaseService> { - return apiMethods.fetch(`${this._route}/markers`) + return apiMethods.fetch(`${this._collectionRoute}/markers`) } listSiteSummaries( workspaceId?: string ): Promise> { return apiMethods.fetch( - this.withQuery(`${this._route}/site-summaries`, { + this.withQuery(`${this._collectionRoute}/site-summaries`, { workspace_id: workspaceId, }) ) @@ -40,7 +40,7 @@ export class MonitoringSiteService extends HydroServerBaseService> { return apiMethods.fetch( - this.withQuery(`${this._route}/task-summaries`, params) + this.withQuery(`${this._collectionRoute}/task-summaries`, params) ) } @@ -50,11 +50,11 @@ export class MonitoringSiteService extends HydroServerBaseService> => this.patchAndRefetch(id, { isPrivate }) getSiteTypeIcons = () => - apiMethods.fetch(`${this._route}/site-type-icons`) + apiMethods.fetch(`${this._collectionRoute}/site-type-icons`) /* ----------------------- Sub-resources: Tags ----------------------- */ getTagKeys(params: { workspace_id?: string; monitoring_site_id?: string }) { - const url = this.withQuery(`${this._route}/tags/keys`, params) + const url = this.withQuery(`${this._collectionRoute}/tags/keys`, params) return apiMethods.fetch>(url) } diff --git a/packages/hydroserver-ts/src/api/services/quality-control.service.ts b/packages/hydroserver-ts/src/api/services/quality-control.service.ts index 179dca2a..11871ea2 100644 --- a/packages/hydroserver-ts/src/api/services/quality-control.service.ts +++ b/packages/hydroserver-ts/src/api/services/quality-control.service.ts @@ -1,7 +1,7 @@ import { apiMethods } from '../apiMethods' import type { HydroServer } from '../HydroServer' import type { ApiResponse } from '../responseInterceptor' -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' import { QualityControlHistoryContract, QualityControlOperationContract, @@ -20,7 +20,7 @@ export class QualityControlHistoryService { private readonly _route: string constructor(client: HydroServer) { - this._route = `${client.baseRoute}/quality-control/histories` + this._route = `${client.baseRoute}/collections/quality-control-histories/items` } async list( @@ -91,7 +91,7 @@ export class QualityControlSessionService { private readonly _route: string constructor(client: HydroServer) { - this._route = `${client.baseRoute}/quality-control/histories` + this._route = `${client.baseRoute}/collections/quality-control-histories/items` } async list( @@ -195,7 +195,7 @@ export class QualityControlOperationService { private readonly _route: string constructor(client: HydroServer) { - this._route = `${client.baseRoute}/quality-control/histories` + this._route = `${client.baseRoute}/collections/quality-control-histories/items` } async list( diff --git a/packages/hydroserver-ts/src/api/services/rating-curve-preview.service.ts b/packages/hydroserver-ts/src/api/services/rating-curve-preview.service.ts index 60ed94d1..ea45db82 100644 --- a/packages/hydroserver-ts/src/api/services/rating-curve-preview.service.ts +++ b/packages/hydroserver-ts/src/api/services/rating-curve-preview.service.ts @@ -183,7 +183,7 @@ export class RatingCurvePreviewService { } function isMonitoringSiteLinkDownloadPath(pathname: string) { - return /^\/api\/data\/monitoring-sites\/[^/]+\/links\/[^/]+\/download\/?$/.test( + return /^\/api\/ogc\/collections\/monitoring-sites\/items\/[^/]+\/links\/[^/]+\/download\/?$/.test( pathname ) } diff --git a/packages/hydroserver-ts/src/api/services/user.service.ts b/packages/hydroserver-ts/src/api/services/user.service.ts index aea91829..f271efd2 100644 --- a/packages/hydroserver-ts/src/api/services/user.service.ts +++ b/packages/hydroserver-ts/src/api/services/user.service.ts @@ -1,6 +1,6 @@ import { apiMethods } from '../apiMethods' import { type HydroServer } from '../HydroServer' -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' import { User, Workspace } from '../../types' import { ApiResponse } from '../responseInterceptor' diff --git a/packages/hydroserver-ts/src/api/services/workspace.service.ts b/packages/hydroserver-ts/src/api/services/workspace.service.ts index b2897962..d45322d2 100644 --- a/packages/hydroserver-ts/src/api/services/workspace.service.ts +++ b/packages/hydroserver-ts/src/api/services/workspace.service.ts @@ -8,7 +8,7 @@ import { UserInfo, Workspace as M, } from '../../types' -import type * as Data from '../../generated/data.types' +import type * as Data from '../../generated/ogc.types' import { ApiResponse } from '../responseInterceptor' type RoleQueryParameters = NonNullable< @@ -271,10 +271,10 @@ export class WorkspaceService extends HydroServerBaseService { ) getRoles = (params?: RoleQueryParameters) => { - const url = this.withQuery(`${this._client.baseRoute}/roles`, params) + const url = this.withQuery(`${this._client.baseRoute}/collections/roles/items`, params) return apiMethods.paginatedFetch(url) } getRole = (id: string) => - apiMethods.fetch(`${this._client.baseRoute}/roles/${id}`) + apiMethods.fetch(`${this._client.baseRoute}/collections/roles/items/${id}`) } diff --git a/packages/hydroserver-ts/src/generated/contracts/aggregation-statistics.contract.ts b/packages/hydroserver-ts/src/generated/contracts/aggregation-statistics.contract.ts index 095643fc..c4c2a04a 100644 --- a/packages/hydroserver-ts/src/generated/contracts/aggregation-statistics.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/aggregation-statistics.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace AggregationStatisticContract { - export const route = 'aggregation-statistics' as const + export const route = 'collections/aggregation-statistics/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_aggregation_statistic_get_aggregation_statistics']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['AggregationStatisticResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_AggregationStatisticResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/data-connections.contract.ts b/packages/hydroserver-ts/src/generated/contracts/data-connections.contract.ts index db21ac16..76ff094a 100644 --- a/packages/hydroserver-ts/src/generated/contracts/data-connections.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/data-connections.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace DataConnectionContract { - export const route = 'etl-data-connections' as const + export const route = 'collections/etl-data-connections/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_etl_data_connection_get_data_connections']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['DataConnectionResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_DataConnectionResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/data-product-tasks.contract.ts b/packages/hydroserver-ts/src/generated/contracts/data-product-tasks.contract.ts index 2e4cf868..e5b1fe87 100644 --- a/packages/hydroserver-ts/src/generated/contracts/data-product-tasks.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/data-product-tasks.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace DataProductTaskContract { - export const route = 'data-product-tasks' as const + export const route = 'collections/data-product-tasks/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_products_task_get_data_product_tasks']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['DataProductTaskResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_DataProductTaskResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/data-product-transformations.contract.ts b/packages/hydroserver-ts/src/generated/contracts/data-product-transformations.contract.ts index 19048e09..676f3f62 100644 --- a/packages/hydroserver-ts/src/generated/contracts/data-product-transformations.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/data-product-transformations.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace DataProductTransformationContract { - export const route = 'data-product-transformations' as const + export const route = 'collections/data-product-transformations/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_products_transformation_get_data_product_transformations']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['DataProductTransformationResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_DataProductTransformationResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/datastream-statuses.contract.ts b/packages/hydroserver-ts/src/generated/contracts/datastream-statuses.contract.ts index 9b6d3bb4..be57d82f 100644 --- a/packages/hydroserver-ts/src/generated/contracts/datastream-statuses.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/datastream-statuses.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace DatastreamStatusContract { - export const route = 'datastream-statuses' as const + export const route = 'collections/datastream-statuses/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_datastream_status_get_datastream_statuses']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['DatastreamStatusResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_DatastreamStatusResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts b/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts index b0e7efe7..24273bc4 100644 --- a/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace DatastreamContract { - export const route = 'datastreams' as const + export const route = 'collections/datastreams/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_datastream_get_datastreams']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['DatastreamResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_DatastreamResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/etl-mappings.contract.ts b/packages/hydroserver-ts/src/generated/contracts/etl-mappings.contract.ts index e58ecc6a..56fa6e59 100644 --- a/packages/hydroserver-ts/src/generated/contracts/etl-mappings.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/etl-mappings.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace EtlMappingContract { - export const route = 'etl-mappings' as const + export const route = 'collections/etl-mappings/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_etl_mapping_get_etl_mappings']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['EtlMappingResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_EtlMappingResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/linked-resource-types.contract.ts b/packages/hydroserver-ts/src/generated/contracts/linked-resource-types.contract.ts index d2468bdf..cfab9c09 100644 --- a/packages/hydroserver-ts/src/generated/contracts/linked-resource-types.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/linked-resource-types.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace LinkedResourceTypeContract { - export const route = 'linked-resource-types' as const + export const route = 'collections/linked-resource-types/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_linked_resource_type_get_linked_resource_types']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['LinkedResourceTypeResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_LinkedResourceTypeResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/method-types.contract.ts b/packages/hydroserver-ts/src/generated/contracts/method-types.contract.ts index bbd14acf..3e0a14f2 100644 --- a/packages/hydroserver-ts/src/generated/contracts/method-types.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/method-types.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace MethodTypeContract { - export const route = 'method-types' as const + export const route = 'collections/method-types/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_method_type_get_method_types']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['MethodTypeResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_MethodTypeResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/methods.contract.ts b/packages/hydroserver-ts/src/generated/contracts/methods.contract.ts index 3370f9a0..3c128d59 100644 --- a/packages/hydroserver-ts/src/generated/contracts/methods.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/methods.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace MethodContract { - export const route = 'methods' as const + export const route = 'collections/methods/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_method_get_methods']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['MethodResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_MethodResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/monitoring-rules.contract.ts b/packages/hydroserver-ts/src/generated/contracts/monitoring-rules.contract.ts index 6e8cffa8..9637f8c8 100644 --- a/packages/hydroserver-ts/src/generated/contracts/monitoring-rules.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/monitoring-rules.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace MonitoringRuleContract { - export const route = 'monitoring-rules' as const + export const route = 'collections/monitoring-rules/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_monitoring_rule_get_monitoring_rules']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['MonitoringRuleResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_MonitoringRuleResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/monitoring-site-types.contract.ts b/packages/hydroserver-ts/src/generated/contracts/monitoring-site-types.contract.ts index 518686e0..5ef9c984 100644 --- a/packages/hydroserver-ts/src/generated/contracts/monitoring-site-types.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/monitoring-site-types.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace MonitoringSiteTypeContract { - export const route = 'monitoring-site-types' as const + export const route = 'collections/monitoring-site-types/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_monitoring_site_type_get_monitoring_site_types']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['MonitoringSiteTypeResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_MonitoringSiteTypeResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/monitoring-sites.contract.ts b/packages/hydroserver-ts/src/generated/contracts/monitoring-sites.contract.ts index 716d13ed..b8f29f55 100644 --- a/packages/hydroserver-ts/src/generated/contracts/monitoring-sites.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/monitoring-sites.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace MonitoringSiteContract { - export const route = 'monitoring-sites' as const + export const route = 'collections/monitoring-sites/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_monitoring_site_get_monitoring_sites']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['MonitoringSiteResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_MonitoringSiteResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/monitoring-tasks.contract.ts b/packages/hydroserver-ts/src/generated/contracts/monitoring-tasks.contract.ts index aeab264b..41af9a76 100644 --- a/packages/hydroserver-ts/src/generated/contracts/monitoring-tasks.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/monitoring-tasks.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace MonitoringTaskContract { - export const route = 'monitoring-tasks' as const + export const route = 'collections/monitoring-tasks/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_monitoring_task_get_monitoring_tasks']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['MonitoringTaskResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_MonitoringTaskResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/observations.contract.ts b/packages/hydroserver-ts/src/generated/contracts/observations.contract.ts index d7a2e7bf..3ab68fa8 100644 --- a/packages/hydroserver-ts/src/generated/contracts/observations.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/observations.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace ObservationContract { - export const route = 'observations' as const + export const route = 'collections/observations/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_observation_get_observations']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = never export type DetailResponse = Data.components['schemas']['ItemResponse_ObservationResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/observed-properties.contract.ts b/packages/hydroserver-ts/src/generated/contracts/observed-properties.contract.ts index 1a31c75b..d5400377 100644 --- a/packages/hydroserver-ts/src/generated/contracts/observed-properties.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/observed-properties.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace ObservedPropertyContract { - export const route = 'observed-properties' as const + export const route = 'collections/observed-properties/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_observed_property_get_observed_properties']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['ObservedPropertyResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_ObservedPropertyResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/observed-property-types.contract.ts b/packages/hydroserver-ts/src/generated/contracts/observed-property-types.contract.ts index 468ed54a..6468dee2 100644 --- a/packages/hydroserver-ts/src/generated/contracts/observed-property-types.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/observed-property-types.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace ObservedPropertyTypeContract { - export const route = 'observed-property-types' as const + export const route = 'collections/observed-property-types/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_observed_property_type_get_observed_property_types']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['ObservedPropertyTypeResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_ObservedPropertyTypeResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/processing-levels.contract.ts b/packages/hydroserver-ts/src/generated/contracts/processing-levels.contract.ts index f2d7f789..5715d4ab 100644 --- a/packages/hydroserver-ts/src/generated/contracts/processing-levels.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/processing-levels.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace ProcessingLevelContract { - export const route = 'processing-levels' as const + export const route = 'collections/processing-levels/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_processing_level_get_processing_levels']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['ProcessingLevelResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_ProcessingLevelResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/quality-control-histories.contract.ts b/packages/hydroserver-ts/src/generated/contracts/quality-control-histories.contract.ts index 14777a2d..2e46bfc9 100644 --- a/packages/hydroserver-ts/src/generated/contracts/quality-control-histories.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/quality-control-histories.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace QualityControlHistoryContract { - export const route = 'quality-control/histories' as const + export const route = 'collections/quality-control-histories/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_quality_history_get_qc_histories']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['QualityControlHistoryResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_QualityControlHistoryResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/quality-control-operations.contract.ts b/packages/hydroserver-ts/src/generated/contracts/quality-control-operations.contract.ts index 27b0042d..fa5346dd 100644 --- a/packages/hydroserver-ts/src/generated/contracts/quality-control-operations.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/quality-control-operations.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace QualityControlOperationContract { - export const route = 'quality-control/histories/{history_id}/sessions/{session_id}/operations' as const + export const route = 'collections/quality-control-histories/items/{history_id}/sessions/{session_id}/operations' as const export type QueryParameters = ([Data.operations['interfaces_api_views_quality_operation_get_qc_operations']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['QualityControlOperationResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_QualityControlOperationResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/quality-control-sessions.contract.ts b/packages/hydroserver-ts/src/generated/contracts/quality-control-sessions.contract.ts index aad6d543..b1876791 100644 --- a/packages/hydroserver-ts/src/generated/contracts/quality-control-sessions.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/quality-control-sessions.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace QualityControlSessionContract { - export const route = 'quality-control/histories/{history_id}/sessions' as const + export const route = 'collections/quality-control-histories/items/{history_id}/sessions' as const export type QueryParameters = ([Data.operations['interfaces_api_views_quality_session_get_qc_sessions']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['QualityControlSessionResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_QualityControlSessionResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/rating-curves.contract.ts b/packages/hydroserver-ts/src/generated/contracts/rating-curves.contract.ts index 9dfe665e..29f48ffa 100644 --- a/packages/hydroserver-ts/src/generated/contracts/rating-curves.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/rating-curves.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace RatingCurveContract { - export const route = 'data-product-rating-curves' as const + export const route = 'collections/data-product-rating-curves/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_products_rating_curve_get_rating_curves']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['RatingCurveResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_RatingCurveResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/result-qualifiers.contract.ts b/packages/hydroserver-ts/src/generated/contracts/result-qualifiers.contract.ts index b995d654..6f939f2f 100644 --- a/packages/hydroserver-ts/src/generated/contracts/result-qualifiers.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/result-qualifiers.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace ResultQualifierContract { - export const route = 'result-qualifiers' as const + export const route = 'collections/result-qualifiers/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_result_qualifier_get_result_qualifiers']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['ResultQualifierResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_ResultQualifierResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts b/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts index 4a1f4a2d..27452272 100644 --- a/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts @@ -1,6 +1,6 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace RunContract { export const route = 'runs' as const diff --git a/packages/hydroserver-ts/src/generated/contracts/sampled-mediums.contract.ts b/packages/hydroserver-ts/src/generated/contracts/sampled-mediums.contract.ts index a2e62b5b..c3e56543 100644 --- a/packages/hydroserver-ts/src/generated/contracts/sampled-mediums.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/sampled-mediums.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace SampledMediumContract { - export const route = 'sampled-mediums' as const + export const route = 'collections/sampled-mediums/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_sampled_medium_get_sampled_mediums']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['SampledMediumResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_SampledMediumResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/tasks.contract.ts b/packages/hydroserver-ts/src/generated/contracts/tasks.contract.ts index 81c2279f..f1be3762 100644 --- a/packages/hydroserver-ts/src/generated/contracts/tasks.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/tasks.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace TaskContract { - export const route = 'etl-tasks' as const + export const route = 'collections/etl-tasks/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_etl_task_get_etl_tasks']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['EtlTaskResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_EtlTaskResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/unit-types.contract.ts b/packages/hydroserver-ts/src/generated/contracts/unit-types.contract.ts index f99a1a1f..f71040d1 100644 --- a/packages/hydroserver-ts/src/generated/contracts/unit-types.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/unit-types.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace UnitTypeContract { - export const route = 'unit-types' as const + export const route = 'collections/unit-types/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_unit_type_get_unit_types']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['UnitTypeResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_UnitTypeResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/units.contract.ts b/packages/hydroserver-ts/src/generated/contracts/units.contract.ts index 16070bd4..ea4da748 100644 --- a/packages/hydroserver-ts/src/generated/contracts/units.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/units.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace UnitContract { - export const route = 'units' as const + export const route = 'collections/units/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_sta_unit_get_units']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['UnitResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_UnitResponse_'] diff --git a/packages/hydroserver-ts/src/generated/contracts/workspaces.contract.ts b/packages/hydroserver-ts/src/generated/contracts/workspaces.contract.ts index 816e8702..c6f8d398 100644 --- a/packages/hydroserver-ts/src/generated/contracts/workspaces.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/workspaces.contract.ts @@ -1,9 +1,9 @@ /* AUTO-GENERATED. DO NOT EDIT. - Generated from ../../django/contracts/openapi/data.openapi.json */ -import type * as Data from '../data.types' + Generated from ../../django/contracts/openapi/ogc.openapi.json */ +import type * as Data from '../ogc.types' export namespace WorkspaceContract { - export const route = 'workspaces' as const + export const route = 'collections/workspaces/items' as const export type QueryParameters = ([Data.operations['interfaces_api_views_iam_workspace_get_workspaces']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['WorkspaceResponse'] export type DetailResponse = Data.components['schemas']['ItemResponse_WorkspaceResponse_'] diff --git a/packages/hydroserver-ts/src/generated/data.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts similarity index 98% rename from packages/hydroserver-ts/src/generated/data.types.d.ts rename to packages/hydroserver-ts/src/generated/ogc.types.d.ts index b5408df2..31a840c1 100644 --- a/packages/hydroserver-ts/src/generated/data.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/api/data/aggregation-statistics": { + "/api/ogc/collections/aggregation-statistics/items": { parameters: { query?: never; header?: never; @@ -28,7 +28,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/aggregation-statistics/{aggregation_statistic_id}": { + "/api/ogc/collections/aggregation-statistics/items/{aggregation_statistic_id}": { parameters: { query?: never; header?: never; @@ -56,7 +56,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_aggregation_statistic_update_aggregation_statistic"]; trace?: never; }; - "/api/data/data-product-rating-curves": { + "/api/ogc/collections/data-product-rating-curves/items": { parameters: { query?: never; header?: never; @@ -80,7 +80,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/data-product-rating-curves/{rating_curve_id}": { + "/api/ogc/collections/data-product-rating-curves/items/{rating_curve_id}": { parameters: { query?: never; header?: never; @@ -108,7 +108,7 @@ export interface paths { patch: operations["interfaces_api_views_products_rating_curve_update_rating_curve"]; trace?: never; }; - "/api/data/data-product-tasks": { + "/api/ogc/collections/data-product-tasks/items": { parameters: { query?: never; header?: never; @@ -132,7 +132,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/data-product-tasks/{task_id}": { + "/api/ogc/collections/data-product-tasks/items/{task_id}": { parameters: { query?: never; header?: never; @@ -160,7 +160,7 @@ export interface paths { patch: operations["interfaces_api_views_products_task_update_data_product_task"]; trace?: never; }; - "/api/data/data-product-tasks/{task_id}/runs": { + "/api/ogc/collections/data-product-tasks/items/{task_id}/runs": { parameters: { query?: never; header?: never; @@ -180,7 +180,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/data-product-tasks/{task_id}/runs/{run_id}": { + "/api/ogc/collections/data-product-tasks/items/{task_id}/runs/{run_id}": { parameters: { query?: never; header?: never; @@ -200,7 +200,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/data-product-tasks/{task_id}/trigger": { + "/api/ogc/collections/data-product-tasks/items/{task_id}/trigger": { parameters: { query?: never; header?: never; @@ -220,7 +220,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/data-product-transformations": { + "/api/ogc/collections/data-product-transformations/items": { parameters: { query?: never; header?: never; @@ -244,7 +244,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/data-product-transformations/{transformation_id}": { + "/api/ogc/collections/data-product-transformations/items/{transformation_id}": { parameters: { query?: never; header?: never; @@ -272,7 +272,7 @@ export interface paths { patch: operations["interfaces_api_views_products_transformation_update_data_product_transformation"]; trace?: never; }; - "/api/data/datastream-statuses": { + "/api/ogc/collections/datastream-statuses/items": { parameters: { query?: never; header?: never; @@ -296,7 +296,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/datastream-statuses/{datastream_status_id}": { + "/api/ogc/collections/datastream-statuses/items/{datastream_status_id}": { parameters: { query?: never; header?: never; @@ -324,7 +324,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_datastream_status_update_datastream_status"]; trace?: never; }; - "/api/data/datastreams": { + "/api/ogc/collections/datastreams/items": { parameters: { query?: never; header?: never; @@ -348,47 +348,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/datastreams/tags/keys": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Datastream Tag Keys - * @description Get all existing unique datastream tag keys. - */ - get: operations["interfaces_api_views_sta_datastream_get_datastream_tag_keys"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/data/datastreams/visualization-bootstrap": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Datastream Visualization Bootstrap - * @description Get the lean metadata required to bootstrap the visualization page. - */ - get: operations["interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/data/datastreams/{datastream_id}": { + "/api/ogc/collections/datastreams/items/{datastream_id}": { parameters: { query?: never; header?: never; @@ -416,7 +376,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_datastream_update_datastream"]; trace?: never; }; - "/api/data/datastreams/{datastream_id}/csv": { + "/api/ogc/collections/datastreams/items/{datastream_id}/csv": { parameters: { query?: never; header?: never; @@ -436,7 +396,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/datastreams/{datastream_id}/linked-resources": { + "/api/ogc/collections/datastreams/items/{datastream_id}/linked-resources": { parameters: { query?: never; header?: never; @@ -460,7 +420,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/datastreams/{datastream_id}/linked-resources/{linked_resource_id}": { + "/api/ogc/collections/datastreams/items/{datastream_id}/linked-resources/{linked_resource_id}": { parameters: { query?: never; header?: never; @@ -485,7 +445,47 @@ export interface paths { patch: operations["interfaces_api_views_sta_datastream_update_datastream_linked_resource"]; trace?: never; }; - "/api/data/etl-data-connections": { + "/api/ogc/collections/datastreams/tags/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Datastream Tag Keys + * @description Get all existing unique datastream tag keys. + */ + get: operations["interfaces_api_views_sta_datastream_get_datastream_tag_keys"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/ogc/collections/datastreams/visualization-bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Datastream Visualization Bootstrap + * @description Get the lean metadata required to bootstrap the visualization page. + */ + get: operations["interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/ogc/collections/etl-data-connections/items": { parameters: { query?: never; header?: never; @@ -509,7 +509,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/etl-data-connections/{data_connection_id}": { + "/api/ogc/collections/etl-data-connections/items/{data_connection_id}": { parameters: { query?: never; header?: never; @@ -537,7 +537,7 @@ export interface paths { patch: operations["interfaces_api_views_etl_data_connection_update_data_connection"]; trace?: never; }; - "/api/data/etl-mappings": { + "/api/ogc/collections/etl-mappings/items": { parameters: { query?: never; header?: never; @@ -561,7 +561,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/etl-mappings/{mapping_id}": { + "/api/ogc/collections/etl-mappings/items/{mapping_id}": { parameters: { query?: never; header?: never; @@ -589,7 +589,7 @@ export interface paths { patch: operations["interfaces_api_views_etl_mapping_update_etl_mapping"]; trace?: never; }; - "/api/data/etl-tasks": { + "/api/ogc/collections/etl-tasks/items": { parameters: { query?: never; header?: never; @@ -613,7 +613,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/etl-tasks/{task_id}": { + "/api/ogc/collections/etl-tasks/items/{task_id}": { parameters: { query?: never; header?: never; @@ -641,7 +641,7 @@ export interface paths { patch: operations["interfaces_api_views_etl_task_update_etl_task"]; trace?: never; }; - "/api/data/etl-tasks/{task_id}/runs": { + "/api/ogc/collections/etl-tasks/items/{task_id}/runs": { parameters: { query?: never; header?: never; @@ -661,7 +661,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/etl-tasks/{task_id}/runs/{run_id}": { + "/api/ogc/collections/etl-tasks/items/{task_id}/runs/{run_id}": { parameters: { query?: never; header?: never; @@ -681,7 +681,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/etl-tasks/{task_id}/trigger": { + "/api/ogc/collections/etl-tasks/items/{task_id}/trigger": { parameters: { query?: never; header?: never; @@ -701,7 +701,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/linked-resource-types": { + "/api/ogc/collections/linked-resource-types/items": { parameters: { query?: never; header?: never; @@ -725,7 +725,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/linked-resource-types/{linked_resource_type_id}": { + "/api/ogc/collections/linked-resource-types/items/{linked_resource_type_id}": { parameters: { query?: never; header?: never; @@ -753,7 +753,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_linked_resource_type_update_linked_resource_type"]; trace?: never; }; - "/api/data/method-types": { + "/api/ogc/collections/method-types/items": { parameters: { query?: never; header?: never; @@ -777,7 +777,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/method-types/{method_type_id}": { + "/api/ogc/collections/method-types/items/{method_type_id}": { parameters: { query?: never; header?: never; @@ -805,7 +805,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_method_type_update_method_type"]; trace?: never; }; - "/api/data/methods": { + "/api/ogc/collections/methods/items": { parameters: { query?: never; header?: never; @@ -829,7 +829,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/methods/{method_id}": { + "/api/ogc/collections/methods/items/{method_id}": { parameters: { query?: never; header?: never; @@ -857,7 +857,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_method_update_method"]; trace?: never; }; - "/api/data/monitoring-rules": { + "/api/ogc/collections/monitoring-rules/items": { parameters: { query?: never; header?: never; @@ -881,7 +881,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-rules/{rule_id}": { + "/api/ogc/collections/monitoring-rules/items/{rule_id}": { parameters: { query?: never; header?: never; @@ -909,7 +909,7 @@ export interface paths { patch: operations["interfaces_api_views_monitoring_rule_update_monitoring_rule"]; trace?: never; }; - "/api/data/monitoring-site-types": { + "/api/ogc/collections/monitoring-site-types/items": { parameters: { query?: never; header?: never; @@ -933,7 +933,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-site-types/{monitoring_site_type_id}": { + "/api/ogc/collections/monitoring-site-types/items/{monitoring_site_type_id}": { parameters: { query?: never; header?: never; @@ -961,7 +961,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_monitoring_site_type_update_monitoring_site_type"]; trace?: never; }; - "/api/data/monitoring-sites": { + "/api/ogc/collections/monitoring-sites/items": { parameters: { query?: never; header?: never; @@ -985,7 +985,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-sites/markers": { + "/api/ogc/collections/monitoring-sites/items/{monitoring_site_id}": { parameters: { query?: never; header?: never; @@ -993,19 +993,27 @@ export interface paths { cookie?: never; }; /** - * Get Monitoring Site Markers - * @description Get lean marker data for public MonitoringSites plus private MonitoringSites visible to the authenticated user. + * Get Monitoring Site + * @description Get a MonitoringSite. */ - get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site"]; put?: never; post?: never; - delete?: never; + /** + * Delete Monitoring Site + * @description Delete a MonitoringSite. + */ + delete: operations["interfaces_api_views_sta_monitoring_site_delete_monitoring_site"]; options?: never; head?: never; - patch?: never; + /** + * Update Monitoring Site + * @description Update a MonitoringSite. + */ + patch: operations["interfaces_api_views_sta_monitoring_site_update_monitoring_site"]; trace?: never; }; - "/api/data/monitoring-sites/site-summaries": { + "/api/ogc/collections/monitoring-sites/items/{monitoring_site_id}/linked-resources": { parameters: { query?: never; header?: never; @@ -1013,39 +1021,48 @@ export interface paths { cookie?: never; }; /** - * Get Monitoring Site Summaries - * @description Get lean site summary data for public MonitoringSites and MonitoringSites associated with the authenticated user. + * Get Monitoring Site Linked Resources + * @description Get all linked resources associated with a MonitoringSite. */ - get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_summaries"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resources"]; put?: never; - post?: never; + /** + * Add Monitoring Site Linked Resource + * @description Add a linked resource to a monitoring_site. + */ + post: operations["interfaces_api_views_sta_monitoring_site_add_monitoring_site_linked_resource"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/data/monitoring-sites/site-type-icons": { + "/api/ogc/collections/monitoring-sites/items/{monitoring_site_id}/linked-resources/{linked_resource_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get Site Type Icons - * @description Get the configured site type icon mappings. - */ - get: operations["interfaces_api_views_sta_monitoring_site_get_site_type_icons"]; + get?: never; put?: never; post?: never; - delete?: never; + /** + * Remove Monitoring Site Linked Resource + * @description Remove a linked resource from a monitoring_site. + */ + delete: operations["interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource"]; options?: never; head?: never; - patch?: never; + /** + * Update Monitoring Site Linked Resource + * @description Update a linked resource for a monitoring_site. A linked resource's mode (hosted file vs. + * external URL) cannot be changed in place — delete it and create a new one instead. + */ + patch: operations["interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource"]; trace?: never; }; - "/api/data/monitoring-sites/tags/keys": { + "/api/ogc/collections/monitoring-sites/markers": { parameters: { query?: never; header?: never; @@ -1053,10 +1070,10 @@ export interface paths { cookie?: never; }; /** - * Get Monitoring Site Tag Keys - * @description Get all existing unique monitoring_site tag keys. + * Get Monitoring Site Markers + * @description Get lean marker data for public MonitoringSites plus private MonitoringSites visible to the authenticated user. */ - get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_tag_keys"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers"]; put?: never; post?: never; delete?: never; @@ -1065,7 +1082,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-sites/task-summaries": { + "/api/ogc/collections/monitoring-sites/site-summaries": { parameters: { query?: never; header?: never; @@ -1073,10 +1090,10 @@ export interface paths { cookie?: never; }; /** - * Get Monitoring Site Task Summaries - * @description Get task count summaries for MonitoringSites associated with the authenticated user. + * Get Monitoring Site Summaries + * @description Get lean site summary data for public MonitoringSites and MonitoringSites associated with the authenticated user. */ - get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_task_summaries"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_summaries"]; put?: never; post?: never; delete?: never; @@ -1085,7 +1102,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-sites/{monitoring_site_id}": { + "/api/ogc/collections/monitoring-sites/site-type-icons": { parameters: { query?: never; header?: never; @@ -1093,27 +1110,19 @@ export interface paths { cookie?: never; }; /** - * Get Monitoring Site - * @description Get a MonitoringSite. + * Get Site Type Icons + * @description Get the configured site type icon mappings. */ - get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_site_type_icons"]; put?: never; post?: never; - /** - * Delete Monitoring Site - * @description Delete a MonitoringSite. - */ - delete: operations["interfaces_api_views_sta_monitoring_site_delete_monitoring_site"]; + delete?: never; options?: never; head?: never; - /** - * Update Monitoring Site - * @description Update a MonitoringSite. - */ - patch: operations["interfaces_api_views_sta_monitoring_site_update_monitoring_site"]; + patch?: never; trace?: never; }; - "/api/data/monitoring-sites/{monitoring_site_id}/linked-resources": { + "/api/ogc/collections/monitoring-sites/tags/keys": { parameters: { query?: never; header?: never; @@ -1121,48 +1130,39 @@ export interface paths { cookie?: never; }; /** - * Get Monitoring Site Linked Resources - * @description Get all linked resources associated with a MonitoringSite. + * Get Monitoring Site Tag Keys + * @description Get all existing unique monitoring_site tag keys. */ - get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resources"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_tag_keys"]; put?: never; - /** - * Add Monitoring Site Linked Resource - * @description Add a linked resource to a monitoring_site. - */ - post: operations["interfaces_api_views_sta_monitoring_site_add_monitoring_site_linked_resource"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/data/monitoring-sites/{monitoring_site_id}/linked-resources/{linked_resource_id}": { + "/api/ogc/collections/monitoring-sites/task-summaries": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Remove Monitoring Site Linked Resource - * @description Remove a linked resource from a monitoring_site. + * Get Monitoring Site Task Summaries + * @description Get task count summaries for MonitoringSites associated with the authenticated user. */ - delete: operations["interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource"]; + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_task_summaries"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; - /** - * Update Monitoring Site Linked Resource - * @description Update a linked resource for a monitoring_site. A linked resource's mode (hosted file vs. - * external URL) cannot be changed in place — delete it and create a new one instead. - */ - patch: operations["interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource"]; + patch?: never; trace?: never; }; - "/api/data/monitoring-tasks": { + "/api/ogc/collections/monitoring-tasks/items": { parameters: { query?: never; header?: never; @@ -1186,7 +1186,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-tasks/{task_id}": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}": { parameters: { query?: never; header?: never; @@ -1214,7 +1214,7 @@ export interface paths { patch: operations["interfaces_api_views_monitoring_task_update_monitoring_task"]; trace?: never; }; - "/api/data/monitoring-tasks/{task_id}/runs": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}/runs": { parameters: { query?: never; header?: never; @@ -1234,7 +1234,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-tasks/{task_id}/runs/{run_id}": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}/runs/{run_id}": { parameters: { query?: never; header?: never; @@ -1254,7 +1254,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/monitoring-tasks/{task_id}/trigger": { + "/api/ogc/collections/monitoring-tasks/items/{task_id}/trigger": { parameters: { query?: never; header?: never; @@ -1274,31 +1274,27 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/observations": { + "/api/ogc/collections/observations/bulk-create": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get Observations - * @description Get Observations. - */ - get: operations["interfaces_api_views_sta_observation_get_observations"]; + get?: never; put?: never; /** - * Create Observation - * @description Create a new Observation. + * Insert Observations + * @description Insert Observations. */ - post: operations["interfaces_api_views_sta_observation_create_observation"]; + post: operations["interfaces_api_views_sta_observation_insert_observations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/data/observations/bulk-create": { + "/api/ogc/collections/observations/bulk-delete": { parameters: { query?: never; header?: never; @@ -1308,37 +1304,41 @@ export interface paths { get?: never; put?: never; /** - * Insert Observations - * @description Insert Observations. + * Delete Observations + * @description Delete Observations between the given phenomenon start and end times. */ - post: operations["interfaces_api_views_sta_observation_insert_observations"]; + post: operations["interfaces_api_views_sta_observation_delete_observations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/data/observations/bulk-delete": { + "/api/ogc/collections/observations/items": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get Observations + * @description Get Observations. + */ + get: operations["interfaces_api_views_sta_observation_get_observations"]; put?: never; /** - * Delete Observations - * @description Delete Observations between the given phenomenon start and end times. + * Create Observation + * @description Create a new Observation. */ - post: operations["interfaces_api_views_sta_observation_delete_observations"]; + post: operations["interfaces_api_views_sta_observation_create_observation"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/data/observations/{observation_id}": { + "/api/ogc/collections/observations/items/{observation_id}": { parameters: { query?: never; header?: never; @@ -1362,7 +1362,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/observed-properties": { + "/api/ogc/collections/observed-properties/items": { parameters: { query?: never; header?: never; @@ -1386,7 +1386,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/observed-properties/{observed_property_id}": { + "/api/ogc/collections/observed-properties/items/{observed_property_id}": { parameters: { query?: never; header?: never; @@ -1414,7 +1414,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_observed_property_update_observed_property"]; trace?: never; }; - "/api/data/observed-property-types": { + "/api/ogc/collections/observed-property-types/items": { parameters: { query?: never; header?: never; @@ -1438,7 +1438,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/observed-property-types/{observed_property_type_id}": { + "/api/ogc/collections/observed-property-types/items/{observed_property_type_id}": { parameters: { query?: never; header?: never; @@ -1466,7 +1466,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_observed_property_type_update_observed_property_type"]; trace?: never; }; - "/api/data/processing-levels": { + "/api/ogc/collections/processing-levels/items": { parameters: { query?: never; header?: never; @@ -1490,7 +1490,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/processing-levels/{processing_level_id}": { + "/api/ogc/collections/processing-levels/items/{processing_level_id}": { parameters: { query?: never; header?: never; @@ -1518,7 +1518,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_processing_level_update_processing_level"]; trace?: never; }; - "/api/data/quality-control/histories": { + "/api/ogc/collections/quality-control-histories/items": { parameters: { query?: never; header?: never; @@ -1542,7 +1542,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/quality-control/histories/{history_id}": { + "/api/ogc/collections/quality-control-histories/items/{history_id}": { parameters: { query?: never; header?: never; @@ -1566,7 +1566,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/quality-control/histories/{history_id}/sessions": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions": { parameters: { query?: never; header?: never; @@ -1590,7 +1590,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}": { parameters: { query?: never; header?: never; @@ -1618,7 +1618,7 @@ export interface paths { patch: operations["interfaces_api_views_quality_session_update_qc_session"]; trace?: never; }; - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}/commit": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}/commit": { parameters: { query?: never; header?: never; @@ -1638,7 +1638,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}/operations": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}/operations": { parameters: { query?: never; header?: never; @@ -1662,7 +1662,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/quality-control/histories/{history_id}/sessions/{session_id}/operations/{operation_id}": { + "/api/ogc/collections/quality-control-histories/items/{history_id}/sessions/{session_id}/operations/{operation_id}": { parameters: { query?: never; header?: never; @@ -1690,7 +1690,7 @@ export interface paths { patch: operations["interfaces_api_views_quality_operation_update_qc_operation"]; trace?: never; }; - "/api/data/result-qualifiers": { + "/api/ogc/collections/result-qualifiers/items": { parameters: { query?: never; header?: never; @@ -1714,7 +1714,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/result-qualifiers/{result_qualifier_id}": { + "/api/ogc/collections/result-qualifiers/items/{result_qualifier_id}": { parameters: { query?: never; header?: never; @@ -1742,7 +1742,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_result_qualifier_update_result_qualifier"]; trace?: never; }; - "/api/data/roles": { + "/api/ogc/collections/roles/items": { parameters: { query?: never; header?: never; @@ -1762,7 +1762,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/roles/{role_id}": { + "/api/ogc/collections/roles/items/{role_id}": { parameters: { query?: never; header?: never; @@ -1782,7 +1782,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/sampled-mediums": { + "/api/ogc/collections/sampled-mediums/items": { parameters: { query?: never; header?: never; @@ -1806,7 +1806,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/sampled-mediums/{sampled_medium_id}": { + "/api/ogc/collections/sampled-mediums/items/{sampled_medium_id}": { parameters: { query?: never; header?: never; @@ -1834,7 +1834,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_sampled_medium_update_sampled_medium"]; trace?: never; }; - "/api/data/unit-types": { + "/api/ogc/collections/unit-types/items": { parameters: { query?: never; header?: never; @@ -1858,7 +1858,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/unit-types/{unit_type_id}": { + "/api/ogc/collections/unit-types/items/{unit_type_id}": { parameters: { query?: never; header?: never; @@ -1886,7 +1886,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_unit_type_update_unit_type"]; trace?: never; }; - "/api/data/units": { + "/api/ogc/collections/units/items": { parameters: { query?: never; header?: never; @@ -1910,7 +1910,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/units/{unit_id}": { + "/api/ogc/collections/units/items/{unit_id}": { parameters: { query?: never; header?: never; @@ -1938,7 +1938,7 @@ export interface paths { patch: operations["interfaces_api_views_sta_unit_update_unit"]; trace?: never; }; - "/api/data/workspaces": { + "/api/ogc/collections/workspaces/items": { parameters: { query?: never; header?: never; @@ -1962,7 +1962,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/workspaces/{workspace_id}": { + "/api/ogc/collections/workspaces/items/{workspace_id}": { parameters: { query?: never; header?: never; @@ -1990,7 +1990,7 @@ export interface paths { patch: operations["interfaces_api_views_iam_workspace_update_workspace"]; trace?: never; }; - "/api/data/workspaces/{workspace_id}/collaborators": { + "/api/ogc/collections/workspaces/items/{workspace_id}/collaborators": { parameters: { query?: never; header?: never; @@ -2022,7 +2022,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/workspaces/{workspace_id}/service-accounts": { + "/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts": { parameters: { query?: never; header?: never; @@ -2046,7 +2046,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/workspaces/{workspace_id}/service-accounts/{service_account_id}": { + "/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts/{service_account_id}": { parameters: { query?: never; header?: never; @@ -2074,7 +2074,7 @@ export interface paths { patch: operations["interfaces_api_views_iam_service_account_update_service_account"]; trace?: never; }; - "/api/data/workspaces/{workspace_id}/service-accounts/{service_account_id}/regenerate": { + "/api/ogc/collections/workspaces/items/{workspace_id}/service-accounts/{service_account_id}/regenerate": { parameters: { query?: never; header?: never; @@ -2094,7 +2094,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/data/workspaces/{workspace_id}/transfer": { + "/api/ogc/collections/workspaces/items/{workspace_id}/transfer": { parameters: { query?: never; header?: never; @@ -8612,14 +8612,18 @@ export interface operations { }; }; }; - interfaces_api_views_sta_datastream_get_datastream_tag_keys: { + interfaces_api_views_sta_datastream_get_datastream: { parameters: { query?: { - workspace_id?: string | null; - datastream_id?: string | null; + /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + /** @description Comma-separated list of related resources to include in the response. */ + include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; }; header?: never; - path?: never; + path: { + datastream_id: string; + }; cookie?: never; }; requestBody?: never; @@ -8630,9 +8634,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string[]; - }; + "application/json": components["schemas"]["ItemResponse_DatastreamResponse_"]; }; }; /** @description Unauthorized */ @@ -8644,98 +8646,30 @@ export interface operations { "application/json": string; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; }; }; - interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap: { + interfaces_api_views_sta_datastream_delete_datastream: { parameters: { - query?: { - /** @description Filter visualization bootstrap datastreams by workspace ID. */ - workspace_id?: string[]; - }; + query?: never; header?: never; - path?: never; + path: { + datastream_id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DatastreamVisualizationBootstrapResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - interfaces_api_views_sta_datastream_get_datastream: { - parameters: { - query?: { - /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; - /** @description Comma-separated list of related resources to include in the response. */ - include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; - }; - header?: never; - path: { - datastream_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ItemResponse_DatastreamResponse_"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - interfaces_api_views_sta_datastream_delete_datastream: { - parameters: { - query?: never; - header?: never; - path: { - datastream_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -9103,6 +9037,72 @@ export interface operations { }; }; }; + interfaces_api_views_sta_datastream_get_datastream_tag_keys: { + parameters: { + query?: { + workspace_id?: string | null; + datastream_id?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string[]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap: { + parameters: { + query?: { + /** @description Filter visualization bootstrap datastreams by workspace ID. */ + workspace_id?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DatastreamVisualizationBootstrapResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; interfaces_api_views_etl_data_connection_get_data_connections: { parameters: { query?: { @@ -11424,18 +11424,18 @@ export interface operations { }; }; }; - interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers: { + interfaces_api_views_sta_monitoring_site_get_monitoring_site: { parameters: { query?: { - /** @description Filter markers by workspace ID. */ - workspace_id?: string[]; - /** @description Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat} */ - bbox?: string[]; - /** @description Filter markers by monitoring site type. */ - type?: string[]; + /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ + properties?: ("id" | "workspaceId" | "name" | "description" | "code" | "type" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "dataDisclaimer" | "isPrivate" | "tags" | "linkedResources")[]; + /** @description Comma-separated list of related resources to include in the response. */ + include?: ("workspace" | "type")[]; }; header?: never; - path?: never; + path: { + monitoring_site_id: string; + }; cookie?: never; }; requestBody?: never; @@ -11446,7 +11446,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MonitoringSiteMarkerResponse"][]; + "application/json": components["schemas"]["ItemResponse_MonitoringSiteResponse_"]; }; }; /** @description Unauthorized */ @@ -11458,30 +11458,34 @@ export interface operations { "application/json": string; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; }; }; - interfaces_api_views_sta_monitoring_site_get_monitoring_site_summaries: { + interfaces_api_views_sta_monitoring_site_delete_monitoring_site: { parameters: { - query?: { - /** @description Filter site summaries by workspace ID. */ - workspace_id?: string[]; - /** @description Filter summaries by monitoring site type. */ - type?: string[]; - }; + query?: never; header?: never; - path?: never; + path: { + monitoring_site_id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["MonitoringSiteMapSummaryResponse"][]; - }; + content?: never; }; /** @description Unauthorized */ 401: { @@ -11492,49 +11496,46 @@ export interface operations { "application/json": string; }; }; - }; - }; - interfaces_api_views_sta_monitoring_site_get_site_type_icons: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SiteTypeIconResponse"][]; + "application/json": string; }; }; }; }; - interfaces_api_views_sta_monitoring_site_get_monitoring_site_tag_keys: { + interfaces_api_views_sta_monitoring_site_update_monitoring_site: { parameters: { - query?: { - workspace_id?: string | null; - monitoring_site_id?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + monitoring_site_id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["MonitoringSitePatchBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string[]; - }; + "application/json": string; }; }; /** @description Unauthorized */ @@ -11546,49 +11547,30 @@ export interface operations { "application/json": string; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; }; }; - interfaces_api_views_sta_monitoring_site_get_monitoring_site_task_summaries: { - parameters: { - query?: { - /** @description Filter task summaries by workspace ID. */ - workspace_id?: string[]; - /** @description Filter summaries by monitoring site type. */ - type?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MonitoringSiteTaskSummaryResponse"][]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - interfaces_api_views_sta_monitoring_site_get_monitoring_site: { + interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resources: { parameters: { query?: { /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "code" | "type" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "dataDisclaimer" | "isPrivate" | "tags" | "linkedResources")[]; + properties?: string | null; /** @description Comma-separated list of related resources to include in the response. */ - include?: ("workspace" | "type")[]; + include?: string | null; + /** @description Number of items to skip. */ + offset?: number | null; + /** @description The maximum number of items to return. */ + limit?: number | null; + /** @description Filter by linked resource type. */ + type?: string[]; }; header?: never; path: { @@ -11604,46 +11586,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ItemResponse_MonitoringSiteResponse_"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - interfaces_api_views_sta_monitoring_site_delete_monitoring_site: { - parameters: { - query?: never; - header?: never; - path: { - monitoring_site_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["LinkedResourceGetResponse"][]; }; - content?: never; }; /** @description Unauthorized */ 401: { @@ -11665,7 +11609,7 @@ export interface operations { }; }; }; - interfaces_api_views_sta_monitoring_site_update_monitoring_site: { + interfaces_api_views_sta_monitoring_site_add_monitoring_site_linked_resource: { parameters: { query?: never; header?: never; @@ -11676,16 +11620,29 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["MonitoringSitePatchBody"]; + "multipart/form-data": { + /** Description */ + description?: string | null; + /** File */ + file?: string | null; + /** Link */ + link?: string | null; + /** Name */ + name: string; + /** Type */ + type: string; + }; }; }; responses: { - /** @description No Content */ - 204: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CreatedResponse"]; + }; }; /** @description Bad Request */ 400: { @@ -11714,38 +11671,35 @@ export interface operations { "application/json": string; }; }; + /** @description Content Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; }; }; - interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resources: { + interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource: { parameters: { - query?: { - /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: string | null; - /** @description Comma-separated list of related resources to include in the response. */ - include?: string | null; - /** @description Number of items to skip. */ - offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; - /** @description Filter by linked resource type. */ - type?: string[]; - }; + query?: never; header?: never; path: { monitoring_site_id: string; + linked_resource_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["LinkedResourceGetResponse"][]; - }; + content?: never; }; /** @description Unauthorized */ 401: { @@ -11765,14 +11719,24 @@ export interface operations { "application/json": string; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; }; }; - interfaces_api_views_sta_monitoring_site_add_monitoring_site_linked_resource: { + interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource: { parameters: { query?: never; header?: never; path: { monitoring_site_id: string; + linked_resource_id: string; }; cookie?: never; }; @@ -11786,21 +11750,19 @@ export interface operations { /** Link */ link?: string | null; /** Name */ - name: string; + name?: string | null; /** Type */ - type: string; + type?: string | null; }; }; }; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CreatedResponse"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -11829,6 +11791,15 @@ export interface operations { "application/json": string; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; /** @description Content Too Large */ 413: { headers: { @@ -11840,24 +11811,30 @@ export interface operations { }; }; }; - interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource: { + interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers: { parameters: { - query?: never; - header?: never; - path: { - monitoring_site_id: string; - linked_resource_id: string; + query?: { + /** @description Filter markers by workspace ID. */ + workspace_id?: string[]; + /** @description Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat} */ + bbox?: string[]; + /** @description Filter markers by monitoring site type. */ + type?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["MonitoringSiteMarkerResponse"][]; + }; }; /** @description Unauthorized */ 401: { @@ -11868,17 +11845,33 @@ export interface operations { "application/json": string; }; }; - /** @description Forbidden */ - 403: { + }; + }; + interfaces_api_views_sta_monitoring_site_get_monitoring_site_summaries: { + parameters: { + query?: { + /** @description Filter site summaries by workspace ID. */ + workspace_id?: string[]; + /** @description Filter summaries by monitoring site type. */ + type?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": components["schemas"]["MonitoringSiteMapSummaryResponse"][]; }; }; - /** @description Not Found */ - 404: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -11888,47 +11881,47 @@ export interface operations { }; }; }; - interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource: { + interfaces_api_views_sta_monitoring_site_get_site_type_icons: { parameters: { query?: never; header?: never; - path: { - monitoring_site_id: string; - linked_resource_id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "multipart/form-data": { - /** Description */ - description?: string | null; - /** File */ - file?: string | null; - /** Link */ - link?: string | null; - /** Name */ - name?: string | null; - /** Type */ - type?: string | null; - }; - }; - }; + requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["SiteTypeIconResponse"][]; + }; }; - /** @description Bad Request */ - 400: { + }; + }; + interfaces_api_views_sta_monitoring_site_get_monitoring_site_tag_keys: { + parameters: { + query?: { + workspace_id?: string | null; + monitoring_site_id?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": { + [key: string]: string[]; + }; }; }; /** @description Unauthorized */ @@ -11940,26 +11933,33 @@ export interface operations { "application/json": string; }; }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; + }; + }; + interfaces_api_views_sta_monitoring_site_get_monitoring_site_task_summaries: { + parameters: { + query?: { + /** @description Filter task summaries by workspace ID. */ + workspace_id?: string[]; + /** @description Filter summaries by monitoring site type. */ + type?: string[]; }; - /** @description Not Found */ - 404: { + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": components["schemas"]["MonitoringSiteTaskSummaryResponse"][]; }; }; - /** @description Content Too Large */ - 413: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -12411,44 +12411,28 @@ export interface operations { }; }; }; - interfaces_api_views_sta_observation_get_observations: { + interfaces_api_views_sta_observation_insert_observations: { parameters: { query?: { - /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record. */ - properties?: ("id" | "workspaceId" | "datastreamId" | "phenomenonTime" | "result" | "resultQualifierCodes")[]; - /** @description Comma-separated list of related resources to include in the response. Only applies to format=record. */ - include?: ("datastream" | "workspace" | "resultQualifiers")[]; - /** @description Number of items to skip. */ - offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; - /** @description Filter observations by datastream ID. */ - datastream_id?: string[]; - /** @description Select one or more fields to sort the response by. */ - sortby?: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId")[] | null; - /** @description Controls the format of the observations response. */ - format?: ("record" | "row" | "column") | null; - /** @description Sets the maximum phenomenon time of filtered observations. */ - phenomenon_time_max?: string | null; - /** @description Sets the minimum phenomenon time of filtered observations. */ - phenomenon_time_min?: string | null; - /** @description Filter observations by result qualifier code. */ - result_qualifier_code?: string[]; + /** @description Specifies how new observations are added to the datastream. `insert` allows observations at any timestamp. `append` adds only future observations (after the latest existing timestamp). `backfill` adds only historical observations (before the earliest existing timestamp). `replace` deletes all observations in the range of provided observations before inserting new ones. */ + mode?: ("insert" | "append" | "backfill" | "replace") | null; }; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ObservationBulkPostBody"] | components["schemas"]["ObservationBulkColumnarPostBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["PaginatedResponse_ObservationResponse_"] | components["schemas"]["ObservationRowResponse"] | components["schemas"]["ObservationColumnarResponse"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -12479,7 +12463,7 @@ export interface operations { }; }; }; - interfaces_api_views_sta_observation_create_observation: { + interfaces_api_views_sta_observation_delete_observations: { parameters: { query?: never; header?: never; @@ -12488,30 +12472,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ObservationPostBody"]; + "application/json": components["schemas"]["ObservationBulkDeleteBody"]; }; }; responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CreatedResponse"]; - }; - }; - /** @description Bad Request */ - 400: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": string; - }; + content?: never; }; - /** @description Unauthorized */ - 401: { + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -12519,8 +12492,8 @@ export interface operations { "application/json": string; }; }; - /** @description Forbidden */ - 403: { + /** @description Not Found */ + 404: { headers: { [name: string]: unknown; }; @@ -12530,28 +12503,44 @@ export interface operations { }; }; }; - interfaces_api_views_sta_observation_insert_observations: { + interfaces_api_views_sta_observation_get_observations: { parameters: { query?: { - /** @description Specifies how new observations are added to the datastream. `insert` allows observations at any timestamp. `append` adds only future observations (after the latest existing timestamp). `backfill` adds only historical observations (before the earliest existing timestamp). `replace` deletes all observations in the range of provided observations before inserting new ones. */ - mode?: ("insert" | "append" | "backfill" | "replace") | null; + /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record. */ + properties?: ("id" | "workspaceId" | "datastreamId" | "phenomenonTime" | "result" | "resultQualifierCodes")[]; + /** @description Comma-separated list of related resources to include in the response. Only applies to format=record. */ + include?: ("datastream" | "workspace" | "resultQualifiers")[]; + /** @description Number of items to skip. */ + offset?: number | null; + /** @description The maximum number of items to return. */ + limit?: number | null; + /** @description Filter observations by datastream ID. */ + datastream_id?: string[]; + /** @description Select one or more fields to sort the response by. */ + sortby?: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId")[] | null; + /** @description Controls the format of the observations response. */ + format?: ("record" | "row" | "column") | null; + /** @description Sets the maximum phenomenon time of filtered observations. */ + phenomenon_time_max?: string | null; + /** @description Sets the minimum phenomenon time of filtered observations. */ + phenomenon_time_min?: string | null; + /** @description Filter observations by result qualifier code. */ + result_qualifier_code?: string[]; }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ObservationBulkPostBody"] | components["schemas"]["ObservationBulkColumnarPostBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PaginatedResponse_ObservationResponse_"] | components["schemas"]["ObservationRowResponse"] | components["schemas"]["ObservationColumnarResponse"]; + }; }; /** @description Bad Request */ 400: { @@ -12582,7 +12571,7 @@ export interface operations { }; }; }; - interfaces_api_views_sta_observation_delete_observations: { + interfaces_api_views_sta_observation_create_observation: { parameters: { query?: never; header?: never; @@ -12591,19 +12580,21 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ObservationBulkDeleteBody"]; + "application/json": components["schemas"]["ObservationPostBody"]; }; }; responses: { - /** @description No Content */ - 204: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CreatedResponse"]; + }; }; - /** @description Forbidden */ - 403: { + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -12611,8 +12602,17 @@ export interface operations { "application/json": string; }; }; - /** @description Not Found */ - 404: { + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; diff --git a/packages/hydroserverpy/src/hydroserverpy/api/client.py b/packages/hydroserverpy/src/hydroserverpy/api/client.py index c413d744..de6acce6 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/client.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/client.py @@ -26,7 +26,7 @@ class HydroServer: def __init__( self, host: str, - base_route: str = "/api/data", + base_route: str = "/api/ogc", email: Optional[str] = None, password: Optional[str] = None, apikey: Optional[str] = None, diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/etl/data_connection.py b/packages/hydroserverpy/src/hydroserverpy/api/models/etl/data_connection.py index 823dbde5..bee71f37 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/etl/data_connection.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/etl/data_connection.py @@ -109,4 +109,4 @@ def save(self): @classmethod def get_route(cls): - return "etl-data-connections" + return "collections/etl-data-connections/items" diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/etl/mapping.py b/packages/hydroserverpy/src/hydroserverpy/api/models/etl/mapping.py index 66f893a5..c4d2ca83 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/etl/mapping.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/etl/mapping.py @@ -23,4 +23,4 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "etl-mappings" + return "collections/etl-mappings/items" diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/etl/task.py b/packages/hydroserverpy/src/hydroserverpy/api/models/etl/task.py index cc233c12..763684f2 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/etl/task.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/etl/task.py @@ -62,7 +62,7 @@ def save(self): @classmethod def get_route(cls): - return "etl-tasks" + return "collections/etl-tasks/items" @cached_property def mappings(self) -> List[EtlMapping]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/iam/role.py b/packages/hydroserverpy/src/hydroserverpy/api/models/iam/role.py index 0a7426bd..891d2bc4 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/iam/role.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/iam/role.py @@ -20,7 +20,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "roles" + return "collections/roles/items" @property def workspace(self) -> "Workspace": diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/iam/workspace.py b/packages/hydroserverpy/src/hydroserverpy/api/models/iam/workspace.py index bc546514..22a30b2c 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/iam/workspace.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/iam/workspace.py @@ -55,7 +55,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "workspaces" + return "collections/workspaces/items" @property def roles(self) -> List["Role"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/rule.py b/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/rule.py index a71f32e1..385dcb05 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/rule.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/rule.py @@ -32,4 +32,4 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "monitoring-rules" + return "collections/monitoring-rules/items" diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/task.py b/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/task.py index 8b206204..df20928a 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/task.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/monitoring/task.py @@ -63,7 +63,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "monitoring-tasks" + return "collections/monitoring-tasks/items" @cached_property def rules(self): diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/products/rating_curve.py b/packages/hydroserverpy/src/hydroserverpy/api/models/products/rating_curve.py index d747db02..d92f45d3 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/products/rating_curve.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/products/rating_curve.py @@ -41,4 +41,4 @@ def save(self): @classmethod def get_route(cls): - return "data-product-rating-curves" + return "collections/data-product-rating-curves/items" diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/products/task.py b/packages/hydroserverpy/src/hydroserverpy/api/models/products/task.py index 0be63ffc..ba3929c8 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/products/task.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/products/task.py @@ -38,7 +38,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "data-product-tasks" + return "collections/data-product-tasks/items" @cached_property def rating_curve_transformations(self) -> List["RatingCurveTransformation"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py index 5f512a62..4c330fa3 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py @@ -70,7 +70,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "datastreams" + return "collections/datastreams/items" @property def workspace(self) -> "Workspace": @@ -219,7 +219,7 @@ def delete_observations( def sync_phenomenon_end_time(self): """Ensures the phenomenon_end_time field matches the actual end time of the observations.""" - path = f"/{self.client.base_route}/observations" + path = f"/{self.client.base_route}/collections/observations/items" response = self.client.request( "get", path, diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/method.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/method.py index 3900dc1d..aa5e9726 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/method.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/method.py @@ -37,7 +37,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "methods" + return "collections/methods/items" @property def workspace(self) -> Optional["Workspace"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/monitoring_site.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/monitoring_site.py index aef7d197..ce192517 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/monitoring_site.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/monitoring_site.py @@ -44,7 +44,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "monitoring-sites" + return "collections/monitoring-sites/items" @property def workspace(self) -> "Workspace": diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/observed_property.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/observed_property.py index de577775..72c697d7 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/observed_property.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/observed_property.py @@ -25,7 +25,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "observed-properties" + return "collections/observed-properties/items" @property def workspace(self) -> Optional["Workspace"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/processing_level.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/processing_level.py index eeb26556..ba5af1fc 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/processing_level.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/processing_level.py @@ -29,7 +29,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "processing-levels" + return "collections/processing-levels/items" @property def workspace(self) -> Optional["Workspace"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/result_qualifier.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/result_qualifier.py index 8591eff1..a45e3165 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/result_qualifier.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/result_qualifier.py @@ -22,7 +22,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "result-qualifiers" + return "collections/result-qualifiers/items" @property def workspace(self) -> Optional["Workspace"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/unit.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/unit.py index b13c8b4c..a401cd55 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/unit.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/unit.py @@ -24,7 +24,7 @@ def __init__(self, client: "HydroServer", **data): @classmethod def get_route(cls): - return "units" + return "collections/units/items" @property def workspace(self) -> Optional["Workspace"]: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/products/transformation.py b/packages/hydroserverpy/src/hydroserverpy/api/services/products/transformation.py index fba949ce..73ab43cd 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/products/transformation.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/products/transformation.py @@ -32,7 +32,7 @@ def default_serializer(obj): raise TypeError(f"Type {type(obj)} not serializable") def _route(self) -> str: - return f"/{self.client.base_route}/data-product-transformations" + return f"/{self.client.base_route}/collections/data-product-transformations/items" def _list( self, diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py index f1934645..89e7e208 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py @@ -262,7 +262,7 @@ def get_observations( if v is not ... } - path = f"/{self.client.base_route}/observations" + path = f"/{self.client.base_route}/collections/observations/items" response = self.client.request("get", path, params=params) datastream = self.get(uid=uid) collection = ObservationCollection( @@ -288,7 +288,7 @@ def load_observations( ) -> None: """Load observations to a datastream.""" - path = f"/{self.client.base_route}/observations/bulk-create" + path = f"/{self.client.base_route}/collections/observations/bulk-create" headers = {"Content-type": "application/json"} params = {"mode": mode} body = { @@ -309,7 +309,7 @@ def delete_observations( ) -> None: """Delete observations from a datastream.""" - path = f"/{self.client.base_route}/observations/bulk-delete" + path = f"/{self.client.base_route}/collections/observations/bulk-delete" headers = {"Content-type": "application/json"} body = {"datastreamId": str(uid)} diff --git a/packages/hydroserverpy/tests/api/test_base_service.py b/packages/hydroserverpy/tests/api/test_base_service.py index 0dbccdea..2f916d50 100644 --- a/packages/hydroserverpy/tests/api/test_base_service.py +++ b/packages/hydroserverpy/tests/api/test_base_service.py @@ -32,7 +32,7 @@ def json(self): def make_client(): client = MagicMock() - client.base_route = "/api/data" + client.base_route = "/api/ogc" return client diff --git a/packages/hydroserverpy/tests/api/test_collection.py b/packages/hydroserverpy/tests/api/test_collection.py index 5453e756..5f9b81db 100644 --- a/packages/hydroserverpy/tests/api/test_collection.py +++ b/packages/hydroserverpy/tests/api/test_collection.py @@ -32,7 +32,7 @@ def json(self): def make_client(): client = MagicMock() - client.base_route = "/api/data" + client.base_route = "/api/ogc" return client diff --git a/packages/hydroserverpy/tests/api/test_etl_mapping.py b/packages/hydroserverpy/tests/api/test_etl_mapping.py index f591ae4c..86046d1a 100644 --- a/packages/hydroserverpy/tests/api/test_etl_mapping.py +++ b/packages/hydroserverpy/tests/api/test_etl_mapping.py @@ -18,7 +18,7 @@ def json(self): def make_client(): client = MagicMock() - client.base_route = "/api/data" + client.base_route = "/api/ogc" return client @@ -49,12 +49,12 @@ def test_create_mapping_posts_to_flat_route_with_etl_task_id_in_body(): post_call = client.request.call_args_list[0] assert post_call.args[0] == "post" - assert post_call.args[1] == "//api/data/etl-mappings" + assert post_call.args[1] == "//api/ogc/collections/etl-mappings/items" sent_body = json.loads(post_call.kwargs["data"]) assert sent_body["etlTaskId"] == str(task_id) get_call = client.request.call_args_list[1] - assert get_call.args[1] == f"//api/data/etl-mappings/{mapping_id}" + assert get_call.args[1] == f"//api/ogc/collections/etl-mappings/items/{mapping_id}" assert str(mapping.uid) == mapping_id assert mapping.source_identifier == "sensor_1" @@ -87,7 +87,7 @@ def test_update_mapping_patches_flat_route_and_refetches_after_204(): patch_call = client.request.call_args_list[0] assert patch_call.args[0] == "patch" - assert patch_call.args[1] == f"//api/data/etl-mappings/{mapping_id}" + assert patch_call.args[1] == f"//api/ogc/collections/etl-mappings/items/{mapping_id}" sent_body = json.loads(patch_call.kwargs["data"]) assert sent_body == {"sourceIdentifier": "sensor_2"} diff --git a/packages/hydroserverpy/tests/api/test_metadata.py b/packages/hydroserverpy/tests/api/test_metadata.py index ee2232dc..bc9daf6b 100644 --- a/packages/hydroserverpy/tests/api/test_metadata.py +++ b/packages/hydroserverpy/tests/api/test_metadata.py @@ -19,7 +19,7 @@ (UnitService, {"type": "Length", "symbol": "m"}), ]) def test_create_metadata_without_optional_fields(service_class, fields): - client = MagicMock(base_route="api/data") + client = MagicMock(base_route="api/ogc") uid = str(uuid4()) payload = {"id": uid, "name": "Name", **fields} if service_class != ResultQualifierService: @@ -53,7 +53,7 @@ def test_create_metadata_without_optional_fields(service_class, fields): {"name": "Raw", "description": "Unprocessed", "code": "0"}), ]) def test_metadata_create_argument_mapping(service_class, args, kwargs, expected): - client = MagicMock(base_route="api/data") + client = MagicMock(base_route="api/ogc") uid, workspace_id = str(uuid4()), str(uuid4()) payload = {"id": uid, "workspaceId": workspace_id, **expected} client.request.side_effect = [ @@ -79,7 +79,7 @@ def test_metadata_create_argument_mapping(service_class, args, kwargs, expected) (ResultQualifierService, (), {"description": "Ice affected"}, "name"), ]) def test_invalid_metadata_create_calls_fail_before_sending_data(service_class, args, kwargs, error): - client = MagicMock(base_route="api/data") + client = MagicMock(base_route="api/ogc") with pytest.raises(TypeError, match=error): service_class(client).create(*args, **kwargs) client.request.assert_not_called() @@ -92,7 +92,7 @@ def test_invalid_metadata_create_calls_fail_before_sending_data(service_class, a {"code": "0", "name": "Raw", "description": "Unprocessed", "definition": "https://example.com/raw"}), ]) def test_metadata_update_preserves_positional_argument_mapping(service_class, args, kwargs, expected): - client = MagicMock(base_route="api/data") + client = MagicMock(base_route="api/ogc") uid = str(uuid4()) payload = {"id": uid, **expected} client.request.side_effect = [ diff --git a/packages/hydroserverpy/tests/api/test_monitoring_rule.py b/packages/hydroserverpy/tests/api/test_monitoring_rule.py index 6f425626..cdd348d7 100644 --- a/packages/hydroserverpy/tests/api/test_monitoring_rule.py +++ b/packages/hydroserverpy/tests/api/test_monitoring_rule.py @@ -18,7 +18,7 @@ def json(self): def make_client(): client = MagicMock() - client.base_route = "/api/data" + client.base_route = "/api/ogc" return client @@ -55,12 +55,12 @@ def test_create_rule_posts_to_flat_route_with_task_id_in_body(): post_call = client.request.call_args_list[0] assert post_call.args[0] == "post" - assert post_call.args[1] == "//api/data/monitoring-rules" + assert post_call.args[1] == "//api/ogc/collections/monitoring-rules/items" sent_body = json.loads(post_call.kwargs["data"]) assert sent_body["taskId"] == str(task_id) get_call = client.request.call_args_list[1] - assert get_call.args[1] == f"//api/data/monitoring-rules/{rule_id}" + assert get_call.args[1] == f"//api/ogc/collections/monitoring-rules/items/{rule_id}" assert str(rule.uid) == rule_id assert rule.task_id == task_id @@ -95,7 +95,7 @@ def test_update_rule_patches_flat_route_and_refetches_after_204(): patch_call = client.request.call_args_list[0] assert patch_call.args[0] == "patch" - assert patch_call.args[1] == f"//api/data/monitoring-rules/{rule_id}" + assert patch_call.args[1] == f"//api/ogc/collections/monitoring-rules/items/{rule_id}" sent_body = json.loads(patch_call.kwargs["data"]) assert sent_body == {"windowInterval": 2} diff --git a/packages/hydroserverpy/tests/api/test_service_account_and_collaborator.py b/packages/hydroserverpy/tests/api/test_service_account_and_collaborator.py index 0fa5f81c..63e8300c 100644 --- a/packages/hydroserverpy/tests/api/test_service_account_and_collaborator.py +++ b/packages/hydroserverpy/tests/api/test_service_account_and_collaborator.py @@ -18,7 +18,7 @@ def json(self): def make_client(): client = MagicMock() - client.base_route = "/api/data" + client.base_route = "/api/ogc" return client diff --git a/packages/hydroserverpy/tests/api/test_transformation.py b/packages/hydroserverpy/tests/api/test_transformation.py index 6b3ab83e..c8eea796 100644 --- a/packages/hydroserverpy/tests/api/test_transformation.py +++ b/packages/hydroserverpy/tests/api/test_transformation.py @@ -18,7 +18,7 @@ def json(self): def make_client(): client = MagicMock() - client.base_route = "/api/data" + client.base_route = "/api/ogc" return client @@ -56,7 +56,7 @@ def test_create_rating_curve_posts_to_flat_route_with_task_id_in_body(): post_call = client.request.call_args_list[0] assert post_call.args[0] == "post" - assert post_call.args[1] == "//api/data/data-product-transformations" + assert post_call.args[1] == "//api/ogc/collections/data-product-transformations/items" sent_body = json.loads(post_call.kwargs["data"]) assert sent_body["taskId"] == str(task_id) @@ -88,7 +88,7 @@ def test_get_rating_curve_hits_the_flat_detail_route_not_a_type_slug(): service.get_rating_curve(uid=transformation_id) args, _ = client.request.call_args - assert args[1] == f"//api/data/data-product-transformations/{transformation_id}" + assert args[1] == f"//api/ogc/collections/data-product-transformations/items/{transformation_id}" def test_list_derivation_filters_by_task_and_type_on_the_flat_route(): @@ -101,7 +101,7 @@ def test_list_derivation_filters_by_task_and_type_on_the_flat_route(): service.list_derivation(task_id=task_id) args, kwargs = client.request.call_args - assert args[1] == "//api/data/data-product-transformations" + assert args[1] == "//api/ogc/collections/data-product-transformations/items" assert kwargs["params"]["transformation_type"] == "derivation" assert kwargs["params"]["task_id"] == str(task_id) @@ -141,5 +141,5 @@ def test_update_aggregation_patches_flat_route_then_refetches_by_id(): patch_call = client.request.call_args_list[0] assert patch_call.args[0] == "patch" - assert patch_call.args[1] == f"//api/data/data-product-transformations/{transformation_id}" + assert patch_call.args[1] == f"//api/ogc/collections/data-product-transformations/items/{transformation_id}" assert str(transformation.input_datastream_id) == input_id diff --git a/packages/hydroserverpy/tests/test_api_client.py b/packages/hydroserverpy/tests/test_api_client.py index a6a7f066..58af95d7 100644 --- a/packages/hydroserverpy/tests/test_api_client.py +++ b/packages/hydroserverpy/tests/test_api_client.py @@ -115,7 +115,7 @@ def next_session(): monkeypatch.setattr(client_module.requests, "Session", next_session) - response = hs.request("get", "/api/data/workspaces") + response = hs.request("get", "/api/ogc/collections/workspaces/items") assert response is second_response assert fake_session_factory[0].closed is True From d395e1b88c354bfe0dc528030eaa3b357b175509 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 11:39:05 -0700 Subject: [PATCH 02/25] Fixed Ninja schema generation issue --- django/interfaces/api/http/api.py | 35 +++++++++++++++++++++++++++++++ django/interfaces/api/urls.py | 4 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 django/interfaces/api/http/api.py diff --git a/django/interfaces/api/http/api.py b/django/interfaces/api/http/api.py new file mode 100644 index 00000000..3a48b419 --- /dev/null +++ b/django/interfaces/api/http/api.py @@ -0,0 +1,35 @@ +from typing import Optional +from ninja import NinjaAPI +from ninja.openapi.schema import OpenAPISchema +from ninja.types import DictStrAny + +PARAMETER_SERIALIZATION_KEYS = ("style", "explode") + + +class HydroServerNinjaAPI(NinjaAPI): + """ + NinjaAPI that moves parameter serialization keywords (style, explode) from a query + parameter's JSON schema onto its OpenAPI Parameter Object, where OpenAPI reads them. + + Ninja copies a parameter's JSON schema into the Parameter Object's schema as-is, so + keywords declared there (e.g., by comma_array_schema) would otherwise be ignored and + clients would fall back to the default explode=true serialization for arrays. + """ + + def get_openapi_schema( + self, + *, + path_prefix: Optional[str] = None, + path_params: Optional[DictStrAny] = None, + ) -> OpenAPISchema: + schema = super().get_openapi_schema(path_prefix=path_prefix, path_params=path_params) + + for path_item in schema.get("paths", {}).values(): + for operation in path_item.values(): + for parameter in operation.get("parameters", []): + parameter_schema = parameter.get("schema", {}) + for key in PARAMETER_SERIALIZATION_KEYS: + if key in parameter_schema: + parameter[key] = parameter_schema.pop(key) + + return schema diff --git a/django/interfaces/api/urls.py b/django/interfaces/api/urls.py index 912c6706..5252fff8 100644 --- a/django/interfaces/api/urls.py +++ b/django/interfaces/api/urls.py @@ -1,4 +1,3 @@ -from ninja import NinjaAPI from ninja.throttling import AnonRateThrottle, AuthRateThrottle from django.conf import settings from django.urls import path, include @@ -6,6 +5,7 @@ from hydroserver import __version__ from interfaces.api.http import handlers +from interfaces.api.http.api import HydroServerNinjaAPI from interfaces.api.http.renderer import ORJSONRenderer from interfaces.api.views import ( @@ -44,7 +44,7 @@ rate_limits = settings.API_RATE_LIMITS or {} throttle_classes = {"anonymous": AnonRateThrottle, "authenticated": AuthRateThrottle} -api = NinjaAPI( +api = HydroServerNinjaAPI( title="HydroServer Data Management API", version=__version__, urls_namespace="ogc", From 1d7e9d43dbc3f46444cc6aad462a9b2a4d33fab1 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 11:39:27 -0700 Subject: [PATCH 03/25] Added shared bbox filter schemas and service --- django/contracts/openapi/ogc.openapi.json | 792 +++++++++------------- django/interfaces/api/schemas/__init__.py | 1 + django/interfaces/api/schemas/bbox.py | 97 +++ django/interfaces/api/service.py | 43 ++ django/tests/interfaces/api/test_bbox.py | 206 ++++++ 5 files changed, 656 insertions(+), 483 deletions(-) create mode 100644 django/interfaces/api/schemas/bbox.py create mode 100644 django/tests/interfaces/api/test_bbox.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 8ae2f407..0df7409e 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -265,7 +265,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "role", @@ -274,7 +273,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -309,7 +307,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "roleId", @@ -318,7 +315,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -412,20 +408,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -445,7 +438,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -674,14 +666,12 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -725,7 +715,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -745,7 +734,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -1116,20 +1104,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -1143,7 +1128,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -1232,14 +1216,12 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -1321,7 +1303,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -1335,7 +1316,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -1494,7 +1474,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "outputDatastream", @@ -1502,13 +1481,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -1528,7 +1505,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -1844,7 +1820,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "outputDatastream", @@ -1852,7 +1827,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -1907,7 +1881,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -1927,7 +1900,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -2155,7 +2127,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -2170,13 +2141,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -2210,7 +2179,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -2670,7 +2638,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -2685,7 +2652,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -2845,7 +2811,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -2879,7 +2844,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -3460,20 +3424,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "targetDatastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -3482,7 +3443,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -3556,14 +3516,12 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "targetDatastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -3598,7 +3556,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -3607,7 +3564,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -3703,20 +3659,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "dataConnection" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -3731,7 +3684,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -3841,14 +3793,12 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "dataConnection" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -3974,7 +3924,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -3989,7 +3938,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -5217,7 +5165,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -5225,13 +5172,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -5247,7 +5192,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -5466,7 +5410,6 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -5474,7 +5417,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -5527,7 +5469,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -5543,7 +5484,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -5819,20 +5759,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "datastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -5846,7 +5783,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -6015,14 +5951,12 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "datastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -6057,7 +5991,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -6071,7 +6004,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -6236,7 +6168,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -6244,13 +6175,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -6273,7 +6202,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -6803,7 +6731,6 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -6811,7 +6738,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -6858,7 +6784,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -6881,7 +6806,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -7263,20 +7187,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -7291,7 +7212,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -7405,14 +7325,12 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -7474,7 +7392,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -7489,7 +7406,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -7933,7 +7849,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response. Only applies to format=record.", - "explode": false, "items": { "enum": [ "datastream", @@ -7942,13 +7857,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", - "explode": false, "items": { "enum": [ "id", @@ -7960,7 +7873,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -8051,7 +7963,6 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response. Only applies to format=record.", - "explode": false, "items": { "enum": [ "datastream", @@ -8060,7 +7971,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -8127,7 +8037,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", - "explode": false, "items": { "enum": [ "id", @@ -8139,7 +8048,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -8288,7 +8196,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -8296,13 +8203,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -8315,7 +8220,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -8463,7 +8367,6 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -8471,7 +8374,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -8524,7 +8426,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -8537,7 +8438,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -10158,20 +10058,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -10183,7 +10080,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -10319,14 +10215,12 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -10379,7 +10273,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -10391,7 +10284,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -10517,7 +10409,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "managedDatastream", @@ -10525,13 +10416,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -10545,7 +10434,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -10577,7 +10465,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "managedDatastream", @@ -10585,7 +10472,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -10630,7 +10516,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -10644,7 +10529,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -10770,7 +10654,6 @@ "properties": { "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -10783,7 +10666,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -10936,7 +10818,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -10949,7 +10830,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -11066,7 +10946,6 @@ "properties": { "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -11084,7 +10963,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -11218,7 +11096,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -11236,7 +11113,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -11424,20 +11300,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -11449,7 +11322,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -11578,14 +11450,12 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -11630,7 +11500,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -11642,7 +11511,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -11772,20 +11640,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -11795,7 +11660,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -11865,14 +11729,12 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -11907,7 +11769,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -11917,7 +11778,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -12017,7 +11877,6 @@ "properties": { "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -12028,7 +11887,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -12081,7 +11939,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -12092,7 +11949,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -12483,20 +12339,17 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -12511,7 +12364,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -12647,14 +12499,12 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -12689,7 +12539,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -12704,7 +12553,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -12916,7 +12764,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -12928,7 +12775,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -13122,7 +12968,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -13130,13 +12975,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -13148,7 +12991,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -13273,7 +13115,6 @@ }, "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -13281,7 +13122,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -13334,7 +13174,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -13346,7 +13185,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -13865,7 +13703,6 @@ "properties": { "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -13874,7 +13711,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -13927,7 +13763,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -13936,7 +13771,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -13980,7 +13814,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "owner", @@ -13989,13 +13822,11 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -14007,7 +13838,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" } @@ -14065,7 +13895,6 @@ "properties": { "include": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "owner", @@ -14074,7 +13903,6 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" }, @@ -14133,7 +13961,6 @@ }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -14145,7 +13972,6 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" }, @@ -14289,12 +14115,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -14303,10 +14129,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -14611,12 +14437,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -14625,10 +14451,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -14774,12 +14600,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -14791,29 +14617,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -15166,12 +14992,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -15183,29 +15009,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -15373,12 +15199,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -15392,29 +15218,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -15857,12 +15683,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -15876,29 +15702,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -16076,12 +15902,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -16093,10 +15919,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -16536,12 +16362,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -16561,19 +16387,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "outputDatastream", @@ -16581,10 +16407,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -16980,12 +16806,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -17005,19 +16831,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "outputDatastream", @@ -17025,10 +16851,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -17196,12 +17022,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -17210,10 +17036,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -17518,12 +17344,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -17532,10 +17358,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -17681,12 +17507,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -17720,19 +17546,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -17747,10 +17573,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -18478,12 +18304,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -18517,19 +18343,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -18544,10 +18370,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -19485,12 +19311,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -19510,29 +19336,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -19886,12 +19712,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -19911,29 +19737,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -20090,12 +19916,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -20104,29 +19930,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "targetDatastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -20506,12 +20332,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -20520,29 +20346,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "targetDatastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -20710,12 +20536,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -20730,29 +20556,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "dataConnection" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -21236,12 +21062,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -21256,29 +21082,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "dataConnection" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -21456,12 +21282,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -21473,10 +21299,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -21916,12 +21742,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -21930,10 +21756,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -22238,12 +22064,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -22252,10 +22078,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -22401,12 +22227,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -22415,10 +22241,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -22723,12 +22549,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -22737,10 +22563,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -22886,12 +22712,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -22907,19 +22733,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -22927,10 +22753,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -23368,12 +23194,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -23389,19 +23215,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -23409,10 +23235,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -23569,12 +23395,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -23588,29 +23414,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "datastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -23992,12 +23818,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -24011,29 +23837,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "datastream" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -24201,12 +24027,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -24215,10 +24041,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -24523,12 +24349,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -24537,10 +24363,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -24686,12 +24512,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -24714,19 +24540,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -24734,10 +24560,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -25157,12 +24983,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -25185,19 +25011,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -25205,10 +25031,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -26312,12 +26138,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -26332,29 +26158,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -26765,12 +26591,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -26785,29 +26611,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "monitoringSite" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -26985,12 +26811,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -27002,10 +26828,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -27613,12 +27439,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", - "explode": false, "items": { "enum": [ "id", @@ -27630,19 +27456,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response. Only applies to format=record.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response. Only applies to format=record.", - "explode": false, "items": { "enum": [ "datastream", @@ -27651,10 +27477,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -28067,12 +27893,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", - "explode": false, "items": { "enum": [ "id", @@ -28084,19 +27910,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response. Only applies to format=record.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response. Only applies to format=record.", - "explode": false, "items": { "enum": [ "datastream", @@ -28105,10 +27931,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -28172,12 +27998,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -28190,19 +28016,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -28210,10 +28036,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -28613,12 +28439,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -28631,19 +28457,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -28651,10 +28477,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -28811,12 +28637,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -28825,10 +28651,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -29133,12 +28959,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -29147,10 +28973,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -29296,12 +29122,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -29313,29 +29139,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -29718,12 +29544,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -29735,29 +29561,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -29914,12 +29740,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -29933,19 +29759,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "managedDatastream", @@ -29953,10 +29779,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -30296,12 +30122,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -30315,19 +30141,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "managedDatastream", @@ -30335,10 +30161,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -30423,12 +30249,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -30446,10 +30272,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -30916,12 +30742,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -30939,10 +30765,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -31235,12 +31061,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -31253,10 +31079,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -31662,12 +31488,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -31680,10 +31506,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -31871,12 +31697,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -31886,29 +31712,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -32219,12 +32045,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -32234,29 +32060,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -32402,12 +32228,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -32418,10 +32244,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -32593,12 +32419,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -32609,10 +32435,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -32676,12 +32502,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -32690,10 +32516,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -32998,12 +32824,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -33012,10 +32838,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -33161,12 +32987,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -33175,10 +33001,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", @@ -33483,12 +33309,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -33497,10 +33323,10 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -33646,12 +33472,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -33663,19 +33489,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -33683,10 +33509,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -34075,12 +33901,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -34092,19 +33918,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace", @@ -34112,10 +33938,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -34272,12 +34098,12 @@ "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -34289,19 +34115,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "owner", @@ -34310,10 +34136,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -34635,12 +34461,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -34652,19 +34478,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "owner", @@ -34673,10 +34499,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { @@ -34919,12 +34745,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "roleId", @@ -34933,19 +34759,19 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "role", @@ -34954,10 +34780,10 @@ ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -35281,12 +35107,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -35301,29 +35127,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" }, { "description": "Number of items to skip.", @@ -35603,12 +35429,12 @@ }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, "in": "query", "name": "properties", "required": false, "schema": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", - "explode": false, "items": { "enum": [ "id", @@ -35623,29 +35449,29 @@ ], "type": "string" }, - "style": "form", "title": "Properties", "type": "array" - } + }, + "style": "form" }, { "description": "Comma-separated list of related resources to include in the response.", + "explode": false, "in": "query", "name": "include", "required": false, "schema": { "description": "Comma-separated list of related resources to include in the response.", - "explode": false, "items": { "enum": [ "workspace" ], "type": "string" }, - "style": "form", "title": "Include", "type": "array" - } + }, + "style": "form" } ], "responses": { diff --git a/django/interfaces/api/schemas/__init__.py b/django/interfaces/api/schemas/__init__.py index ce502ba6..b54ad400 100644 --- a/django/interfaces/api/schemas/__init__.py +++ b/django/interfaces/api/schemas/__init__.py @@ -1,6 +1,7 @@ from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, CollectionQueryParameters, PaginationMeta, PaginatedResponse, CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema) +from .bbox import BoundingBox, BoundingBoxQuery, parse_bbox from interfaces.api.schemas.iam.user import UserContactResponse from interfaces.api.schemas.iam.role import (RoleResponse, RoleQueryParameters, RoleItemQueryParameters, RoleSortByFields) diff --git a/django/interfaces/api/schemas/bbox.py b/django/interfaces/api/schemas/bbox.py new file mode 100644 index 00000000..032d821a --- /dev/null +++ b/django/interfaces/api/schemas/bbox.py @@ -0,0 +1,97 @@ +import math + +from dataclasses import dataclass +from typing import Optional, Any, Annotated +from pydantic import BeforeValidator, WithJsonSchema + + +@dataclass(frozen=True) +class BoundingBox: + """ + A WGS 84 longitude/latitude (CRS84) bounding box, per OGC API - Features - Part 1: Core. + + A west edge greater than the east edge describes a box that crosses the antimeridian. + """ + + west: float + south: float + east: float + north: float + + @property + def crosses_antimeridian(self) -> bool: + return self.west > self.east + + def contains(self, longitude: float, latitude: float) -> bool: + """Returns whether a point lies inside the box, boundaries included.""" + + if not self.south <= latitude <= self.north: + return False + + if self.crosses_antimeridian: + return longitude >= self.west or longitude <= self.east + + return self.west <= longitude <= self.east + + +def parse_bbox(value: Any) -> Optional[BoundingBox]: + """ + Parses an OGC API bbox query parameter value into a BoundingBox. + + Accepts four numbers (min lon, min lat, max lon, max lat) or six numbers + (min lon, min lat, min height, max lon, max lat, max height). HydroServer geometries + are two-dimensional, so the height range of a six-number box is validated but not + used for filtering. + """ + + if value is None or isinstance(value, BoundingBox): + return value + + if isinstance(value, str) and value.strip().lower() == "null": + return None + + parts = value.split(",") if isinstance(value, str) else list(value) + + try: + numbers = [float(part) for part in parts] + except (TypeError, ValueError): + raise ValueError("Bounding box must contain only numeric values") + + if not all(math.isfinite(number) for number in numbers): + raise ValueError("Bounding box must contain only finite numeric values") + + if len(numbers) == 4: + west, south, east, north = numbers + elif len(numbers) == 6: + west, south, min_height, east, north, max_height = numbers + if min_height > max_height: + raise ValueError("Bounding box minimum height must be less than or equal to maximum height") + else: + raise ValueError( + "Bounding box must have 4 or 6 comma-separated values: " + "min_lon,min_lat,max_lon,max_lat or min_lon,min_lat,min_height,max_lon,max_lat,max_height" + ) + + if not all(-180 <= longitude <= 180 for longitude in (west, east)): + raise ValueError("Bounding box longitudes must be between -180 and 180") + if not all(-90 <= latitude <= 90 for latitude in (south, north)): + raise ValueError("Bounding box latitudes must be between -90 and 90") + if south > north: + raise ValueError("Bounding box minimum latitude must be less than or equal to maximum latitude") + + return BoundingBox(west=west, south=south, east=east, north=north) + + +BBOX_JSON_SCHEMA = { + "type": "array", + "oneOf": [{"minItems": 4, "maxItems": 4}, {"minItems": 6, "maxItems": 6}], + "items": {"type": "number"}, + "style": "form", + "explode": False, +} + +BoundingBoxQuery = Annotated[ + Optional[BoundingBox], + BeforeValidator(parse_bbox), + WithJsonSchema(BBOX_JSON_SCHEMA), +] diff --git a/django/interfaces/api/service.py b/django/interfaces/api/service.py index f2281532..e9c79bbd 100644 --- a/django/interfaces/api/service.py +++ b/django/interfaces/api/service.py @@ -14,6 +14,7 @@ from core.iam.permissions.anonymous import AnonymousPrincipal from interfaces.api.http.errors import BadRequestError, NotFoundError from interfaces.api.schemas.base import PaginationMeta +from interfaces.api.schemas.bbox import BoundingBox User = get_user_model() @@ -81,6 +82,48 @@ def apply_filters(queryset: QuerySet, field_name: str, values: Optional[Any]): else: return queryset.filter(**{field_name: values}) + @staticmethod + def bbox_q( + bbox: BoundingBox, + latitude_field: str = "latitude", + longitude_field: str = "longitude", + ) -> Q: + """ + Builds a Q object matching points inside a bounding box, boundaries included. A box + that crosses the antimeridian matches longitudes east of its west edge or west of its + east edge. + """ + + latitude_q = Q( + **{f"{latitude_field}__gte": bbox.south, f"{latitude_field}__lte": bbox.north} + ) + + if bbox.crosses_antimeridian: + longitude_q = Q(**{f"{longitude_field}__gte": bbox.west}) | Q( + **{f"{longitude_field}__lte": bbox.east} + ) + else: + longitude_q = Q( + **{f"{longitude_field}__gte": bbox.west, f"{longitude_field}__lte": bbox.east} + ) + + return latitude_q & longitude_q + + @classmethod + def apply_bbox( + cls, + queryset: QuerySet, + bbox: Optional[BoundingBox], + latitude_field: str = "latitude", + longitude_field: str = "longitude", + ) -> QuerySet: + """Filters a queryset to points inside a bounding box. A missing box leaves it unfiltered.""" + + if bbox is None: + return queryset + + return queryset.filter(cls.bbox_q(bbox, latitude_field, longitude_field)) + @staticmethod def apply_sorting( queryset: QuerySet, diff --git a/django/tests/interfaces/api/test_bbox.py b/django/tests/interfaces/api/test_bbox.py new file mode 100644 index 00000000..3d9854f4 --- /dev/null +++ b/django/tests/interfaces/api/test_bbox.py @@ -0,0 +1,206 @@ +import pytest +from ninja import Query +from ninja.testing import TestClient +from pydantic import ValidationError + +from core.sta.models import Datastream, MonitoringSite +from interfaces.api.http import handlers +from interfaces.api.http.api import HydroServerNinjaAPI +from interfaces.api.schemas import BaseQueryParameters, BoundingBox, BoundingBoxQuery, parse_bbox +from interfaces.api.service import APIService +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory + + +class _BBoxQueryParameters(BaseQueryParameters): + bbox: BoundingBoxQuery = Query(None, description="Filter by bounding box.") + + +_bbox_api = HydroServerNinjaAPI(urls_namespace="bbox-test") +handlers.register(_bbox_api) + + +@_bbox_api.get("/items") +def _list_items(request, query: Query[_BBoxQueryParameters]): + bbox = query.bbox + return None if bbox is None else [bbox.west, bbox.south, bbox.east, bbox.north] + + +_bbox_client = TestClient(_bbox_api) + + +# --- parse_bbox --------------------------------------------------------------------------- + + +def test_parse_bbox_parses_four_numbers(): + assert parse_bbox("-112.5,40,-111,41.25") == BoundingBox(west=-112.5, south=40, east=-111, north=41.25) + + +def test_parse_bbox_parses_six_numbers_and_drops_the_height_range(): + assert parse_bbox("-112,40,-10,-111,41,2500") == BoundingBox(west=-112, south=40, east=-111, north=41) + + +def test_parse_bbox_tolerates_whitespace_around_values(): + assert parse_bbox(" -112 , 40 , -111 , 41 ") == BoundingBox(west=-112, south=40, east=-111, north=41) + + +def test_parse_bbox_accepts_a_sequence_of_values(): + assert parse_bbox([-112, 40, -111, 41]) == BoundingBox(west=-112, south=40, east=-111, north=41) + + +def test_parse_bbox_accepts_a_box_that_crosses_the_antimeridian(): + bbox = parse_bbox("170,-20,-170,-10") + + assert bbox == BoundingBox(west=170, south=-20, east=-170, north=-10) + assert bbox.crosses_antimeridian + + +@pytest.mark.parametrize("value", [None, "null", "NULL"]) +def test_parse_bbox_returns_none_for_missing_values(value): + assert parse_bbox(value) is None + + +def test_parse_bbox_passes_through_an_existing_bounding_box(): + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + + assert parse_bbox(bbox) is bbox + + +@pytest.mark.parametrize( + "value, message", + [ + ("-112,40,-111,north", "only numeric values"), + ("", "only numeric values"), + ("-112,40,-111,nan", "finite"), + ("-112,40,-111,inf", "finite"), + ("-112,40,-111", "4 or 6"), + ("-112,40,0,-111,41", "4 or 6"), + ("-112,40,0,-111,41,1,2", "4 or 6"), + ("-181,40,-111,41", "longitudes"), + ("-112,40,181,41", "longitudes"), + ("-112,-91,-111,41", "latitudes"), + ("-112,40,-111,91", "latitudes"), + ("-112,41,-111,40", "minimum latitude"), + ("-112,40,100,-111,41,0", "minimum height"), + ], +) +def test_parse_bbox_rejects_invalid_values(value, message): + with pytest.raises(ValueError, match=message): + parse_bbox(value) + + +# --- BoundingBox.contains ----------------------------------------------------------------- + + +@pytest.mark.parametrize( + "longitude, latitude, expected", + [ + (-111.5, 40.5, True), + (-112, 40, True), + (-111, 41, True), + (-110.9, 40.5, False), + (-111.5, 41.1, False), + ], +) +def test_contains_includes_boundaries(longitude, latitude, expected): + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + + assert bbox.contains(longitude, latitude) is expected + + +@pytest.mark.parametrize( + "longitude, expected", + [(175, True), (180, True), (-180, True), (-175, True), (0, False), (165, False), (-165, False)], +) +def test_contains_handles_boxes_that_cross_the_antimeridian(longitude, expected): + bbox = BoundingBox(west=170, south=-20, east=-170, north=-10) + + assert bbox.contains(longitude, -15) is expected + + +# --- BoundingBoxQuery --------------------------------------------------------------------- + + +def test_bounding_box_query_parses_the_query_parameter(): + response = _bbox_client.get("/items", query_params={"bbox": "-112,40,-111,41"}) + + assert response.status_code == 200 + assert response.json() == [-112, 40, -111, 41] + + +def test_bounding_box_query_is_optional(): + response = _bbox_client.get("/items") + + assert response.status_code == 200 + assert response.json() is None + + +@pytest.mark.parametrize("value", ["-112,40,-111", "-112,41,-111,40", "not,a,valid,bbox"]) +def test_bounding_box_query_returns_400_for_invalid_values(value): + response = _bbox_client.get("/items", query_params={"bbox": value}) + + assert response.status_code == 400 + + +def test_bounding_box_query_is_declared_per_ogc_core_requirement_23(): + parameters = _bbox_api.get_openapi_schema(path_prefix="")["paths"]["/items"]["get"]["parameters"] + bbox = next(parameter for parameter in parameters if parameter["name"] == "bbox") + + assert bbox["style"] == "form" + assert bbox["explode"] is False + assert bbox["schema"]["type"] == "array" + assert bbox["schema"]["items"] == {"type": "number"} + assert bbox["schema"]["oneOf"] == [{"minItems": 4, "maxItems": 4}, {"minItems": 6, "maxItems": 6}] + + +def test_bounding_box_query_rejects_invalid_values_during_model_validation(): + with pytest.raises(ValidationError): + _BBoxQueryParameters.model_validate({"bbox": "-112,40,-111"}) + + +# --- APIService.apply_bbox ---------------------------------------------------------------- + + +@pytest.mark.django_db +def test_apply_bbox_returns_the_queryset_unfiltered_without_a_box(): + MonitoringSiteFactory.create_batch(2) + + assert APIService.apply_bbox(MonitoringSite.objects.all(), None).count() == 2 + + +@pytest.mark.django_db +def test_apply_bbox_matches_points_inside_the_box_including_boundaries(): + inside = MonitoringSiteFactory(longitude=-111.5, latitude=40.5) + on_corner = MonitoringSiteFactory(longitude=-112, latitude=40) + MonitoringSiteFactory(longitude=-110, latitude=40.5) + MonitoringSiteFactory(longitude=-111.5, latitude=42) + + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + matched = set(APIService.apply_bbox(MonitoringSite.objects.all(), bbox).values_list("id", flat=True)) + + assert matched == {inside.id, on_corner.id} + + +@pytest.mark.django_db +def test_apply_bbox_matches_both_sides_of_the_antimeridian(): + east_of_west_edge = MonitoringSiteFactory(longitude=175, latitude=-15) + west_of_east_edge = MonitoringSiteFactory(longitude=-175, latitude=-15) + MonitoringSiteFactory(longitude=0, latitude=-15) + MonitoringSiteFactory(longitude=175, latitude=-30) + + bbox = BoundingBox(west=170, south=-20, east=-170, north=-10) + matched = set(APIService.apply_bbox(MonitoringSite.objects.all(), bbox).values_list("id", flat=True)) + + assert matched == {east_of_west_edge.id, west_of_east_edge.id} + + +@pytest.mark.django_db +def test_apply_bbox_supports_related_field_paths(): + inside = DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=-111.5, latitude=40.5)) + DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=-100, latitude=40.5)) + + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + queryset = APIService.apply_bbox( + Datastream.objects.all(), bbox, "monitoring_site__latitude", "monitoring_site__longitude" + ) + + assert list(queryset.values_list("id", flat=True)) == [inside.id] From 976a50f7d114fd543fd206573beb8a10569155c8 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 12:00:02 -0700 Subject: [PATCH 04/25] Update monitoring sites to use shared bbox filter --- django/contracts/openapi/ogc.openapi.json | 76 +++++++++++---- .../api/schemas/sta/monitoring_site.py | 17 ++-- .../api/services/sta/monitoring_site.py | 94 +++++-------------- .../api/views/sta/monitoring_site.py | 2 + .../api/test_monitoring_site_views.py | 75 +++++++++++++++ .../src/generated/ogc.types.d.ts | 24 ++--- 6 files changed, 173 insertions(+), 115 deletions(-) diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 0df7409e..8414a767 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -6298,12 +6298,20 @@ "MonitoringSiteMarkerQueryParameters": { "properties": { "bbox": { - "default": [], - "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "description": "Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", "items": { - "type": "string" + "type": "number" }, - "title": "Bbox", + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], "type": "array" }, "type": { @@ -6712,12 +6720,20 @@ "type": "array" }, "bbox": { - "default": [], - "description": "Filter monitoring sites by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "description": "Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", "items": { - "type": "string" + "type": "number" }, - "title": "Bbox", + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], "type": "array" }, "country": { @@ -24687,19 +24703,29 @@ } }, { - "description": "Filter monitoring sites by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "description": "Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", + "explode": false, "in": "query", "name": "bbox", "required": false, "schema": { - "default": [], - "description": "Filter monitoring sites by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "description": "Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", "items": { - "type": "string" + "type": "number" }, - "title": "Bbox", + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], "type": "array" - } + }, + "style": "form" }, { "description": "Filter monitoring sites by admin area 1.", @@ -25769,19 +25795,29 @@ } }, { - "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "description": "Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", + "explode": false, "in": "query", "name": "bbox", "required": false, "schema": { - "default": [], - "description": "Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + "description": "Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", "items": { - "type": "string" + "type": "number" }, - "title": "Bbox", + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], "type": "array" - } + }, + "style": "form" }, { "description": "Filter markers by monitoring site type.", diff --git a/django/interfaces/api/schemas/sta/monitoring_site.py b/django/interfaces/api/schemas/sta/monitoring_site.py index d362485f..61ad1a8b 100644 --- a/django/interfaces/api/schemas/sta/monitoring_site.py +++ b/django/interfaces/api/schemas/sta/monitoring_site.py @@ -14,6 +14,7 @@ BasePostBody, BaseQueryParameters, CollectionQueryParameters, + BoundingBoxQuery, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -134,9 +135,11 @@ class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryP workspace_id: list[uuid.UUID] = Query( [], description="Filter monitoring sites by workspace ID." ) - bbox: list[str] = Query( - [], - description="Filter monitoring sites by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + bbox: BoundingBoxQuery = Query( + None, + description="Filter monitoring sites by bounding box in WGS 84 longitude/latitude: " + "min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). " + "A six-value box with min and max heights is accepted; heights are not used for filtering.", ) admin_area_1: list[str] = Query( [], description="Filter monitoring sites by admin area 1." @@ -159,9 +162,11 @@ class MonitoringSiteMarkerQueryParameters(BaseQueryParameters): workspace_id: list[uuid.UUID] = Query( [], description="Filter markers by workspace ID." ) - bbox: list[str] = Query( - [], - description="Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat}", + bbox: BoundingBoxQuery = Query( + None, + description="Filter markers by bounding box in WGS 84 longitude/latitude: " + "min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). " + "A six-value box with min and max heights is accepted; heights are not used for filtering.", ) type: list[str] = Query([], description="Filter markers by monitoring site type.") diff --git a/django/interfaces/api/services/sta/monitoring_site.py b/django/interfaces/api/services/sta/monitoring_site.py index 70ad8997..524d02e2 100644 --- a/django/interfaces/api/services/sta/monitoring_site.py +++ b/django/interfaces/api/services/sta/monitoring_site.py @@ -4,7 +4,7 @@ from typing import Optional, Literal, get_args from django.contrib.auth import get_user_model from django.db import IntegrityError -from django.db.models import Count, QuerySet, Q, FloatField, Subquery, OuterRef, IntegerField +from django.db.models import Count, QuerySet, FloatField, Subquery, OuterRef, IntegerField from django.db.models.functions import Cast, Coalesce from django.utils import timezone @@ -21,6 +21,7 @@ MonitoringSiteLinkedResource, ) from interfaces.api.schemas import ( + BoundingBox, MonitoringSiteResponse, MonitoringSitePostBody, MonitoringSitePatchBody, @@ -90,10 +91,6 @@ def _include_query_hints( return select_paths, [] - @classmethod - def apply_bbox_filter(cls, queryset, bbox: Optional[list[str]]): - return cls.apply_marker_bbox_filter(queryset, bbox) - @staticmethod def apply_tag_filter(queryset, tags: list[str]): if not tags: @@ -108,57 +105,6 @@ def apply_tag_filter(queryset, tags: list[str]): return queryset - @staticmethod - def parse_bbox_filters(bbox: Optional[list[str]]) -> list[tuple[float, float, float, float]]: - parsed_bbox_filters: list[tuple[float, float, float, float]] = [] - - if not bbox: - return parsed_bbox_filters - - for bbox_str in bbox: - try: - parts = [float(x) for x in bbox_str.split(",")] - except ValueError: - raise BadRequestError("Bounding box must contain only numeric values") - - if len(parts) != 4: - raise BadRequestError( - "Bounding box must have exactly 4 comma-separated values: min_lon,min_lat,max_lon,max_lat" - ) - - min_lon, min_lat, max_lon, max_lat = parts - - if min_lon > max_lon or min_lat > max_lat: - raise BadRequestError( - "Invalid bounding box coordinates: min must be less than or equal to max" - ) - - parsed_bbox_filters.append((min_lon, min_lat, max_lon, max_lat)) - - return parsed_bbox_filters - - @classmethod - def apply_marker_bbox_filter( - cls, - queryset: QuerySet, - bbox: Optional[list[str]], - ) -> QuerySet: - parsed_bbox_filters = cls.parse_bbox_filters(bbox) - - if not parsed_bbox_filters: - return queryset - - bbox_filter = Q() - for min_lon, min_lat, max_lon, max_lat in parsed_bbox_filters: - bbox_filter |= Q( - longitude__gte=min_lon, - longitude__lte=max_lon, - latitude__gte=min_lat, - latitude__lte=max_lat, - ) - - return queryset.filter(bbox_filter) - @staticmethod def apply_marker_filters(queryset: QuerySet, filtering: Optional[dict] = None) -> QuerySet: filtering = filtering or {} @@ -241,7 +187,10 @@ def serialize_site_summary_rows(site_rows) -> list[dict]: @classmethod def filter_cached_markers( - cls, markers: list[dict], filtering: Optional[dict] = None + cls, + markers: list[dict], + filtering: Optional[dict] = None, + bbox: Optional[BoundingBox] = None, ) -> list[dict]: filtering = filtering or {} filtered_markers = markers @@ -262,21 +211,18 @@ def filter_cached_markers( if marker["type"] in site_types ] - parsed_bbox_filters = cls.parse_bbox_filters(filtering.get("bbox")) - if parsed_bbox_filters: + if bbox is not None: filtered_markers = [ marker for marker in filtered_markers - if any( - min_lon <= marker["longitude"] <= max_lon - and min_lat <= marker["latitude"] <= max_lat - for min_lon, min_lat, max_lon, max_lat in parsed_bbox_filters - ) + if bbox.contains(marker["longitude"], marker["latitude"]) ] return filtered_markers - def get_public_markers(self, filtering: Optional[dict] = None) -> list[dict]: + def get_public_markers( + self, filtering: Optional[dict] = None, bbox: Optional[BoundingBox] = None + ) -> list[dict]: public_markers = get_public_monitoring_site_markers_cache() if public_markers is None: @@ -286,12 +232,13 @@ def get_public_markers(self, filtering: Optional[dict] = None) -> list[dict]: public_markers = self.serialize_marker_rows(public_marker_queryset) set_public_monitoring_site_markers_cache(public_markers) - return self.filter_cached_markers(public_markers, filtering=filtering) + return self.filter_cached_markers(public_markers, filtering=filtering, bbox=bbox) def get_private_markers( self, principal: User | ServiceAccount | AnonymousPrincipal, filtering: Optional[dict] = None, + bbox: Optional[BoundingBox] = None, ) -> list[dict]: if not principal.is_authenticated: return [] @@ -303,10 +250,7 @@ def get_private_markers( private_marker_queryset, filtering=filtering, ) - private_marker_queryset = self.apply_marker_bbox_filter( - private_marker_queryset, - filtering.get("bbox") if filtering else None, - ) + private_marker_queryset = self.apply_bbox(private_marker_queryset, bbox) return self.serialize_marker_rows( self.get_marker_values(private_marker_queryset.order_by("id").distinct()) @@ -316,9 +260,12 @@ def list_markers( self, principal: User | ServiceAccount | AnonymousPrincipal, filtering: Optional[dict] = None, + bbox: Optional[BoundingBox] = None, ): - public_markers = self.get_public_markers(filtering=filtering) - private_markers = self.get_private_markers(principal=principal, filtering=filtering) + public_markers = self.get_public_markers(filtering=filtering, bbox=bbox) + private_markers = self.get_private_markers( + principal=principal, filtering=filtering, bbox=bbox + ) markers = public_markers + private_markers markers.sort(key=lambda marker: marker["id"]) @@ -399,6 +346,7 @@ def list( sortby: Optional[list[str]] = None, filtering: Optional[dict] = None, include: Optional[list[str]] = None, + bbox: Optional[BoundingBox] = None, ): requested_includes = self.resolve_include_set(include) queryset = MonitoringSite.objects @@ -422,7 +370,7 @@ def list( else: queryset = self.apply_filters(queryset, field, filtering[field]) - queryset = self.apply_bbox_filter(queryset, filtering.get("bbox")) + queryset = self.apply_bbox(queryset, bbox) queryset = self.apply_tag_filter(queryset, filtering.get("tag")) queryset, has_search = self.apply_search(queryset, filtering.get("q")) queryset = self.apply_sorting( diff --git a/django/interfaces/api/views/sta/monitoring_site.py b/django/interfaces/api/views/sta/monitoring_site.py index 0d90c634..af7acac9 100644 --- a/django/interfaces/api/views/sta/monitoring_site.py +++ b/django/interfaces/api/views/sta/monitoring_site.py @@ -56,6 +56,7 @@ def get_monitoring_sites( offset=query.offset, limit=query.limit, sortby=query.sortby, + bbox=query.bbox, filtering=query.dict(exclude_unset=True), include=query.include, ) @@ -80,6 +81,7 @@ def get_monitoring_site_markers( return 200, monitoring_site_service.list_markers( principal=request.principal, + bbox=query.bbox, filtering=query.dict(exclude_unset=True), ) diff --git a/django/tests/interfaces/api/test_monitoring_site_views.py b/django/tests/interfaces/api/test_monitoring_site_views.py index a70398f6..cbac94de 100644 --- a/django/tests/interfaces/api/test_monitoring_site_views.py +++ b/django/tests/interfaces/api/test_monitoring_site_views.py @@ -90,6 +90,51 @@ def test_get_monitoring_sites_returns_400_for_malformed_bbox(client): assert response.status_code == 400 +@pytest.mark.parametrize( + "bbox", + ["-181,40,-111,41", "-112,-91,-111,41", "-112,41,-111,40", "-112,40,-111", "-112,40,100,-111,41,0"], +) +def test_get_monitoring_sites_returns_400_for_invalid_bbox(client, bbox): + response = client.get(MONITORING_SITES_URL, {"bbox": bbox}) + + assert response.status_code == 400 + + +def _site_ids(response): + return {site["id"] for site in response.json()["data"]} + + +def test_get_monitoring_sites_filters_by_bbox(client): + inside = MonitoringSiteFactory(longitude=-111.5, latitude=40.5) + MonitoringSiteFactory(longitude=-100, latitude=40.5) + + response = client.get(MONITORING_SITES_URL, {"bbox": "-112,40,-111,41"}) + + assert response.status_code == 200 + assert _site_ids(response) == {str(inside.id)} + + +def test_get_monitoring_sites_accepts_a_six_value_bbox_and_ignores_heights(client): + inside = MonitoringSiteFactory(longitude=-111.5, latitude=40.5, elevation_m=5000) + MonitoringSiteFactory(longitude=-100, latitude=40.5) + + response = client.get(MONITORING_SITES_URL, {"bbox": "-112,40,0,-111,41,10"}) + + assert response.status_code == 200 + assert _site_ids(response) == {str(inside.id)} + + +def test_get_monitoring_sites_filters_by_a_bbox_crossing_the_antimeridian(client): + east = MonitoringSiteFactory(longitude=175, latitude=-15) + west = MonitoringSiteFactory(longitude=-175, latitude=-15) + MonitoringSiteFactory(longitude=0, latitude=-15) + + response = client.get(MONITORING_SITES_URL, {"bbox": "170,-20,-170,-10"}) + + assert response.status_code == 200 + assert _site_ids(response) == {str(east.id), str(west.id)} + + def test_get_monitoring_sites_returns_400_for_malformed_tag(client): response = client.get(MONITORING_SITES_URL, {"tag": "no-colon-in-here"}) @@ -695,6 +740,36 @@ def test_get_monitoring_site_markers_returns_400_for_malformed_bbox(client): assert response.status_code == 400 +def test_get_monitoring_site_markers_returns_400_for_out_of_range_bbox(client): + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/markers", {"bbox": "-112,40,-111,91"}) + + assert response.status_code == 400 + + +def test_get_monitoring_site_markers_filters_public_markers_by_a_bbox_crossing_the_antimeridian(client): + east = MonitoringSiteFactory(longitude=175, latitude=-15) + west = MonitoringSiteFactory(longitude=-175, latitude=-15) + MonitoringSiteFactory(longitude=0, latitude=-15) + + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/markers", {"bbox": "170,-20,-170,-10"}) + + assert response.status_code == 200 + assert {m["id"] for m in response.json()} == {str(east.id), str(west.id)} + + +def test_get_monitoring_site_markers_filters_private_markers_by_bbox(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + inside = MonitoringSiteFactory(workspace=workspace, private=True, longitude=-111.5, latitude=40.5) + MonitoringSiteFactory(workspace=workspace, private=True, longitude=-100, latitude=40.5) + client.force_login(owner) + + response = client.get(f"{MONITORING_SITES_COLLECTION_URL}/markers", {"bbox": "-112,40,-111,41"}) + + assert response.status_code == 200 + assert {m["id"] for m in response.json()} == {str(inside.id)} + + def test_get_monitoring_site_site_summaries_returns_summary_for_public_monitoring_site(client): workspace = WorkspaceFactory() monitoring_site = MonitoringSiteFactory(workspace=workspace) diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 31a840c1..3be4d07f 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -4324,12 +4324,8 @@ export interface components { }; /** MonitoringSiteMarkerQueryParameters */ MonitoringSiteMarkerQueryParameters: { - /** - * Bbox - * @description Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat} - * @default [] - */ - bbox: string[]; + /** @description Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ + bbox?: number[] & (unknown | unknown); /** * Type * @description Filter markers by monitoring site type. @@ -4456,12 +4452,8 @@ export interface components { * @default [] */ admin_area_2: string[]; - /** - * Bbox - * @description Filter monitoring sites by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat} - * @default [] - */ - bbox: string[]; + /** @description Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ + bbox?: number[] & (unknown | unknown); /** * Country * @description Filter monitoring sites by country. @@ -11341,8 +11333,8 @@ export interface operations { q?: string | null; /** @description Filter monitoring sites by workspace ID. */ workspace_id?: string[]; - /** @description Filter monitoring sites by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat} */ - bbox?: string[]; + /** @description Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ + bbox?: number[] & (unknown | unknown); /** @description Filter monitoring sites by admin area 1. */ admin_area_1?: string[]; /** @description Filter monitoring sites by admin area 2. */ @@ -11816,8 +11808,8 @@ export interface operations { query?: { /** @description Filter markers by workspace ID. */ workspace_id?: string[]; - /** @description Filter markers by bounding box. Format bounding box as {min_lon},{min_lat},{max_lon},{max_lat} */ - bbox?: string[]; + /** @description Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ + bbox?: number[] & (unknown | unknown); /** @description Filter markers by monitoring site type. */ type?: string[]; }; From 0a000786263a6896a1703d509a5638e100b33e05 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 13:35:12 -0700 Subject: [PATCH 05/25] Added ExtentQueryParameter schema to API --- django/contracts/openapi/ogc.openapi.json | 1637 +++++++++++++---- django/interfaces/api/schemas/__init__.py | 1 + django/interfaces/api/schemas/base.py | 10 + django/interfaces/api/schemas/bbox.py | 3 + .../api/schemas/etl/data_connection.py | 3 +- django/interfaces/api/schemas/etl/mapping.py | 3 +- django/interfaces/api/schemas/etl/task.py | 3 +- django/interfaces/api/schemas/iam/role.py | 3 +- .../interfaces/api/schemas/iam/workspace.py | 3 +- .../interfaces/api/schemas/monitoring/rule.py | 3 +- .../interfaces/api/schemas/monitoring/task.py | 3 +- .../api/schemas/products/rating_curve.py | 3 +- .../interfaces/api/schemas/products/task.py | 3 +- .../api/schemas/products/transformation.py | 3 +- .../interfaces/api/schemas/quality/history.py | 3 +- .../interfaces/api/schemas/sta/datastream.py | 3 +- django/interfaces/api/schemas/sta/method.py | 3 +- .../api/schemas/sta/monitoring_site.py | 18 +- .../interfaces/api/schemas/sta/observation.py | 3 +- .../api/schemas/sta/observed_property.py | 3 +- .../api/schemas/sta/processing_level.py | 3 +- .../api/schemas/sta/result_qualifier.py | 3 +- django/interfaces/api/schemas/sta/unit.py | 3 +- .../interfaces/api/schemas/sta/vocabulary.py | 3 +- django/tests/interfaces/api/test_bbox.py | 20 + .../interfaces/api/test_non_spatial_bbox.py | 103 ++ .../src/generated/ogc.types.d.ts | 102 +- 27 files changed, 1579 insertions(+), 372 deletions(-) create mode 100644 django/tests/interfaces/api/test_non_spatial_bbox.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 8414a767..3f01c1fb 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -664,6 +664,23 @@ }, "DataConnectionQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -1214,6 +1231,23 @@ }, "DataProductTaskQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -1818,6 +1852,23 @@ }, "DataProductTransformationQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -2636,6 +2687,23 @@ }, "DatastreamQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -3504,6 +3572,23 @@ }, "EtlMappingQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "etl_task_id": { "default": [], "description": "Filter mappings by ETL task ID.", @@ -3781,6 +3866,23 @@ }, "EtlTaskQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "data_connection_id": { "default": [], "description": "Filter ETL tasks by data connection ID.", @@ -5390,6 +5492,23 @@ }, "MethodQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter methods by datastream ID.", @@ -5939,6 +6058,23 @@ }, "MonitoringRuleQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter rules by datastream ID.", @@ -6298,7 +6434,7 @@ "MonitoringSiteMarkerQueryParameters": { "properties": { "bbox": { - "description": "Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "items": { "type": "number" }, @@ -6720,7 +6856,7 @@ "type": "array" }, "bbox": { - "description": "Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "items": { "type": "number" }, @@ -7329,6 +7465,23 @@ }, "MonitoringTaskQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter monitoring tasks by datastream ID.", @@ -7950,6 +8103,23 @@ }, "ObservationQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter observations by datastream ID.", @@ -8363,6 +8533,23 @@ }, "ObservedPropertyQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter observed properties by datastream ID.", @@ -10211,6 +10398,23 @@ }, "ProcessingLevelQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter processing levels by datastream ID.", @@ -10479,6 +10683,23 @@ }, "QualityControlHistoryQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -11464,6 +11685,23 @@ }, "RatingCurveQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -11743,6 +11981,23 @@ }, "ResultQualifierQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -11912,6 +12167,23 @@ }, "RoleQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "anyOf": [ { @@ -13111,6 +13383,23 @@ }, "UnitQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "datastream_id": { "default": [], "description": "Filter units by datastream ID.", @@ -13736,6 +14025,23 @@ }, "VocabularyQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "anyOf": [ { @@ -13909,6 +14215,23 @@ }, "WorkspaceQueryParameters": { "properties": { + "bbox": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -14129,6 +14452,31 @@ "description": "Get Aggregation Statistics.", "operationId": "interfaces_api_views_sta_aggregation_statistic_get_aggregation_statistics", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -14614,6 +14962,31 @@ "description": "Get rating curves accessible to the authenticated user.", "operationId": "interfaces_api_views_products_rating_curve_get_rating_curves", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -15213,6 +15586,31 @@ "description": "Get data product tasks accessible to the authenticated user.", "operationId": "interfaces_api_views_products_task_get_data_product_tasks", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -16376,6 +16774,31 @@ "description": "Get data product transformations.", "operationId": "interfaces_api_views_products_transformation_get_data_product_transformations", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -17036,6 +17459,31 @@ "description": "Get Datastream Statuses.", "operationId": "interfaces_api_views_sta_datastream_status_get_datastream_statuses", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -17521,6 +17969,31 @@ "description": "Get public Datastreams and Datastreams associated with the authenticated user.", "operationId": "interfaces_api_views_sta_datastream_get_datastreams", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -19249,82 +19722,107 @@ "BasicAuth": [] } ], - "summary": "Get Datastream Tag Keys", + "summary": "Get Datastream Tag Keys", + "tags": [ + "Datastreams" + ] + } + }, + "/api/ogc/collections/datastreams/visualization-bootstrap": { + "get": { + "description": "Get the lean metadata required to bootstrap the visualization page.", + "operationId": "interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap", + "parameters": [ + { + "description": "Filter visualization bootstrap datastreams by workspace ID.", + "in": "query", + "name": "workspace_id", + "required": false, + "schema": { + "default": [], + "description": "Filter visualization bootstrap datastreams by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatastreamVisualizationBootstrapResponse" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Datastream Visualization Bootstrap", "tags": [ "Datastreams" ] } }, - "/api/ogc/collections/datastreams/visualization-bootstrap": { + "/api/ogc/collections/etl-data-connections/items": { "get": { - "description": "Get the lean metadata required to bootstrap the visualization page.", - "operationId": "interfaces_api_views_sta_datastream_get_datastream_visualization_bootstrap", + "description": "Get ETL Data Connections associated with the authenticated user.", + "operationId": "interfaces_api_views_etl_data_connection_get_data_connections", "parameters": [ { - "description": "Filter visualization bootstrap datastreams by workspace ID.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, "in": "query", - "name": "workspace_id", + "name": "bbox", "required": false, "schema": { - "default": [], - "description": "Filter visualization bootstrap datastreams by workspace ID.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "items": { - "format": "uuid", - "type": "string" + "type": "number" }, - "title": "Workspace Id", - "type": "array" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DatastreamVisualizationBootstrapResponse" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 } - } + ], + "type": "array" }, - "description": "Unauthorized" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] + "style": "form" }, - { - "BasicAuth": [] - } - ], - "summary": "Get Datastream Visualization Bootstrap", - "tags": [ - "Datastreams" - ] - } - }, - "/api/ogc/collections/etl-data-connections/items": { - "get": { - "description": "Get ETL Data Connections associated with the authenticated user.", - "operationId": "interfaces_api_views_etl_data_connection_get_data_connections", - "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -19930,6 +20428,31 @@ "description": "Get ETL mappings.", "operationId": "interfaces_api_views_etl_mapping_get_etl_mappings", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -20550,6 +21073,31 @@ "description": "Get ETL Tasks accessible to the authenticated user.", "operationId": "interfaces_api_views_etl_task_get_etl_tasks", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -21756,6 +22304,31 @@ "description": "Get Linked Resource Types.", "operationId": "interfaces_api_views_sta_linked_resource_type_get_linked_resource_types", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -22241,6 +22814,31 @@ "description": "Get Method Types.", "operationId": "interfaces_api_views_sta_method_type_get_method_types", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -22726,6 +23324,31 @@ "description": "Get public Methods and Methods associated with the authenticated user.", "operationId": "interfaces_api_views_sta_method_get_methods", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -23409,6 +24032,31 @@ "description": "Get monitoring rules.", "operationId": "interfaces_api_views_monitoring_rule_get_monitoring_rules", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -24041,6 +24689,31 @@ "description": "Get Monitoring Site Types.", "operationId": "interfaces_api_views_sta_monitoring_site_type_get_monitoring_site_types", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -24526,6 +25199,31 @@ "description": "Get public MonitoringSites and MonitoringSites associated with the authenticated user.", "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_sites", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -24702,31 +25400,6 @@ "type": "array" } }, - { - "description": "Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", - "explode": false, - "in": "query", - "name": "bbox", - "required": false, - "schema": { - "description": "Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", - "items": { - "type": "number" - }, - "oneOf": [ - { - "maxItems": 4, - "minItems": 4 - }, - { - "maxItems": 6, - "minItems": 6 - } - ], - "type": "array" - }, - "style": "form" - }, { "description": "Filter monitoring sites by admin area 1.", "in": "query", @@ -25779,29 +26452,13 @@ "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers", "parameters": [ { - "description": "Filter markers by workspace ID.", - "in": "query", - "name": "workspace_id", - "required": false, - "schema": { - "default": [], - "description": "Filter markers by workspace ID.", - "items": { - "format": "uuid", - "type": "string" - }, - "title": "Workspace Id", - "type": "array" - } - }, - { - "description": "Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "explode": false, "in": "query", "name": "bbox", "required": false, "schema": { - "description": "Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "items": { "type": "number" }, @@ -25819,6 +26476,22 @@ }, "style": "form" }, + { + "description": "Filter markers by workspace ID.", + "in": "query", + "name": "workspace_id", + "required": false, + "schema": { + "default": [], + "description": "Filter markers by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" + } + }, { "description": "Filter markers by monitoring site type.", "in": "query", @@ -26172,6 +26845,31 @@ "description": "Get monitoring tasks accessible to the authenticated user.", "operationId": "interfaces_api_views_monitoring_task_get_monitoring_tasks", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -27473,6 +28171,31 @@ "description": "Get Observations.", "operationId": "interfaces_api_views_sta_observation_get_observations", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", "explode": false, @@ -28032,6 +28755,31 @@ "description": "Get public Observed Properties and Observed Properties associated with the authenticated user.", "operationId": "interfaces_api_views_sta_observed_property_get_observed_properties", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -28671,6 +29419,31 @@ "description": "Get Observed Property Types.", "operationId": "interfaces_api_views_sta_observed_property_type_get_observed_property_types", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -29156,6 +29929,31 @@ "description": "Get public Processing Levels and Processing Levels associated with the authenticated user.", "operationId": "interfaces_api_views_sta_processing_level_get_processing_levels", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -29774,6 +30572,31 @@ "description": "Get QC histories.", "operationId": "interfaces_api_views_quality_history_get_qc_histories", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -31731,6 +32554,31 @@ "description": "Get public Result Qualifiers and Result Qualifiers associated with the authenticated user.", "operationId": "interfaces_api_views_sta_result_qualifier_get_result_qualifiers", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -32262,6 +33110,31 @@ "description": "Get public Roles and Roles associated with the authenticated user.", "operationId": "interfaces_api_views_iam_role_get_roles", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -32536,6 +33409,31 @@ "description": "Get Sampled Mediums.", "operationId": "interfaces_api_views_sta_sampled_medium_get_sampled_mediums", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -33021,6 +33919,31 @@ "description": "Get Unit Types.", "operationId": "interfaces_api_views_sta_unit_type_get_unit_types", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -33261,251 +34184,276 @@ "BasicAuth": [] } ], - "summary": "Create Unit Type", + "summary": "Create Unit Type", + "tags": [ + "Unit Types" + ] + } + }, + "/api/ogc/collections/unit-types/items/{unit_type_id}": { + "delete": { + "description": "Delete a Unit Type.", + "operationId": "interfaces_api_views_sta_unit_type_delete_unit_type", + "parameters": [ + { + "in": "path", + "name": "unit_type_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Unit Type Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Delete Unit Type", + "tags": [ + "Unit Types" + ] + }, + "get": { + "description": "Get a Unit Type.", + "operationId": "interfaces_api_views_sta_unit_type_get_unit_type", + "parameters": [ + { + "in": "path", + "name": "unit_type_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Unit Type Id", + "type": "string" + } + }, + { + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "explode": false, + "in": "query", + "name": "properties", + "required": false, + "schema": { + "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "items": { + "enum": [ + "id", + "name", + "description" + ], + "type": "string" + }, + "title": "Properties", + "type": "array" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemResponse_UnitTypeResponse_" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Unit Type", + "tags": [ + "Unit Types" + ] + }, + "patch": { + "description": "Update a Unit Type.", + "operationId": "interfaces_api_views_sta_unit_type_update_unit_type", + "parameters": [ + { + "in": "path", + "name": "unit_type_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Unit Type Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnitTypePatchBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Update Unit Type", "tags": [ "Unit Types" ] } }, - "/api/ogc/collections/unit-types/items/{unit_type_id}": { - "delete": { - "description": "Delete a Unit Type.", - "operationId": "interfaces_api_views_sta_unit_type_delete_unit_type", - "parameters": [ - { - "in": "path", - "name": "unit_type_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Unit Type Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "No Content" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Delete Unit Type", - "tags": [ - "Unit Types" - ] - }, + "/api/ogc/collections/units/items": { "get": { - "description": "Get a Unit Type.", - "operationId": "interfaces_api_views_sta_unit_type_get_unit_type", + "description": "Get public Units and Units associated with the authenticated user.", + "operationId": "interfaces_api_views_sta_unit_get_units", "parameters": [ { - "in": "path", - "name": "unit_type_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Unit Type Id", - "type": "string" - } - }, - { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "explode": false, "in": "query", - "name": "properties", + "name": "bbox", "required": false, "schema": { - "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "items": { - "enum": [ - "id", - "name", - "description" - ], - "type": "string" + "type": "number" }, - "title": "Properties", + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], "type": "array" }, "style": "form" - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ItemResponse_UnitTypeResponse_" - } - } - }, - "description": "OK" - }, - "400": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - } - }, - "security": [ - { - "SessionAuth": [] }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Get Unit Type", - "tags": [ - "Unit Types" - ] - }, - "patch": { - "description": "Update a Unit Type.", - "operationId": "interfaces_api_views_sta_unit_type_update_unit_type", - "parameters": [ - { - "in": "path", - "name": "unit_type_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Unit Type Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnitTypePatchBody" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "No Content" - }, - "401": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "title": "Response", - "type": "string" - } - } - }, - "description": "Forbidden" - } - }, - "security": [ - { - "SessionAuth": [] - }, - { - "OIDCAuth": [] - }, - { - "APIKeyAuth": [] - }, - { - "BasicAuth": [] - } - ], - "summary": "Update Unit Type", - "tags": [ - "Unit Types" - ] - } - }, - "/api/ogc/collections/units/items": { - "get": { - "description": "Get public Units and Units associated with the authenticated user.", - "operationId": "interfaces_api_views_sta_unit_get_units", - "parameters": [ { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -34132,6 +35080,31 @@ "description": "Get public workspaces and workspaces associated with the authenticated user.", "operationId": "interfaces_api_views_iam_workspace_get_workspaces", "parameters": [ + { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", + "items": { + "type": "number" + }, + "oneOf": [ + { + "maxItems": 4, + "minItems": 4 + }, + { + "maxItems": 6, + "minItems": 6 + } + ], + "type": "array" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, diff --git a/django/interfaces/api/schemas/__init__.py b/django/interfaces/api/schemas/__init__.py index b54ad400..8c878fe8 100644 --- a/django/interfaces/api/schemas/__init__.py +++ b/django/interfaces/api/schemas/__init__.py @@ -1,4 +1,5 @@ from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, PaginationMeta, PaginatedResponse, CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema) from .bbox import BoundingBox, BoundingBoxQuery, parse_bbox diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index ffea6261..ce5312bc 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -14,6 +14,7 @@ ) from core.types import Unset +from interfaces.api.schemas.bbox import BoundingBoxQuery base_alias_generator = AliasGenerator( serialization_alias=lambda field_name: to_camel(field_name), @@ -124,6 +125,15 @@ class CollectionQueryParameters(BaseQueryParameters): ) +class ExtentQueryParameters(Schema): + """ + Query parameters that filter items by their spatial and temporal extent, shared by every + OGC API collection items endpoint. + """ + + bbox: BoundingBoxQuery = Query(None) + + class BaseGetResponse(Schema): model_config = ConfigDict( populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel diff --git a/django/interfaces/api/schemas/bbox.py b/django/interfaces/api/schemas/bbox.py index 032d821a..474a4b06 100644 --- a/django/interfaces/api/schemas/bbox.py +++ b/django/interfaces/api/schemas/bbox.py @@ -83,6 +83,9 @@ def parse_bbox(value: Any) -> Optional[BoundingBox]: BBOX_JSON_SCHEMA = { + "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, " + "or six values including min and max heights (heights are not used for filtering). A min_lon " + "greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "type": "array", "oneOf": [{"minItems": 4, "maxItems": 4}, {"minItems": 6, "maxItems": 6}], "items": {"type": "number"}, diff --git a/django/interfaces/api/schemas/etl/data_connection.py b/django/interfaces/api/schemas/etl/data_connection.py index 1c4711c2..d3630608 100644 --- a/django/interfaces/api/schemas/etl/data_connection.py +++ b/django/interfaces/api/schemas/etl/data_connection.py @@ -14,6 +14,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -93,7 +94,7 @@ class DataConnectionItemQueryParameters(DataConnectionFilterFields, BaseQueryPar pass -class DataConnectionQueryParameters(DataConnectionFilterFields, CollectionQueryParameters): +class DataConnectionQueryParameters(DataConnectionFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[DataConnectionSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/etl/mapping.py b/django/interfaces/api/schemas/etl/mapping.py index 0de2ef2b..36bfec2b 100644 --- a/django/interfaces/api/schemas/etl/mapping.py +++ b/django/interfaces/api/schemas/etl/mapping.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, DatastreamResponse, split_comma_separated, comma_array_schema, @@ -71,7 +72,7 @@ class EtlMappingItemQueryParameters(EtlMappingFilterFields, BaseQueryParameters) pass -class EtlMappingQueryParameters(EtlMappingFilterFields, CollectionQueryParameters): +class EtlMappingQueryParameters(EtlMappingFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[EtlMappingSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/etl/task.py b/django/interfaces/api/schemas/etl/task.py index 6586729b..8ceecbad 100644 --- a/django/interfaces/api/schemas/etl/task.py +++ b/django/interfaces/api/schemas/etl/task.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, DataConnectionResponse, split_comma_separated, comma_array_schema, @@ -89,7 +90,7 @@ class EtlTaskItemQueryParameters(EtlTaskFilterFields, BaseQueryParameters): pass -class EtlTaskQueryParameters(EtlTaskFilterFields, CollectionQueryParameters): +class EtlTaskQueryParameters(EtlTaskFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[EtlTaskSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/iam/role.py b/django/interfaces/api/schemas/iam/role.py index 482a9a72..165aceed 100644 --- a/django/interfaces/api/schemas/iam/role.py +++ b/django/interfaces/api/schemas/iam/role.py @@ -11,6 +11,7 @@ BaseGetResponse, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, split_comma_separated, comma_array_schema, ) @@ -57,7 +58,7 @@ class RoleItemQueryParameters(RoleFilterFields, BaseQueryParameters): pass -class RoleQueryParameters(RoleFilterFields, CollectionQueryParameters): +class RoleQueryParameters(RoleFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[RoleSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/iam/workspace.py b/django/interfaces/api/schemas/iam/workspace.py index 5709811b..925c2bb6 100644 --- a/django/interfaces/api/schemas/iam/workspace.py +++ b/django/interfaces/api/schemas/iam/workspace.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, UserContactResponse, RoleResponse, split_comma_separated, @@ -84,7 +85,7 @@ class WorkspaceItemQueryParameters(WorkspaceFilterFields, BaseQueryParameters): pass -class WorkspaceQueryParameters(WorkspaceFilterFields, CollectionQueryParameters): +class WorkspaceQueryParameters(WorkspaceFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[WorkspaceSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/monitoring/rule.py b/django/interfaces/api/schemas/monitoring/rule.py index c0c2b235..d9e99804 100644 --- a/django/interfaces/api/schemas/monitoring/rule.py +++ b/django/interfaces/api/schemas/monitoring/rule.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, DatastreamResponse, split_comma_separated, comma_array_schema, @@ -76,7 +77,7 @@ class MonitoringRuleItemQueryParameters(MonitoringRuleFilterFields, BaseQueryPar pass -class MonitoringRuleQueryParameters(MonitoringRuleFilterFields, CollectionQueryParameters): +class MonitoringRuleQueryParameters(MonitoringRuleFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[MonitoringRuleSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/monitoring/task.py b/django/interfaces/api/schemas/monitoring/task.py index 99954848..b66030b7 100644 --- a/django/interfaces/api/schemas/monitoring/task.py +++ b/django/interfaces/api/schemas/monitoring/task.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, MonitoringSiteResponse, split_comma_separated, comma_array_schema, @@ -91,7 +92,7 @@ class MonitoringTaskItemQueryParameters(MonitoringTaskFilterFields, BaseQueryPar pass -class MonitoringTaskQueryParameters(MonitoringTaskFilterFields, CollectionQueryParameters): +class MonitoringTaskQueryParameters(MonitoringTaskFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[MonitoringTaskSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/products/rating_curve.py b/django/interfaces/api/schemas/products/rating_curve.py index ee71173c..693ffc09 100644 --- a/django/interfaces/api/schemas/products/rating_curve.py +++ b/django/interfaces/api/schemas/products/rating_curve.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, MonitoringSiteResponse, split_comma_separated, comma_array_schema, @@ -81,7 +82,7 @@ class RatingCurveItemQueryParameters(RatingCurveFilterFields, BaseQueryParameter pass -class RatingCurveQueryParameters(RatingCurveFilterFields, CollectionQueryParameters): +class RatingCurveQueryParameters(RatingCurveFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[RatingCurveSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/products/task.py b/django/interfaces/api/schemas/products/task.py index 6d5139df..4864c6a6 100644 --- a/django/interfaces/api/schemas/products/task.py +++ b/django/interfaces/api/schemas/products/task.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, MonitoringSiteResponse, split_comma_separated, comma_array_schema, @@ -90,7 +91,7 @@ class DataProductTaskItemQueryParameters(DataProductTaskFilterFields, BaseQueryP pass -class DataProductTaskQueryParameters(DataProductTaskFilterFields, CollectionQueryParameters): +class DataProductTaskQueryParameters(DataProductTaskFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[DataProductTaskSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/products/transformation.py b/django/interfaces/api/schemas/products/transformation.py index a13eec50..00e02f41 100644 --- a/django/interfaces/api/schemas/products/transformation.py +++ b/django/interfaces/api/schemas/products/transformation.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, DatastreamResponse, split_comma_separated, comma_array_schema, @@ -105,7 +106,7 @@ class DataProductTransformationItemQueryParameters( class DataProductTransformationQueryParameters( - DataProductTransformationFilterFields, CollectionQueryParameters + DataProductTransformationFilterFields, CollectionQueryParameters, ExtentQueryParameters ): sortby: Optional[list[DataProductTransformationSortByFields]] = Query( [], description="Select one or more fields to sort the response by." diff --git a/django/interfaces/api/schemas/quality/history.py b/django/interfaces/api/schemas/quality/history.py index dad50d16..ca7d40fe 100644 --- a/django/interfaces/api/schemas/quality/history.py +++ b/django/interfaces/api/schemas/quality/history.py @@ -10,6 +10,7 @@ BasePostBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, DatastreamResponse, split_comma_separated, comma_array_schema, @@ -77,7 +78,7 @@ class QualityControlHistoryItemQueryParameters( class QualityControlHistoryQueryParameters( - QualityControlHistoryFilterFields, CollectionQueryParameters + QualityControlHistoryFilterFields, CollectionQueryParameters, ExtentQueryParameters ): sortby: Optional[list[QualityControlHistorySortByFields]] = Query( [], description="Select one or more fields to sort the response by." diff --git a/django/interfaces/api/schemas/sta/datastream.py b/django/interfaces/api/schemas/sta/datastream.py index d076e3ca..af30f953 100644 --- a/django/interfaces/api/schemas/sta/datastream.py +++ b/django/interfaces/api/schemas/sta/datastream.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, MonitoringSiteResponse, ObservedPropertyResponse, @@ -165,7 +166,7 @@ class DatastreamItemQueryParameters(DatastreamFilterFields, BaseQueryParameters) pass -class DatastreamQueryParameters(DatastreamFilterFields, CollectionQueryParameters): +class DatastreamQueryParameters(DatastreamFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[DatastreamSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/sta/method.py b/django/interfaces/api/schemas/sta/method.py index 7b115e08..08418108 100644 --- a/django/interfaces/api/schemas/sta/method.py +++ b/django/interfaces/api/schemas/sta/method.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -84,7 +85,7 @@ class MethodItemQueryParameters(MethodFilterFields, BaseQueryParameters): pass -class MethodQueryParameters(MethodFilterFields, CollectionQueryParameters): +class MethodQueryParameters(MethodFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[MethodSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/sta/monitoring_site.py b/django/interfaces/api/schemas/sta/monitoring_site.py index 61ad1a8b..a3bdec92 100644 --- a/django/interfaces/api/schemas/sta/monitoring_site.py +++ b/django/interfaces/api/schemas/sta/monitoring_site.py @@ -14,7 +14,7 @@ BasePostBody, BaseQueryParameters, CollectionQueryParameters, - BoundingBoxQuery, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -123,7 +123,7 @@ class MonitoringSiteItemQueryParameters(MonitoringSiteFilterFields, BaseQueryPar pass -class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryParameters): +class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[MonitoringSiteSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) @@ -135,12 +135,6 @@ class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryP workspace_id: list[uuid.UUID] = Query( [], description="Filter monitoring sites by workspace ID." ) - bbox: BoundingBoxQuery = Query( - None, - description="Filter monitoring sites by bounding box in WGS 84 longitude/latitude: " - "min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). " - "A six-value box with min and max heights is accepted; heights are not used for filtering.", - ) admin_area_1: list[str] = Query( [], description="Filter monitoring sites by admin area 1." ) @@ -158,16 +152,10 @@ class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryP ) -class MonitoringSiteMarkerQueryParameters(BaseQueryParameters): +class MonitoringSiteMarkerQueryParameters(BaseQueryParameters, ExtentQueryParameters): workspace_id: list[uuid.UUID] = Query( [], description="Filter markers by workspace ID." ) - bbox: BoundingBoxQuery = Query( - None, - description="Filter markers by bounding box in WGS 84 longitude/latitude: " - "min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). " - "A six-value box with min and max heights is accepted; heights are not used for filtering.", - ) type: list[str] = Query([], description="Filter markers by monitoring site type.") diff --git a/django/interfaces/api/schemas/sta/observation.py b/django/interfaces/api/schemas/sta/observation.py index cba0c8bc..5036638e 100644 --- a/django/interfaces/api/schemas/sta/observation.py +++ b/django/interfaces/api/schemas/sta/observation.py @@ -13,6 +13,7 @@ BasePostBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, PaginationMeta, WorkspaceResponse, DatastreamResponse, @@ -88,7 +89,7 @@ class ObservationItemQueryParameters(ObservationFilterFields, BaseQueryParameter pass -class ObservationQueryParameters(ObservationFilterFields, CollectionQueryParameters): +class ObservationQueryParameters(ObservationFilterFields, CollectionQueryParameters, ExtentQueryParameters): datastream_id: list[uuid.UUID] = Query( [], description="Filter observations by datastream ID." ) diff --git a/django/interfaces/api/schemas/sta/observed_property.py b/django/interfaces/api/schemas/sta/observed_property.py index 1ac805fd..79b75712 100644 --- a/django/interfaces/api/schemas/sta/observed_property.py +++ b/django/interfaces/api/schemas/sta/observed_property.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -81,7 +82,7 @@ class ObservedPropertyItemQueryParameters(ObservedPropertyFilterFields, BaseQuer pass -class ObservedPropertyQueryParameters(ObservedPropertyFilterFields, CollectionQueryParameters): +class ObservedPropertyQueryParameters(ObservedPropertyFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[ObservedPropertySortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/sta/processing_level.py b/django/interfaces/api/schemas/sta/processing_level.py index 29d5cf13..5411bed4 100644 --- a/django/interfaces/api/schemas/sta/processing_level.py +++ b/django/interfaces/api/schemas/sta/processing_level.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -68,7 +69,7 @@ class ProcessingLevelItemQueryParameters(ProcessingLevelFilterFields, BaseQueryP pass -class ProcessingLevelQueryParameters(ProcessingLevelFilterFields, CollectionQueryParameters): +class ProcessingLevelQueryParameters(ProcessingLevelFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[ProcessingLevelSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/sta/result_qualifier.py b/django/interfaces/api/schemas/sta/result_qualifier.py index 7b3ac6eb..ab464787 100644 --- a/django/interfaces/api/schemas/sta/result_qualifier.py +++ b/django/interfaces/api/schemas/sta/result_qualifier.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -71,7 +72,7 @@ class ResultQualifierItemQueryParameters( class ResultQualifierQueryParameters( - ResultQualifierFilterFields, CollectionQueryParameters + ResultQualifierFilterFields, CollectionQueryParameters, ExtentQueryParameters ): sortby: Optional[list[ResultQualifierSortByFields]] = Query( [], description="Select one or more fields to sort the response by." diff --git a/django/interfaces/api/schemas/sta/unit.py b/django/interfaces/api/schemas/sta/unit.py index 6d590e31..80187b0d 100644 --- a/django/interfaces/api/schemas/sta/unit.py +++ b/django/interfaces/api/schemas/sta/unit.py @@ -12,6 +12,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -76,7 +77,7 @@ class UnitItemQueryParameters(UnitFilterFields, BaseQueryParameters): pass -class UnitQueryParameters(UnitFilterFields, CollectionQueryParameters): +class UnitQueryParameters(UnitFilterFields, CollectionQueryParameters, ExtentQueryParameters): sortby: Optional[list[UnitSortByFields]] = Query( [], description="Select one or more fields to sort the response by." ) diff --git a/django/interfaces/api/schemas/sta/vocabulary.py b/django/interfaces/api/schemas/sta/vocabulary.py index 32b940b6..b7072755 100644 --- a/django/interfaces/api/schemas/sta/vocabulary.py +++ b/django/interfaces/api/schemas/sta/vocabulary.py @@ -11,6 +11,7 @@ BasePatchBody, BaseQueryParameters, CollectionQueryParameters, + ExtentQueryParameters, split_comma_separated, comma_array_schema, ) @@ -52,7 +53,7 @@ class VocabularyItemQueryParameters( class VocabularyQueryParameters( - VocabularyFilterFields, CollectionQueryParameters + VocabularyFilterFields, CollectionQueryParameters, ExtentQueryParameters ): sortby: Optional[list[VocabularySortByFields]] = Query( [], description="Select one or more fields to sort the response by." diff --git a/django/tests/interfaces/api/test_bbox.py b/django/tests/interfaces/api/test_bbox.py index 3d9854f4..cef243d5 100644 --- a/django/tests/interfaces/api/test_bbox.py +++ b/django/tests/interfaces/api/test_bbox.py @@ -8,6 +8,7 @@ from interfaces.api.http.api import HydroServerNinjaAPI from interfaces.api.schemas import BaseQueryParameters, BoundingBox, BoundingBoxQuery, parse_bbox from interfaces.api.service import APIService +from interfaces.api.urls import api from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory @@ -157,6 +158,25 @@ def test_bounding_box_query_rejects_invalid_values_during_model_validation(): _BBoxQueryParameters.model_validate({"bbox": "-112,40,-111"}) +def test_bbox_is_declared_on_every_collection_items_list_and_no_nested_list(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + + def declares_bbox(path): + return any(p["name"] == "bbox" for p in paths[path]["get"].get("parameters", [])) + + items_lists = [path for path in paths if path.endswith("/items") and "get" in paths[path]] + nested_lists = [ + path + for path in paths + if "/items/{" in path and not path.rsplit("/", 1)[-1].startswith("{") and "get" in paths[path] + and not path.endswith("/csv") + ] + + assert len(items_lists) == 27 + assert [path for path in items_lists if not declares_bbox(path)] == [] + assert nested_lists and [path for path in nested_lists if declares_bbox(path)] == [] + + # --- APIService.apply_bbox ---------------------------------------------------------------- diff --git a/django/tests/interfaces/api/test_non_spatial_bbox.py b/django/tests/interfaces/api/test_non_spatial_bbox.py new file mode 100644 index 00000000..17d747bd --- /dev/null +++ b/django/tests/interfaces/api/test_non_spatial_bbox.py @@ -0,0 +1,103 @@ +import pytest + +from tests.core.iam.factories import UserFactory, WorkspaceFactory +from tests.core.sta.factories import ( + AggregationStatisticFactory, + DatastreamStatusFactory, + LinkedResourceTypeFactory, + MethodFactory, + MethodTypeFactory, + MonitoringSiteTypeFactory, + ObservedPropertyFactory, + ObservedPropertyTypeFactory, + ProcessingLevelFactory, + ResultQualifierFactory, + SampledMediumFactory, + UnitFactory, + UnitTypeFactory, +) + +pytestmark = pytest.mark.django_db + +# Collections whose items have no geometry: bbox is validated, and every item matches +# (OGC API - Features - Part 1: Core, Req 24C). +NON_SPATIAL_COLLECTIONS = [ + "observed-properties", + "units", + "methods", + "processing-levels", + "result-qualifiers", + "monitoring-site-types", + "linked-resource-types", + "observed-property-types", + "unit-types", + "method-types", + "sampled-mediums", + "aggregation-statistics", + "datastream-statuses", + "roles", + "etl-data-connections", + "etl-mappings", + "etl-tasks", + "data-product-rating-curves", + "data-product-tasks", + "data-product-transformations", + "monitoring-rules", + "monitoring-tasks", + "quality-control-histories", +] + +ITEM_FACTORIES = { + "observed-properties": lambda workspace: ObservedPropertyFactory(workspace=workspace), + "units": lambda workspace: UnitFactory(workspace=workspace), + "methods": lambda workspace: MethodFactory(workspace=workspace), + "processing-levels": lambda workspace: ProcessingLevelFactory(workspace=workspace), + "result-qualifiers": lambda workspace: ResultQualifierFactory(workspace=workspace), + "monitoring-site-types": lambda workspace: MonitoringSiteTypeFactory(), + "linked-resource-types": lambda workspace: LinkedResourceTypeFactory(), + "observed-property-types": lambda workspace: ObservedPropertyTypeFactory(), + "unit-types": lambda workspace: UnitTypeFactory(), + "method-types": lambda workspace: MethodTypeFactory(), + "sampled-mediums": lambda workspace: SampledMediumFactory(), + "aggregation-statistics": lambda workspace: AggregationStatisticFactory(), + "datastream-statuses": lambda workspace: DatastreamStatusFactory(), +} + + +def _items_url(collection): + return f"/api/ogc/collections/{collection}/items" + + +@pytest.fixture +def owner_workspace(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + client.force_login(owner) + return workspace + + +@pytest.mark.parametrize("collection", NON_SPATIAL_COLLECTIONS) +@pytest.mark.parametrize("bbox", ["-112,40,-111", "-181,40,-111,41", "-112,41,-111,40", "not,a,valid,bbox"]) +def test_non_spatial_collection_returns_400_for_invalid_bbox(client, owner_workspace, collection, bbox): + response = client.get(_items_url(collection), {"bbox": bbox}) + + assert response.status_code == 400 + + +@pytest.mark.parametrize("collection", NON_SPATIAL_COLLECTIONS) +def test_non_spatial_collection_ignores_a_valid_bbox(client, owner_workspace, collection): + unfiltered = client.get(_items_url(collection)) + filtered = client.get(_items_url(collection), {"bbox": "170,-20,-170,-10"}) + + assert filtered.status_code == 200 + assert filtered.json() == unfiltered.json() + + +@pytest.mark.parametrize("collection", ITEM_FACTORIES) +def test_non_spatial_collection_returns_items_for_any_bbox(client, owner_workspace, collection): + item = ITEM_FACTORIES[collection](owner_workspace) + + response = client.get(_items_url(collection), {"bbox": "0,0,0.001,0.001"}) + + assert response.status_code == 200 + assert str(item.id) in [i["id"] for i in response.json()["data"]] diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 3be4d07f..44a37853 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2348,6 +2348,8 @@ export interface components { }; /** DataConnectionQueryParameters */ DataConnectionQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -2510,6 +2512,8 @@ export interface components { }; /** DataProductTaskQueryParameters */ DataProductTaskQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -2711,6 +2715,8 @@ export interface components { }; /** DataProductTransformationQueryParameters */ DataProductTransformationQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -2996,6 +3002,8 @@ export interface components { }; /** DatastreamQueryParameters */ DatastreamQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -3347,6 +3355,8 @@ export interface components { }; /** EtlMappingQueryParameters */ EtlMappingQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Etl Task Id * @description Filter mappings by ETL task ID. @@ -3473,6 +3483,8 @@ export interface components { }; /** EtlTaskQueryParameters */ EtlTaskQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Data Connection Id * @description Filter ETL tasks by data connection ID. @@ -3995,6 +4007,8 @@ export interface components { }; /** MethodQueryParameters */ MethodQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter methods by datastream ID. @@ -4179,6 +4193,8 @@ export interface components { }; /** MonitoringRuleQueryParameters */ MonitoringRuleQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter rules by datastream ID. @@ -4324,7 +4340,7 @@ export interface components { }; /** MonitoringSiteMarkerQueryParameters */ MonitoringSiteMarkerQueryParameters: { - /** @description Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); /** * Type @@ -4452,7 +4468,7 @@ export interface components { * @default [] */ admin_area_2: string[]; - /** @description Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); /** * Country @@ -4695,6 +4711,8 @@ export interface components { }; /** MonitoringTaskQueryParameters */ MonitoringTaskQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter monitoring tasks by datastream ID. @@ -4915,6 +4933,8 @@ export interface components { }; /** ObservationQueryParameters */ ObservationQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter observations by datastream ID. @@ -5062,6 +5082,8 @@ export interface components { }; /** ObservedPropertyQueryParameters */ ObservedPropertyQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter observed properties by datastream ID. @@ -5612,6 +5634,8 @@ export interface components { }; /** ProcessingLevelQueryParameters */ ProcessingLevelQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter processing levels by datastream ID. @@ -5710,6 +5734,8 @@ export interface components { }; /** QualityControlHistoryQueryParameters */ QualityControlHistoryQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6054,6 +6080,8 @@ export interface components { }; /** RatingCurveQueryParameters */ RatingCurveQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6166,6 +6194,8 @@ export interface components { }; /** ResultQualifierQueryParameters */ ResultQualifierQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6233,6 +6263,8 @@ export interface components { }; /** RoleQueryParameters */ RoleQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6628,6 +6660,8 @@ export interface components { }; /** UnitQueryParameters */ UnitQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Datastream Id * @description Filter units by datastream ID. @@ -6869,6 +6903,8 @@ export interface components { }; /** VocabularyQueryParameters */ VocabularyQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6934,6 +6970,8 @@ export interface components { }; /** WorkspaceQueryParameters */ WorkspaceQueryParameters: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** * Include * @description Comma-separated list of related resources to include in the response. @@ -7016,6 +7054,8 @@ export interface operations { interfaces_api_views_sta_aggregation_statistic_get_aggregation_statistics: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -7241,6 +7281,8 @@ export interface operations { interfaces_api_views_products_rating_curve_get_rating_curves: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "monitoringSiteId" | "name" | "description" | "fittingMethod" | "points")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -7508,6 +7550,8 @@ export interface operations { interfaces_api_views_products_task_get_data_product_tasks: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "monitoringSiteId" | "schedule" | "latestRun" | "transformationTypes" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -7954,6 +7998,8 @@ export interface operations { interfaces_api_views_products_transformation_get_data_product_transformations: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "transformationType" | "outputDatastreamId" | "inputDatastreams" | "ratingCurveId" | "formula" | "aggregationMethod" | "outputIntervalUnits" | "outputInterval" | "timezoneType" | "timezone" | "minValues" | "stopOnNoData" | "stopOnError")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -8243,6 +8289,8 @@ export interface operations { interfaces_api_views_sta_datastream_status_get_datastream_statuses: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -8468,6 +8516,8 @@ export interface operations { interfaces_api_views_sta_datastream_get_datastreams: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -9098,6 +9148,8 @@ export interface operations { interfaces_api_views_etl_data_connection_get_data_connections: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description" | "sourceUrl" | "authHeaderName" | "authHeaderValue" | "timezoneType" | "timezone" | "workspaceId" | "payload" | "placeholderVariables" | "notification" | "taskCount" | "taskAttentionCount")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -9356,6 +9408,8 @@ export interface operations { interfaces_api_views_etl_mapping_get_etl_mappings: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "sourceIdentifier" | "targetDatastreamId")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -9643,6 +9697,8 @@ export interface operations { interfaces_api_views_etl_task_get_etl_tasks: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "dataConnectionId" | "schedule" | "latestRun" | "mappingCount" | "name" | "description" | "taskVariables")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10091,6 +10147,8 @@ export interface operations { interfaces_api_views_sta_linked_resource_type_get_linked_resource_types: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10316,6 +10374,8 @@ export interface operations { interfaces_api_views_sta_method_type_get_method_types: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10541,6 +10601,8 @@ export interface operations { interfaces_api_views_sta_method_get_methods: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "code" | "type" | "description" | "definition" | "sensorModel" | "sensorModelManufacturer" | "sensorModelDefinition")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10807,6 +10869,8 @@ export interface operations { interfaces_api_views_monitoring_rule_get_monitoring_rules: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "datastreamId" | "ruleType" | "lastCheckedAt" | "minValue" | "maxValue" | "windowInterval" | "windowIntervalUnits")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -11094,6 +11158,8 @@ export interface operations { interfaces_api_views_sta_monitoring_site_type_get_monitoring_site_types: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -11319,6 +11385,8 @@ export interface operations { interfaces_api_views_sta_monitoring_site_get_monitoring_sites: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description" | "code" | "type" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "dataDisclaimer" | "isPrivate" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -11333,8 +11401,6 @@ export interface operations { q?: string | null; /** @description Filter monitoring sites by workspace ID. */ workspace_id?: string[]; - /** @description Filter monitoring sites by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ - bbox?: number[] & (unknown | unknown); /** @description Filter monitoring sites by admin area 1. */ admin_area_1?: string[]; /** @description Filter monitoring sites by admin area 2. */ @@ -11806,10 +11872,10 @@ export interface operations { interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Filter markers by workspace ID. */ workspace_id?: string[]; - /** @description Filter markers by bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat (a min_lon greater than max_lon crosses the antimeridian). A six-value box with min and max heights is accepted; heights are not used for filtering. */ - bbox?: number[] & (unknown | unknown); /** @description Filter markers by monitoring site type. */ type?: string[]; }; @@ -11964,6 +12030,8 @@ export interface operations { interfaces_api_views_monitoring_task_get_monitoring_tasks: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "monitoringSiteId" | "schedule" | "latestRun" | "ruleTypeCounts" | "recipients" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -12498,6 +12566,8 @@ export interface operations { interfaces_api_views_sta_observation_get_observations: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record. */ properties?: ("id" | "workspaceId" | "datastreamId" | "phenomenonTime" | "result" | "resultQualifierCodes")[]; /** @description Comma-separated list of related resources to include in the response. Only applies to format=record. */ @@ -12709,6 +12779,8 @@ export interface operations { interfaces_api_views_sta_observed_property_get_observed_properties: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "definition" | "description" | "type" | "code")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -12971,6 +13043,8 @@ export interface operations { interfaces_api_views_sta_observed_property_type_get_observed_property_types: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -13196,6 +13270,8 @@ export interface operations { interfaces_api_views_sta_processing_level_get_processing_levels: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "code" | "name" | "description" | "definition")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -13456,6 +13532,8 @@ export interface operations { interfaces_api_views_quality_history_get_qc_histories: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "managedDatastreamId" | "sourceDatastreamId" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "sourceChecksum" | "managedChecksum")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14309,6 +14387,8 @@ export interface operations { interfaces_api_views_sta_result_qualifier_get_result_qualifiers: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14538,6 +14618,8 @@ export interface operations { interfaces_api_views_iam_role_get_roles: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description" | "permissions")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14623,6 +14705,8 @@ export interface operations { interfaces_api_views_sta_sampled_medium_get_sampled_mediums: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14848,6 +14932,8 @@ export interface operations { interfaces_api_views_sta_unit_type_get_unit_types: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -15073,6 +15159,8 @@ export interface operations { interfaces_api_views_sta_unit_get_units: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "symbol" | "definition" | "type")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -15326,6 +15414,8 @@ export interface operations { interfaces_api_views_iam_workspace_get_workspaces: { parameters: { query?: { + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "ownerEmail" | "pendingTransferToEmail" | "collaboratorRoleId" | "name" | "isPrivate")[]; /** @description Comma-separated list of related resources to include in the response. */ From 537ca7074b3da7607b1d65133bb656e1b8d35b66 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 13:57:20 -0700 Subject: [PATCH 06/25] Added bbox filtering for workspaces, datastreams, and observations --- .../interfaces/api/services/iam/workspace.py | 31 ++++++- .../interfaces/api/services/sta/datastream.py | 11 +++ .../api/services/sta/observation.py | 16 +++- django/interfaces/api/views/iam/workspace.py | 1 + django/interfaces/api/views/sta/datastream.py | 1 + .../interfaces/api/views/sta/observation.py | 1 + .../interfaces/api/test_datastream_views.py | 50 +++++++++++ .../interfaces/api/test_observation_views.py | 70 +++++++++++++++ .../interfaces/api/test_workspace_views.py | 90 +++++++++++++++++++ 9 files changed, 268 insertions(+), 3 deletions(-) diff --git a/django/interfaces/api/services/iam/workspace.py b/django/interfaces/api/services/iam/workspace.py index 89e1741e..c7d43311 100644 --- a/django/interfaces/api/services/iam/workspace.py +++ b/django/interfaces/api/services/iam/workspace.py @@ -2,14 +2,16 @@ from typing import Optional, get_args from django.contrib.auth import get_user_model -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.db.utils import IntegrityError from core.iam.models import Workspace, ServiceAccount +from core.sta.models import MonitoringSite from core.iam.permissions.anonymous import AnonymousPrincipal from interfaces.api.service import APIService from interfaces.api.http.errors import BadRequestError, ConflictError, PermissionDeniedError from interfaces.api.schemas import ( + BoundingBox, WorkspacePostBody, WorkspacePatchBody, WorkspaceTransferBody, @@ -49,6 +51,31 @@ def attach_role_and_transfer_fields( return workspace + @classmethod + def apply_visible_site_bbox( + cls, + principal: User | ServiceAccount | AnonymousPrincipal, + queryset, + bbox: Optional[BoundingBox], + ): + """ + Filters workspaces by the monitoring sites the principal can view. A workspace matches + when one of those sites lies inside the box, or when the principal can view none of its + sites, since a workspace without a visible location matches any box (OGC API - Features + Core Req 24C). Sites the principal can't view never affect the result. + """ + + if bbox is None: + return queryset + + visible_sites = principal.filter_by_permission( + MonitoringSite.objects.filter(workspace_id=OuterRef("pk")), "can_view" + ) + + return queryset.filter( + Exists(cls.apply_bbox(visible_sites, bbox)) | ~Exists(visible_sites) + ) + def list( self, principal: User | ServiceAccount | AnonymousPrincipal, @@ -57,6 +84,7 @@ def list( sortby: Optional[list[str]] = None, filtering: Optional[dict] = None, include: Optional[list[str]] = None, + bbox: Optional[BoundingBox] = None, ): requested_includes = self.resolve_include_set(include) queryset = Workspace.objects @@ -90,6 +118,7 @@ def list( else: queryset = self.apply_filters(queryset, field, filtering[field]) + queryset = self.apply_visible_site_bbox(principal, queryset, bbox) queryset, has_search = self.apply_search(queryset, filtering.get("q")) queryset = self.apply_sorting( queryset, diff --git a/django/interfaces/api/services/sta/datastream.py b/django/interfaces/api/services/sta/datastream.py index 2dd20573..240f68ae 100644 --- a/django/interfaces/api/services/sta/datastream.py +++ b/django/interfaces/api/services/sta/datastream.py @@ -18,6 +18,7 @@ from interfaces.api.http.errors import BadRequestError, ConflictError, NotFoundError, PermissionDeniedError from interfaces.api.service import APIService from interfaces.api.schemas import ( + BoundingBox, DatastreamPostBody, DatastreamPatchBody, LinkedResourcePostBody, @@ -101,6 +102,14 @@ def apply_tag_filter(queryset, tags: list[str]): return queryset + @classmethod + def apply_site_bbox(cls, queryset, bbox: Optional[BoundingBox]): + """Filters datastreams to those whose monitoring site lies inside a bounding box.""" + + return cls.apply_bbox( + queryset, bbox, "monitoring_site__latitude", "monitoring_site__longitude" + ) + def list( self, principal: User | ServiceAccount | AnonymousPrincipal, @@ -109,6 +118,7 @@ def list( sortby: Optional[list[str]] = None, filtering: Optional[dict] = None, include: Optional[list[str]] = None, + bbox: Optional[BoundingBox] = None, ): requested_includes = self.resolve_include_set(include) queryset = Datastream.objects @@ -153,6 +163,7 @@ def list( else: queryset = self.apply_filters(queryset, field, filtering[field]) + queryset = self.apply_site_bbox(queryset, bbox) queryset = self.apply_tag_filter(queryset, filtering.get("tag")) queryset, has_search = self.apply_search(queryset, filtering.get("q")) queryset = self.apply_sorting( diff --git a/django/interfaces/api/services/sta/observation.py b/django/interfaces/api/services/sta/observation.py index 1fb87131..0b2440f3 100644 --- a/django/interfaces/api/services/sta/observation.py +++ b/django/interfaces/api/services/sta/observation.py @@ -17,6 +17,7 @@ from interfaces.api.service import APIService from interfaces.api.services.sta.datastream import DatastreamAPIService from interfaces.api.http.errors import BadRequestError, ConflictError, PermissionDeniedError, NotFoundError +from interfaces.api.schemas import BoundingBox from interfaces.api.schemas.sta.observation import ( ObservationFields, ObservationSortByFields, @@ -122,10 +123,12 @@ def _validate_result_qualifier_codes( def sum_datastream_value_count( principal: User | ServiceAccount | AnonymousPrincipal, datastream_ids: list[uuid.UUID], + bbox: Optional[BoundingBox] = None, ) -> int: """ Exact observation count for the given datastreams (or every datastream the - principal can view if none are given), via Datastream's maintained value_count + principal can view if none are given), optionally limited to datastreams whose + monitoring site lies inside a bounding box, via Datastream's maintained value_count rather than a COUNT(*) over Observation. """ @@ -134,6 +137,7 @@ def sum_datastream_value_count( if datastream_ids else Datastream.objects ) + queryset = DatastreamAPIService.apply_site_bbox(queryset, bbox) queryset = principal.filter_by_permission(queryset, "can_view") return queryset.aggregate(total=Sum("value_count"))["total"] or 0 @@ -148,6 +152,7 @@ def list( filtering: Optional[dict] = None, response_format: Optional[str] = None, include: Optional[list[str]] = None, + bbox: Optional[BoundingBox] = None, ): requested_includes = self.resolve_include_set(include) queryset = Observation.objects @@ -169,6 +174,13 @@ def list( code_filter |= Q(result_qualifiers__contains=[code]) queryset = queryset.filter(code_filter) + if bbox is not None: + queryset = queryset.filter( + datastream_id__in=DatastreamAPIService.apply_site_bbox( + Datastream.objects, bbox + ).values("id") + ) + queryset = principal.filter_by_permission(queryset, "can_view") count = ( @@ -178,7 +190,7 @@ def list( or filtering.get("phenomenon_time__gte") or filtering.get("result_qualifier_codes") ) - else self.sum_datastream_value_count(principal, datastream_ids) + else self.sum_datastream_value_count(principal, datastream_ids, bbox) ) # TODO: Can't really fix this until PostgreSQL 18 UUID v7 support diff --git a/django/interfaces/api/views/iam/workspace.py b/django/interfaces/api/views/iam/workspace.py index bac3ed16..bf503419 100644 --- a/django/interfaces/api/views/iam/workspace.py +++ b/django/interfaces/api/views/iam/workspace.py @@ -49,6 +49,7 @@ def get_workspaces( sortby=query.sortby, filtering=query.dict(exclude_unset=True), include=query.include, + bbox=query.bbox, ) diff --git a/django/interfaces/api/views/sta/datastream.py b/django/interfaces/api/views/sta/datastream.py index a6e0c133..3a2cbc75 100644 --- a/django/interfaces/api/views/sta/datastream.py +++ b/django/interfaces/api/views/sta/datastream.py @@ -52,6 +52,7 @@ def get_datastreams( sortby=query.sortby, filtering=query.dict(exclude_unset=True), include=query.include, + bbox=query.bbox, ) diff --git a/django/interfaces/api/views/sta/observation.py b/django/interfaces/api/views/sta/observation.py index 4176175d..d0d93a28 100644 --- a/django/interfaces/api/views/sta/observation.py +++ b/django/interfaces/api/views/sta/observation.py @@ -58,6 +58,7 @@ def get_observations( filtering=query.dict(exclude_unset=True), response_format=query.response_format, include=query.include, + bbox=query.bbox, ) diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index badd7b91..9fdc96b4 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -930,3 +930,53 @@ def test_get_datastreams_properties_filters_response_fields(client): assert response.status_code == 200 row = response.json()["data"][0] assert set(row.keys()) == {"id", "name"} + + +# --- bbox -------------------------------------------------------------------------------- + + +def _datastream_at(longitude, latitude): + return DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=longitude, latitude=latitude)) + + +def _datastream_ids(response): + return {datastream["id"] for datastream in response.json()["data"]} + + +def test_get_datastreams_filters_by_monitoring_site_location(client): + inside = _datastream_at(-111.5, 40.5) + _datastream_at(-100, 40.5) + + response = client.get(DATASTREAMS_URL, {"bbox": "-112,40,-111,41"}) + + assert response.status_code == 200 + assert _datastream_ids(response) == {str(inside.id)} + + +def test_get_datastreams_filters_by_a_bbox_crossing_the_antimeridian(client): + east = _datastream_at(175, -15) + west = _datastream_at(-175, -15) + _datastream_at(0, -15) + + response = client.get(DATASTREAMS_URL, {"bbox": "170,-20,-170,-10"}) + + assert response.status_code == 200 + assert _datastream_ids(response) == {str(east.id), str(west.id)} + + +def test_get_datastreams_combines_bbox_with_other_filters(client): + inside = _datastream_at(-111.5, 40.5) + _datastream_at(-111.5, 40.5) + + response = client.get( + DATASTREAMS_URL, {"bbox": "-112,40,-111,41", "monitoring_site_id": str(inside.monitoring_site_id)} + ) + + assert response.status_code == 200 + assert _datastream_ids(response) == {str(inside.id)} + + +def test_get_datastreams_returns_400_for_invalid_bbox(client): + response = client.get(DATASTREAMS_URL, {"bbox": "-112,40,-111,91"}) + + assert response.status_code == 400 diff --git a/django/tests/interfaces/api/test_observation_views.py b/django/tests/interfaces/api/test_observation_views.py index e37d7a39..2e884f8f 100644 --- a/django/tests/interfaces/api/test_observation_views.py +++ b/django/tests/interfaces/api/test_observation_views.py @@ -978,3 +978,73 @@ def test_delete_observations_returns_400_without_datastream_id(client): ) assert response.status_code == 400 + + +# --- bbox -------------------------------------------------------------------------------- + + +def _datastream_with_observations(longitude, latitude, count=2): + datastream = DatastreamFactory( + monitoring_site=MonitoringSiteFactory(longitude=longitude, latitude=latitude), + value_count=count, + ) + ObservationFactory.create_batch(count, datastream=datastream) + return datastream + + +def test_get_observations_filters_by_monitoring_site_location(client): + inside = _datastream_with_observations(-111.5, 40.5) + _datastream_with_observations(-100, 40.5) + + response = client.get(_observations_url(bbox="-112,40,-111,41")) + + assert response.status_code == 200 + body = response.json() + assert {o["datastreamId"] for o in body["data"]} == {str(inside.id)} + assert len(body["data"]) == 2 + assert body["meta"]["totalCount"] == 2 + + +def test_get_observations_bbox_count_uses_matched_datastreams_with_a_time_filter(client): + inside = _datastream_with_observations(-111.5, 40.5) + _datastream_with_observations(-100, 40.5) + + response = client.get( + _observations_url( + bbox="-112,40,-111,41", + phenomenon_time_min=(timezone.now() - timedelta(days=1)).isoformat(), + ) + ) + + assert response.status_code == 200 + assert {o["datastreamId"] for o in response.json()["data"]} == {str(inside.id)} + assert response.json()["meta"]["totalCount"] == 2 + + +def test_get_observations_bbox_combines_with_datastream_id(client): + inside = _datastream_with_observations(-111.5, 40.5) + outside = _datastream_with_observations(-100, 40.5) + + response = client.get( + _observations_url(datastream_id=[str(inside.id), str(outside.id)], bbox="-112,40,-111,41") + ) + + assert response.status_code == 200 + assert {o["datastreamId"] for o in response.json()["data"]} == {str(inside.id)} + assert response.json()["meta"]["totalCount"] == 2 + + +def test_get_observations_row_format_is_empty_when_the_datastream_is_outside_the_bbox(client): + outside = _datastream_with_observations(-100, 40.5) + + response = client.get(_observations_url(datastream_id=str(outside.id), format="row", bbox="-112,40,-111,41")) + + assert response.status_code == 200 + assert response.json()["data"]["rows"] == [] + assert response.json()["meta"]["totalCount"] == 0 + + +def test_get_observations_returns_400_for_invalid_bbox(client): + response = client.get(_observations_url(bbox="-112,40,-111")) + + assert response.status_code == 400 diff --git a/django/tests/interfaces/api/test_workspace_views.py b/django/tests/interfaces/api/test_workspace_views.py index d0640344..705304d6 100644 --- a/django/tests/interfaces/api/test_workspace_views.py +++ b/django/tests/interfaces/api/test_workspace_views.py @@ -10,6 +10,7 @@ UserFactory, WorkspaceFactory, ) +from tests.core.sta.factories import MonitoringSiteFactory pytestmark = pytest.mark.django_db @@ -635,3 +636,92 @@ def test_reject_workspace_transfer_returns_400_when_none_pending(client): response = client.delete(_transfer_url(workspace.id)) assert response.status_code == 400 + + +# --- bbox -------------------------------------------------------------------------------- +# +# A workspace's location is the set of its monitoring sites the requester can view. It matches +# a bbox when one of those sites lies inside it, or when the requester can view none of its +# sites (no location matches any bbox, per OGC API - Features Core Req 24C). + +INSIDE_BBOX = "-112,40,-111,41" + + +def _site(workspace, longitude, latitude, **kwargs): + return MonitoringSiteFactory(workspace=workspace, longitude=longitude, latitude=latitude, **kwargs) + + +def _workspace_ids(response): + return {workspace["id"] for workspace in response.json()["data"]} + + +def test_get_workspaces_bbox_matches_workspaces_with_a_visible_site_inside(client): + inside = WorkspaceFactory() + _site(inside, -111.5, 40.5) + outside = WorkspaceFactory() + _site(outside, -100, 40.5) + + response = client.get(WORKSPACES_URL, {"bbox": INSIDE_BBOX}) + + assert response.status_code == 200 + assert str(inside.id) in _workspace_ids(response) + assert str(outside.id) not in _workspace_ids(response) + + +def test_get_workspaces_bbox_matches_workspaces_without_sites(client): + empty = WorkspaceFactory() + + response = client.get(WORKSPACES_URL, {"bbox": INSIDE_BBOX}) + + assert str(empty.id) in _workspace_ids(response) + + +def test_get_workspaces_bbox_ignores_private_sites_the_requester_cannot_view(client): + # A private site inside the box must not make the workspace match... + hidden_inside = WorkspaceFactory() + _site(hidden_inside, -100, 40.5) + _site(hidden_inside, -111.5, 40.5, private=True) + # ...and a workspace whose only site is private has no visible location, so it matches. + only_private = WorkspaceFactory() + _site(only_private, -100, 40.5, private=True) + + response = client.get(WORKSPACES_URL, {"bbox": INSIDE_BBOX}) + + assert str(hidden_inside.id) not in _workspace_ids(response) + assert str(only_private.id) in _workspace_ids(response) + + +def test_get_workspaces_bbox_uses_private_sites_the_owner_can_view(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner, private=True) + _site(workspace, -100, 40.5) + _site(workspace, -111.5, 40.5, private=True) + client.force_login(owner) + + inside = client.get(WORKSPACES_URL, {"bbox": INSIDE_BBOX}) + elsewhere = client.get(WORKSPACES_URL, {"bbox": "0,0,1,1"}) + + assert str(workspace.id) in _workspace_ids(inside) + assert str(workspace.id) not in _workspace_ids(elsewhere) + + +def test_get_workspaces_bbox_uses_private_sites_a_collaborator_can_view(client): + workspace = WorkspaceFactory(private=True) + _site(workspace, -111.5, 40.5, private=True) + role = RoleFactory(workspace=workspace) + PermissionFactory(role=role, resource_type="Workspace", can_view=True) + PermissionFactory(role=role, resource_type="MonitoringSite", can_view=True) + collaborator = CollaboratorFactory(workspace=workspace, role=role) + client.force_login(collaborator.user) + + inside = client.get(WORKSPACES_URL, {"bbox": INSIDE_BBOX}) + elsewhere = client.get(WORKSPACES_URL, {"bbox": "0,0,1,1"}) + + assert str(workspace.id) in _workspace_ids(inside) + assert str(workspace.id) not in _workspace_ids(elsewhere) + + +def test_get_workspaces_returns_400_for_invalid_bbox(client): + response = client.get(WORKSPACES_URL, {"bbox": "-112,40,-111"}) + + assert response.status_code == 400 From 67a192eea6b5688ccd0d9e7be1792fa119fe70a6 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 14:52:37 -0700 Subject: [PATCH 07/25] Add shared datetime param schema for OGC API --- django/contracts/openapi/ogc.openapi.json | 436 +++++++++++++++++- django/interfaces/api/schemas/__init__.py | 4 +- django/interfaces/api/schemas/base.py | 10 - .../api/schemas/{bbox.py => extent.py} | 100 ++++ .../api/schemas/sta/monitoring_site.py | 4 +- django/interfaces/api/service.py | 59 ++- django/tests/interfaces/api/test_bbox.py | 226 --------- ...tial_bbox.py => test_collection_extent.py} | 31 ++ django/tests/interfaces/api/test_extent.py | 425 +++++++++++++++++ .../src/generated/ogc.types.d.ts | 98 +++- 10 files changed, 1135 insertions(+), 258 deletions(-) rename django/interfaces/api/schemas/{bbox.py => extent.py} (53%) delete mode 100644 django/tests/interfaces/api/test_bbox.py rename django/tests/interfaces/api/{test_non_spatial_bbox.py => test_collection_extent.py} (70%) create mode 100644 django/tests/interfaces/api/test_extent.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 3f01c1fb..8dd5454a 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -681,6 +681,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -1248,6 +1252,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -1869,6 +1877,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -2704,6 +2716,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -3589,6 +3605,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "etl_task_id": { "default": [], "description": "Filter mappings by ETL task ID.", @@ -3893,6 +3913,10 @@ "title": "Data Connection Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -5527,6 +5551,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -6085,6 +6113,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -6881,6 +6913,10 @@ "title": "Country", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -7492,6 +7528,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -8130,6 +8170,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "format": { "anyOf": [ { @@ -8568,6 +8612,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -10433,6 +10481,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -10700,6 +10752,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -11702,6 +11758,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -11998,6 +12058,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -12184,6 +12248,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "anyOf": [ { @@ -13418,6 +13486,10 @@ "title": "Datastream Id", "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -14042,6 +14114,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "anyOf": [ { @@ -14232,6 +14308,10 @@ ], "type": "array" }, + "datetime": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, "include": { "description": "Comma-separated list of related resources to include in the response.", "items": { @@ -14477,6 +14557,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -14987,6 +15079,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -15611,6 +15715,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -16799,6 +16915,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -17484,6 +17612,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -17994,6 +18134,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -19823,6 +19975,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -20453,6 +20617,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -21098,6 +21274,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -22329,6 +22517,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -22839,6 +23039,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -23349,6 +23561,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -24057,6 +24281,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -24714,6 +24950,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -25224,6 +25472,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -26451,6 +26711,22 @@ "description": "Get lean marker data for public MonitoringSites plus private MonitoringSites visible to the authenticated user.", "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers", "parameters": [ + { + "description": "Filter markers by workspace ID.", + "in": "query", + "name": "workspace_id", + "required": false, + "schema": { + "default": [], + "description": "Filter markers by workspace ID.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Workspace Id", + "type": "array" + } + }, { "description": "Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box.", "explode": false, @@ -26476,22 +26752,6 @@ }, "style": "form" }, - { - "description": "Filter markers by workspace ID.", - "in": "query", - "name": "workspace_id", - "required": false, - "schema": { - "default": [], - "description": "Filter markers by workspace ID.", - "items": { - "format": "uuid", - "type": "string" - }, - "title": "Workspace Id", - "type": "array" - } - }, { "description": "Filter markers by monitoring site type.", "in": "query", @@ -26870,6 +27130,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -28196,6 +28468,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", "explode": false, @@ -28780,6 +29064,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -29444,6 +29740,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -29954,6 +30262,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -30597,6 +30917,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -32579,6 +32911,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -33135,6 +33479,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -33434,6 +33790,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -33944,6 +34312,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -34454,6 +34834,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, @@ -35105,6 +35497,18 @@ }, "style": "form" }, + { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime.", + "type": "string" + }, + "style": "form" + }, { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted.", "explode": false, diff --git a/django/interfaces/api/schemas/__init__.py b/django/interfaces/api/schemas/__init__.py index 8c878fe8..af667c9b 100644 --- a/django/interfaces/api/schemas/__init__.py +++ b/django/interfaces/api/schemas/__init__.py @@ -1,8 +1,8 @@ from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, CollectionQueryParameters, - ExtentQueryParameters, PaginationMeta, PaginatedResponse, CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema) -from .bbox import BoundingBox, BoundingBoxQuery, parse_bbox +from .extent import (BoundingBox, BoundingBoxQuery, DatetimeQuery, ExtentQueryParameters, TimeInterval, + parse_bbox, parse_datetime) from interfaces.api.schemas.iam.user import UserContactResponse from interfaces.api.schemas.iam.role import (RoleResponse, RoleQueryParameters, RoleItemQueryParameters, RoleSortByFields) diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index ce5312bc..ffea6261 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -14,7 +14,6 @@ ) from core.types import Unset -from interfaces.api.schemas.bbox import BoundingBoxQuery base_alias_generator = AliasGenerator( serialization_alias=lambda field_name: to_camel(field_name), @@ -125,15 +124,6 @@ class CollectionQueryParameters(BaseQueryParameters): ) -class ExtentQueryParameters(Schema): - """ - Query parameters that filter items by their spatial and temporal extent, shared by every - OGC API collection items endpoint. - """ - - bbox: BoundingBoxQuery = Query(None) - - class BaseGetResponse(Schema): model_config = ConfigDict( populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel diff --git a/django/interfaces/api/schemas/bbox.py b/django/interfaces/api/schemas/extent.py similarity index 53% rename from django/interfaces/api/schemas/bbox.py rename to django/interfaces/api/schemas/extent.py index 474a4b06..5f434250 100644 --- a/django/interfaces/api/schemas/bbox.py +++ b/django/interfaces/api/schemas/extent.py @@ -1,7 +1,10 @@ import math +import re from dataclasses import dataclass +from datetime import datetime, timezone from typing import Optional, Any, Annotated +from ninja import Query, Schema from pydantic import BeforeValidator, WithJsonSchema @@ -98,3 +101,100 @@ def parse_bbox(value: Any) -> Optional[BoundingBox]: BeforeValidator(parse_bbox), WithJsonSchema(BBOX_JSON_SCHEMA), ] + + +# RFC 3339 section 5.6 date-time: a full date, "T", a time and a required UTC offset. +RFC3339_DATE_TIME = re.compile( + r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})" +) + + +@dataclass(frozen=True) +class TimeInterval: + """ + An OGC API datetime parameter value, per OGC API - Features - Part 1: Core. An instant has + equal start and end; an interval may leave one end open (None). + """ + + start: Optional[datetime] + end: Optional[datetime] + + +def parse_rfc3339(value: str) -> datetime: + """Parses an RFC 3339 date-time, which requires a UTC offset, into an aware UTC datetime.""" + + if not RFC3339_DATE_TIME.fullmatch(value): + raise ValueError( + f"Invalid date-time '{value}'. Use RFC 3339 with a UTC offset, e.g. 2024-01-01T00:00:00Z" + ) + + try: + parsed = datetime.fromisoformat(value.upper()) + except ValueError: + raise ValueError(f"Invalid date-time '{value}'") + + return parsed.astimezone(timezone.utc) + + +def parse_datetime(value: Any) -> Optional[TimeInterval]: + """ + Parses an OGC API datetime query parameter value into a TimeInterval. + + Accepts an instant (date-time), a bounded interval (start/end) or a half-bounded interval + whose open end is ".." or empty (../end, /end, start/.., start/), per Core Req 26. + """ + + if value is None or isinstance(value, TimeInterval): + return value + + if not isinstance(value, str): + raise ValueError("Datetime must be a string") + + value = value.strip() + + if value.lower() == "null": + return None + + if "/" not in value: + instant = parse_rfc3339(value) + return TimeInterval(start=instant, end=instant) + + parts = value.split("/") + if len(parts) != 2: + raise ValueError("Datetime interval must have exactly one '/' separating its start and end") + + start, end = (None if part in ("", "..") else parse_rfc3339(part) for part in parts) + + if start is None and end is None: + raise ValueError("Datetime interval must have at least one bounded end") + if start is not None and end is not None and start > end: + raise ValueError("Datetime interval start must be earlier than or equal to its end") + + return TimeInterval(start=start, end=end) + + +DATETIME_JSON_SCHEMA = { + "description": "Date-time or interval in RFC 3339 with a UTC offset: an instant " + "(2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with " + "'..' or an empty value for the open end (../end, start/..). Items without a time match any " + "datetime.", + "type": "string", + "style": "form", + "explode": False, +} + +DatetimeQuery = Annotated[ + Optional[TimeInterval], + BeforeValidator(parse_datetime), + WithJsonSchema(DATETIME_JSON_SCHEMA), +] + + +class ExtentQueryParameters(Schema): + """ + Query parameters that filter items by their spatial and temporal extent, shared by every + OGC API collection items endpoint. + """ + + bbox: BoundingBoxQuery = Query(None) + datetime: DatetimeQuery = Query(None) diff --git a/django/interfaces/api/schemas/sta/monitoring_site.py b/django/interfaces/api/schemas/sta/monitoring_site.py index a3bdec92..bea79f3f 100644 --- a/django/interfaces/api/schemas/sta/monitoring_site.py +++ b/django/interfaces/api/schemas/sta/monitoring_site.py @@ -15,6 +15,7 @@ BaseQueryParameters, CollectionQueryParameters, ExtentQueryParameters, + BoundingBoxQuery, WorkspaceResponse, split_comma_separated, comma_array_schema, @@ -152,10 +153,11 @@ class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryP ) -class MonitoringSiteMarkerQueryParameters(BaseQueryParameters, ExtentQueryParameters): +class MonitoringSiteMarkerQueryParameters(BaseQueryParameters): workspace_id: list[uuid.UUID] = Query( [], description="Filter markers by workspace ID." ) + bbox: BoundingBoxQuery = Query(None) type: list[str] = Query([], description="Filter markers by monitoring site type.") diff --git a/django/interfaces/api/service.py b/django/interfaces/api/service.py index e9c79bbd..ff2ad775 100644 --- a/django/interfaces/api/service.py +++ b/django/interfaces/api/service.py @@ -14,7 +14,7 @@ from core.iam.permissions.anonymous import AnonymousPrincipal from interfaces.api.http.errors import BadRequestError, NotFoundError from interfaces.api.schemas.base import PaginationMeta -from interfaces.api.schemas.bbox import BoundingBox +from interfaces.api.schemas.extent import BoundingBox, TimeInterval User = get_user_model() @@ -124,6 +124,63 @@ def apply_bbox( return queryset.filter(cls.bbox_q(bbox, latitude_field, longitude_field)) + @staticmethod + def datetime_instant_q(interval: TimeInterval, field: str) -> Q: + """ + Builds a Q object matching items whose instant lies inside a datetime interval, bounds + included. Items without an instant match any interval (OGC API - Features Core Req 26C). + """ + + q = Q() + if interval.start is not None: + q &= Q(**{f"{field}__gte": interval.start}) + if interval.end is not None: + q &= Q(**{f"{field}__lte": interval.end}) + + return q | Q(**{f"{field}__isnull": True}) + + @staticmethod + def datetime_interval_q(interval: TimeInterval, start_field: str, end_field: str) -> Q: + """ + Builds a Q object matching items whose [start, end] interval intersects a datetime + interval, bounds included (OGC API - Features Core Req 26). A missing start or end on + an item is treated as open, so items without an interval match any datetime (Req 26C). + """ + + q = Q() + if interval.end is not None: + q &= Q(**{f"{start_field}__lte": interval.end}) | Q(**{f"{start_field}__isnull": True}) + if interval.start is not None: + q &= Q(**{f"{end_field}__gte": interval.start}) | Q(**{f"{end_field}__isnull": True}) + + return q + + @classmethod + def apply_datetime_instant( + cls, queryset: QuerySet, interval: Optional[TimeInterval], field: str + ) -> QuerySet: + """Filters a queryset to items whose instant lies inside a datetime interval.""" + + if interval is None: + return queryset + + return queryset.filter(cls.datetime_instant_q(interval, field)) + + @classmethod + def apply_datetime_interval( + cls, + queryset: QuerySet, + interval: Optional[TimeInterval], + start_field: str, + end_field: str, + ) -> QuerySet: + """Filters a queryset to items whose interval intersects a datetime interval.""" + + if interval is None: + return queryset + + return queryset.filter(cls.datetime_interval_q(interval, start_field, end_field)) + @staticmethod def apply_sorting( queryset: QuerySet, diff --git a/django/tests/interfaces/api/test_bbox.py b/django/tests/interfaces/api/test_bbox.py deleted file mode 100644 index cef243d5..00000000 --- a/django/tests/interfaces/api/test_bbox.py +++ /dev/null @@ -1,226 +0,0 @@ -import pytest -from ninja import Query -from ninja.testing import TestClient -from pydantic import ValidationError - -from core.sta.models import Datastream, MonitoringSite -from interfaces.api.http import handlers -from interfaces.api.http.api import HydroServerNinjaAPI -from interfaces.api.schemas import BaseQueryParameters, BoundingBox, BoundingBoxQuery, parse_bbox -from interfaces.api.service import APIService -from interfaces.api.urls import api -from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory - - -class _BBoxQueryParameters(BaseQueryParameters): - bbox: BoundingBoxQuery = Query(None, description="Filter by bounding box.") - - -_bbox_api = HydroServerNinjaAPI(urls_namespace="bbox-test") -handlers.register(_bbox_api) - - -@_bbox_api.get("/items") -def _list_items(request, query: Query[_BBoxQueryParameters]): - bbox = query.bbox - return None if bbox is None else [bbox.west, bbox.south, bbox.east, bbox.north] - - -_bbox_client = TestClient(_bbox_api) - - -# --- parse_bbox --------------------------------------------------------------------------- - - -def test_parse_bbox_parses_four_numbers(): - assert parse_bbox("-112.5,40,-111,41.25") == BoundingBox(west=-112.5, south=40, east=-111, north=41.25) - - -def test_parse_bbox_parses_six_numbers_and_drops_the_height_range(): - assert parse_bbox("-112,40,-10,-111,41,2500") == BoundingBox(west=-112, south=40, east=-111, north=41) - - -def test_parse_bbox_tolerates_whitespace_around_values(): - assert parse_bbox(" -112 , 40 , -111 , 41 ") == BoundingBox(west=-112, south=40, east=-111, north=41) - - -def test_parse_bbox_accepts_a_sequence_of_values(): - assert parse_bbox([-112, 40, -111, 41]) == BoundingBox(west=-112, south=40, east=-111, north=41) - - -def test_parse_bbox_accepts_a_box_that_crosses_the_antimeridian(): - bbox = parse_bbox("170,-20,-170,-10") - - assert bbox == BoundingBox(west=170, south=-20, east=-170, north=-10) - assert bbox.crosses_antimeridian - - -@pytest.mark.parametrize("value", [None, "null", "NULL"]) -def test_parse_bbox_returns_none_for_missing_values(value): - assert parse_bbox(value) is None - - -def test_parse_bbox_passes_through_an_existing_bounding_box(): - bbox = BoundingBox(west=-112, south=40, east=-111, north=41) - - assert parse_bbox(bbox) is bbox - - -@pytest.mark.parametrize( - "value, message", - [ - ("-112,40,-111,north", "only numeric values"), - ("", "only numeric values"), - ("-112,40,-111,nan", "finite"), - ("-112,40,-111,inf", "finite"), - ("-112,40,-111", "4 or 6"), - ("-112,40,0,-111,41", "4 or 6"), - ("-112,40,0,-111,41,1,2", "4 or 6"), - ("-181,40,-111,41", "longitudes"), - ("-112,40,181,41", "longitudes"), - ("-112,-91,-111,41", "latitudes"), - ("-112,40,-111,91", "latitudes"), - ("-112,41,-111,40", "minimum latitude"), - ("-112,40,100,-111,41,0", "minimum height"), - ], -) -def test_parse_bbox_rejects_invalid_values(value, message): - with pytest.raises(ValueError, match=message): - parse_bbox(value) - - -# --- BoundingBox.contains ----------------------------------------------------------------- - - -@pytest.mark.parametrize( - "longitude, latitude, expected", - [ - (-111.5, 40.5, True), - (-112, 40, True), - (-111, 41, True), - (-110.9, 40.5, False), - (-111.5, 41.1, False), - ], -) -def test_contains_includes_boundaries(longitude, latitude, expected): - bbox = BoundingBox(west=-112, south=40, east=-111, north=41) - - assert bbox.contains(longitude, latitude) is expected - - -@pytest.mark.parametrize( - "longitude, expected", - [(175, True), (180, True), (-180, True), (-175, True), (0, False), (165, False), (-165, False)], -) -def test_contains_handles_boxes_that_cross_the_antimeridian(longitude, expected): - bbox = BoundingBox(west=170, south=-20, east=-170, north=-10) - - assert bbox.contains(longitude, -15) is expected - - -# --- BoundingBoxQuery --------------------------------------------------------------------- - - -def test_bounding_box_query_parses_the_query_parameter(): - response = _bbox_client.get("/items", query_params={"bbox": "-112,40,-111,41"}) - - assert response.status_code == 200 - assert response.json() == [-112, 40, -111, 41] - - -def test_bounding_box_query_is_optional(): - response = _bbox_client.get("/items") - - assert response.status_code == 200 - assert response.json() is None - - -@pytest.mark.parametrize("value", ["-112,40,-111", "-112,41,-111,40", "not,a,valid,bbox"]) -def test_bounding_box_query_returns_400_for_invalid_values(value): - response = _bbox_client.get("/items", query_params={"bbox": value}) - - assert response.status_code == 400 - - -def test_bounding_box_query_is_declared_per_ogc_core_requirement_23(): - parameters = _bbox_api.get_openapi_schema(path_prefix="")["paths"]["/items"]["get"]["parameters"] - bbox = next(parameter for parameter in parameters if parameter["name"] == "bbox") - - assert bbox["style"] == "form" - assert bbox["explode"] is False - assert bbox["schema"]["type"] == "array" - assert bbox["schema"]["items"] == {"type": "number"} - assert bbox["schema"]["oneOf"] == [{"minItems": 4, "maxItems": 4}, {"minItems": 6, "maxItems": 6}] - - -def test_bounding_box_query_rejects_invalid_values_during_model_validation(): - with pytest.raises(ValidationError): - _BBoxQueryParameters.model_validate({"bbox": "-112,40,-111"}) - - -def test_bbox_is_declared_on_every_collection_items_list_and_no_nested_list(): - paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] - - def declares_bbox(path): - return any(p["name"] == "bbox" for p in paths[path]["get"].get("parameters", [])) - - items_lists = [path for path in paths if path.endswith("/items") and "get" in paths[path]] - nested_lists = [ - path - for path in paths - if "/items/{" in path and not path.rsplit("/", 1)[-1].startswith("{") and "get" in paths[path] - and not path.endswith("/csv") - ] - - assert len(items_lists) == 27 - assert [path for path in items_lists if not declares_bbox(path)] == [] - assert nested_lists and [path for path in nested_lists if declares_bbox(path)] == [] - - -# --- APIService.apply_bbox ---------------------------------------------------------------- - - -@pytest.mark.django_db -def test_apply_bbox_returns_the_queryset_unfiltered_without_a_box(): - MonitoringSiteFactory.create_batch(2) - - assert APIService.apply_bbox(MonitoringSite.objects.all(), None).count() == 2 - - -@pytest.mark.django_db -def test_apply_bbox_matches_points_inside_the_box_including_boundaries(): - inside = MonitoringSiteFactory(longitude=-111.5, latitude=40.5) - on_corner = MonitoringSiteFactory(longitude=-112, latitude=40) - MonitoringSiteFactory(longitude=-110, latitude=40.5) - MonitoringSiteFactory(longitude=-111.5, latitude=42) - - bbox = BoundingBox(west=-112, south=40, east=-111, north=41) - matched = set(APIService.apply_bbox(MonitoringSite.objects.all(), bbox).values_list("id", flat=True)) - - assert matched == {inside.id, on_corner.id} - - -@pytest.mark.django_db -def test_apply_bbox_matches_both_sides_of_the_antimeridian(): - east_of_west_edge = MonitoringSiteFactory(longitude=175, latitude=-15) - west_of_east_edge = MonitoringSiteFactory(longitude=-175, latitude=-15) - MonitoringSiteFactory(longitude=0, latitude=-15) - MonitoringSiteFactory(longitude=175, latitude=-30) - - bbox = BoundingBox(west=170, south=-20, east=-170, north=-10) - matched = set(APIService.apply_bbox(MonitoringSite.objects.all(), bbox).values_list("id", flat=True)) - - assert matched == {east_of_west_edge.id, west_of_east_edge.id} - - -@pytest.mark.django_db -def test_apply_bbox_supports_related_field_paths(): - inside = DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=-111.5, latitude=40.5)) - DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=-100, latitude=40.5)) - - bbox = BoundingBox(west=-112, south=40, east=-111, north=41) - queryset = APIService.apply_bbox( - Datastream.objects.all(), bbox, "monitoring_site__latitude", "monitoring_site__longitude" - ) - - assert list(queryset.values_list("id", flat=True)) == [inside.id] diff --git a/django/tests/interfaces/api/test_non_spatial_bbox.py b/django/tests/interfaces/api/test_collection_extent.py similarity index 70% rename from django/tests/interfaces/api/test_non_spatial_bbox.py rename to django/tests/interfaces/api/test_collection_extent.py index 17d747bd..3b5ca9ac 100644 --- a/django/tests/interfaces/api/test_non_spatial_bbox.py +++ b/django/tests/interfaces/api/test_collection_extent.py @@ -47,6 +47,10 @@ "quality-control-histories", ] +TEMPORAL_COLLECTIONS = ["observations", "datastreams", "monitoring-sites", "workspaces", "quality-control-histories"] +NON_TEMPORAL_COLLECTIONS = [c for c in NON_SPATIAL_COLLECTIONS if c not in TEMPORAL_COLLECTIONS] +ALL_COLLECTIONS = NON_SPATIAL_COLLECTIONS + ["monitoring-sites", "datastreams", "observations", "workspaces"] + ITEM_FACTORIES = { "observed-properties": lambda workspace: ObservedPropertyFactory(workspace=workspace), "units": lambda workspace: UnitFactory(workspace=workspace), @@ -101,3 +105,30 @@ def test_non_spatial_collection_returns_items_for_any_bbox(client, owner_workspa assert response.status_code == 200 assert str(item.id) in [i["id"] for i in response.json()["data"]] + + +@pytest.mark.parametrize("collection", ALL_COLLECTIONS) +@pytest.mark.parametrize("value", ["2024-01-01", "2024-01-01T00:00:00", "../..", "2024-02-01T00:00:00Z/2024-01-01T00:00:00Z"]) +def test_collection_returns_400_for_invalid_datetime(client, owner_workspace, collection, value): + response = client.get(_items_url(collection), {"datetime": value}) + + assert response.status_code == 400 + + +@pytest.mark.parametrize("collection", NON_TEMPORAL_COLLECTIONS) +def test_non_temporal_collection_ignores_a_valid_datetime(client, owner_workspace, collection): + unfiltered = client.get(_items_url(collection)) + filtered = client.get(_items_url(collection), {"datetime": "1900-01-01T00:00:00Z/1900-01-02T00:00:00Z"}) + + assert filtered.status_code == 200 + assert filtered.json() == unfiltered.json() + + +@pytest.mark.parametrize("collection", ITEM_FACTORIES) +def test_non_temporal_collection_returns_items_for_any_datetime(client, owner_workspace, collection): + item = ITEM_FACTORIES[collection](owner_workspace) + + response = client.get(_items_url(collection), {"datetime": "1900-01-01T00:00:00Z"}) + + assert response.status_code == 200 + assert str(item.id) in [i["id"] for i in response.json()["data"]] diff --git a/django/tests/interfaces/api/test_extent.py b/django/tests/interfaces/api/test_extent.py new file mode 100644 index 00000000..09473719 --- /dev/null +++ b/django/tests/interfaces/api/test_extent.py @@ -0,0 +1,425 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from ninja import Query +from ninja.testing import TestClient +from pydantic import ValidationError + +from core.sta.models import Datastream, MonitoringSite, Observation +from interfaces.api.http import handlers +from interfaces.api.http.api import HydroServerNinjaAPI +from interfaces.api.schemas import ( + BaseQueryParameters, + BoundingBox, + BoundingBoxQuery, + ExtentQueryParameters, + TimeInterval, + parse_bbox, + parse_datetime, +) +from interfaces.api.service import APIService +from interfaces.api.urls import api +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory, ObservationFactory + + +class _BBoxQueryParameters(BaseQueryParameters): + bbox: BoundingBoxQuery = Query(None, description="Filter by bounding box.") + + +_bbox_api = HydroServerNinjaAPI(urls_namespace="bbox-test") +handlers.register(_bbox_api) + + +@_bbox_api.get("/items") +def _list_items(request, query: Query[_BBoxQueryParameters]): + bbox = query.bbox + return None if bbox is None else [bbox.west, bbox.south, bbox.east, bbox.north] + + +class _ExtentQueryParameters(BaseQueryParameters, ExtentQueryParameters): + pass + + +@_bbox_api.get("/extent-items") +def _list_extent_items(request, query: Query[_ExtentQueryParameters]): + interval = query.datetime + if interval is None: + return None + return [None if t is None else t.isoformat() for t in (interval.start, interval.end)] + + +_bbox_client = TestClient(_bbox_api) + + +# --- parse_bbox --------------------------------------------------------------------------- + + +def test_parse_bbox_parses_four_numbers(): + assert parse_bbox("-112.5,40,-111,41.25") == BoundingBox(west=-112.5, south=40, east=-111, north=41.25) + + +def test_parse_bbox_parses_six_numbers_and_drops_the_height_range(): + assert parse_bbox("-112,40,-10,-111,41,2500") == BoundingBox(west=-112, south=40, east=-111, north=41) + + +def test_parse_bbox_tolerates_whitespace_around_values(): + assert parse_bbox(" -112 , 40 , -111 , 41 ") == BoundingBox(west=-112, south=40, east=-111, north=41) + + +def test_parse_bbox_accepts_a_sequence_of_values(): + assert parse_bbox([-112, 40, -111, 41]) == BoundingBox(west=-112, south=40, east=-111, north=41) + + +def test_parse_bbox_accepts_a_box_that_crosses_the_antimeridian(): + bbox = parse_bbox("170,-20,-170,-10") + + assert bbox == BoundingBox(west=170, south=-20, east=-170, north=-10) + assert bbox.crosses_antimeridian + + +@pytest.mark.parametrize("value", [None, "null", "NULL"]) +def test_parse_bbox_returns_none_for_missing_values(value): + assert parse_bbox(value) is None + + +def test_parse_bbox_passes_through_an_existing_bounding_box(): + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + + assert parse_bbox(bbox) is bbox + + +@pytest.mark.parametrize( + "value, message", + [ + ("-112,40,-111,north", "only numeric values"), + ("", "only numeric values"), + ("-112,40,-111,nan", "finite"), + ("-112,40,-111,inf", "finite"), + ("-112,40,-111", "4 or 6"), + ("-112,40,0,-111,41", "4 or 6"), + ("-112,40,0,-111,41,1,2", "4 or 6"), + ("-181,40,-111,41", "longitudes"), + ("-112,40,181,41", "longitudes"), + ("-112,-91,-111,41", "latitudes"), + ("-112,40,-111,91", "latitudes"), + ("-112,41,-111,40", "minimum latitude"), + ("-112,40,100,-111,41,0", "minimum height"), + ], +) +def test_parse_bbox_rejects_invalid_values(value, message): + with pytest.raises(ValueError, match=message): + parse_bbox(value) + + +# --- BoundingBox.contains ----------------------------------------------------------------- + + +@pytest.mark.parametrize( + "longitude, latitude, expected", + [ + (-111.5, 40.5, True), + (-112, 40, True), + (-111, 41, True), + (-110.9, 40.5, False), + (-111.5, 41.1, False), + ], +) +def test_contains_includes_boundaries(longitude, latitude, expected): + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + + assert bbox.contains(longitude, latitude) is expected + + +@pytest.mark.parametrize( + "longitude, expected", + [(175, True), (180, True), (-180, True), (-175, True), (0, False), (165, False), (-165, False)], +) +def test_contains_handles_boxes_that_cross_the_antimeridian(longitude, expected): + bbox = BoundingBox(west=170, south=-20, east=-170, north=-10) + + assert bbox.contains(longitude, -15) is expected + + +# --- BoundingBoxQuery --------------------------------------------------------------------- + + +def test_bounding_box_query_parses_the_query_parameter(): + response = _bbox_client.get("/items", query_params={"bbox": "-112,40,-111,41"}) + + assert response.status_code == 200 + assert response.json() == [-112, 40, -111, 41] + + +def test_bounding_box_query_is_optional(): + response = _bbox_client.get("/items") + + assert response.status_code == 200 + assert response.json() is None + + +@pytest.mark.parametrize("value", ["-112,40,-111", "-112,41,-111,40", "not,a,valid,bbox"]) +def test_bounding_box_query_returns_400_for_invalid_values(value): + response = _bbox_client.get("/items", query_params={"bbox": value}) + + assert response.status_code == 400 + + +def test_bounding_box_query_is_declared_per_ogc_core_requirement_23(): + parameters = _bbox_api.get_openapi_schema(path_prefix="")["paths"]["/items"]["get"]["parameters"] + bbox = next(parameter for parameter in parameters if parameter["name"] == "bbox") + + assert bbox["style"] == "form" + assert bbox["explode"] is False + assert bbox["schema"]["type"] == "array" + assert bbox["schema"]["items"] == {"type": "number"} + assert bbox["schema"]["oneOf"] == [{"minItems": 4, "maxItems": 4}, {"minItems": 6, "maxItems": 6}] + + +def test_bounding_box_query_rejects_invalid_values_during_model_validation(): + with pytest.raises(ValidationError): + _BBoxQueryParameters.model_validate({"bbox": "-112,40,-111"}) + + +def test_extent_parameters_are_declared_on_every_collection_items_list_and_no_nested_list(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + + def declares(path, name): + return any(p["name"] == name for p in paths[path]["get"].get("parameters", [])) + + items_lists = [path for path in paths if path.endswith("/items") and "get" in paths[path]] + nested_lists = [ + path + for path in paths + if "/items/{" in path and not path.rsplit("/", 1)[-1].startswith("{") and "get" in paths[path] + and not path.endswith("/csv") + ] + markers = "/api/ogc/collections/monitoring-sites/markers" + + assert len(items_lists) == 27 + for name in ("bbox", "datetime"): + assert [path for path in items_lists if not declares(path, name)] == [] + assert nested_lists and [path for path in nested_lists if declares(path, name)] == [] + assert declares(markers, "bbox") and not declares(markers, "datetime") + + +# --- APIService.apply_bbox ---------------------------------------------------------------- + + +@pytest.mark.django_db +def test_apply_bbox_returns_the_queryset_unfiltered_without_a_box(): + MonitoringSiteFactory.create_batch(2) + + assert APIService.apply_bbox(MonitoringSite.objects.all(), None).count() == 2 + + +@pytest.mark.django_db +def test_apply_bbox_matches_points_inside_the_box_including_boundaries(): + inside = MonitoringSiteFactory(longitude=-111.5, latitude=40.5) + on_corner = MonitoringSiteFactory(longitude=-112, latitude=40) + MonitoringSiteFactory(longitude=-110, latitude=40.5) + MonitoringSiteFactory(longitude=-111.5, latitude=42) + + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + matched = set(APIService.apply_bbox(MonitoringSite.objects.all(), bbox).values_list("id", flat=True)) + + assert matched == {inside.id, on_corner.id} + + +@pytest.mark.django_db +def test_apply_bbox_matches_both_sides_of_the_antimeridian(): + east_of_west_edge = MonitoringSiteFactory(longitude=175, latitude=-15) + west_of_east_edge = MonitoringSiteFactory(longitude=-175, latitude=-15) + MonitoringSiteFactory(longitude=0, latitude=-15) + MonitoringSiteFactory(longitude=175, latitude=-30) + + bbox = BoundingBox(west=170, south=-20, east=-170, north=-10) + matched = set(APIService.apply_bbox(MonitoringSite.objects.all(), bbox).values_list("id", flat=True)) + + assert matched == {east_of_west_edge.id, west_of_east_edge.id} + + +@pytest.mark.django_db +def test_apply_bbox_supports_related_field_paths(): + inside = DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=-111.5, latitude=40.5)) + DatastreamFactory(monitoring_site=MonitoringSiteFactory(longitude=-100, latitude=40.5)) + + bbox = BoundingBox(west=-112, south=40, east=-111, north=41) + queryset = APIService.apply_bbox( + Datastream.objects.all(), bbox, "monitoring_site__latitude", "monitoring_site__longitude" + ) + + assert list(queryset.values_list("id", flat=True)) == [inside.id] + + +# --- parse_datetime ----------------------------------------------------------------------- + +JAN_1 = datetime(2024, 1, 1, tzinfo=timezone.utc) +FEB_1 = datetime(2024, 2, 1, tzinfo=timezone.utc) + + +def test_parse_datetime_parses_an_instant(): + assert parse_datetime("2024-01-01T00:00:00Z") == TimeInterval(start=JAN_1, end=JAN_1) + + +def test_parse_datetime_converts_offsets_to_utc(): + interval = parse_datetime("2024-01-01T02:00:00+02:00") + + assert interval.start == JAN_1 + assert interval.start.tzinfo == timezone.utc + + +@pytest.mark.parametrize("value", ["2024-01-01t00:00:00z", "2024-01-01T00:00:00.000Z"]) +def test_parse_datetime_accepts_lowercase_separators_and_fractional_seconds(value): + assert parse_datetime(value) == TimeInterval(start=JAN_1, end=JAN_1) + + +def test_parse_datetime_parses_a_bounded_interval(): + assert parse_datetime("2024-01-01T00:00:00Z/2024-02-01T00:00:00Z") == TimeInterval(start=JAN_1, end=FEB_1) + + +@pytest.mark.parametrize("value", ["../2024-02-01T00:00:00Z", "/2024-02-01T00:00:00Z"]) +def test_parse_datetime_parses_an_open_start(value): + assert parse_datetime(value) == TimeInterval(start=None, end=FEB_1) + + +@pytest.mark.parametrize("value", ["2024-01-01T00:00:00Z/..", "2024-01-01T00:00:00Z/"]) +def test_parse_datetime_parses_an_open_end(value): + assert parse_datetime(value) == TimeInterval(start=JAN_1, end=None) + + +@pytest.mark.parametrize("value", [None, "null"]) +def test_parse_datetime_returns_none_for_missing_values(value): + assert parse_datetime(value) is None + + +@pytest.mark.parametrize( + "value, message", + [ + ("2024-01-01", "RFC 3339"), + ("2024-01-01T00:00:00", "RFC 3339"), + ("2024-01-01 00:00:00Z", "RFC 3339"), + ("2024-01-01T00:00:00 00:00", "RFC 3339"), + ("2024-13-01T00:00:00Z", "Invalid date-time"), + ("yesterday", "RFC 3339"), + ("../..", "at least one bounded end"), + ("/", "at least one bounded end"), + ("2024-01-01T00:00:00Z/2024-02-01T00:00:00Z/..", "exactly one '/'"), + ("2024-02-01T00:00:00Z/2024-01-01T00:00:00Z", "earlier than or equal"), + ], +) +def test_parse_datetime_rejects_invalid_values(value, message): + with pytest.raises(ValueError, match=message): + parse_datetime(value) + + +# --- DatetimeQuery ------------------------------------------------------------------------ + + +def test_datetime_query_parses_the_query_parameter(): + response = _bbox_client.get("/extent-items", query_params={"datetime": "../2024-02-01T00:00:00Z"}) + + assert response.status_code == 200 + assert response.json() == [None, "2024-02-01T00:00:00+00:00"] + + +@pytest.mark.parametrize("value", ["2024-01-01", "../..", "2024-02-01T00:00:00Z/2024-01-01T00:00:00Z"]) +def test_datetime_query_returns_400_for_invalid_values(value): + response = _bbox_client.get("/extent-items", query_params={"datetime": value}) + + assert response.status_code == 400 + + +def test_datetime_query_is_declared_per_ogc_core_requirement_25(): + parameters = _bbox_api.get_openapi_schema(path_prefix="")["paths"]["/extent-items"]["get"]["parameters"] + parameter = next(parameter for parameter in parameters if parameter["name"] == "datetime") + + assert parameter["style"] == "form" + assert parameter["explode"] is False + assert parameter["schema"]["type"] == "string" + + +# --- APIService datetime filters ---------------------------------------------------------- + + +def _ids(queryset): + return set(queryset.values_list("id", flat=True)) + + +@pytest.mark.django_db +def test_apply_datetime_instant_matches_instants_inside_the_interval_including_bounds(): + datastream = DatastreamFactory() + on_start = ObservationFactory(datastream=datastream, phenomenon_time=JAN_1) + inside = ObservationFactory(datastream=datastream, phenomenon_time=JAN_1 + timedelta(days=5)) + on_end = ObservationFactory(datastream=datastream, phenomenon_time=FEB_1) + ObservationFactory(datastream=datastream, phenomenon_time=FEB_1 + timedelta(seconds=1)) + + interval = TimeInterval(start=JAN_1, end=FEB_1) + matched = _ids(APIService.apply_datetime_instant(Observation.objects.all(), interval, "phenomenon_time")) + + assert matched == {on_start.id, inside.id, on_end.id} + + +@pytest.mark.django_db +def test_apply_datetime_instant_supports_open_ends_and_instants(): + datastream = DatastreamFactory() + early = ObservationFactory(datastream=datastream, phenomenon_time=JAN_1) + late = ObservationFactory(datastream=datastream, phenomenon_time=FEB_1) + + def matched(interval): + return _ids(APIService.apply_datetime_instant(Observation.objects.all(), interval, "phenomenon_time")) + + assert matched(TimeInterval(start=None, end=JAN_1)) == {early.id} + assert matched(TimeInterval(start=FEB_1, end=None)) == {late.id} + assert matched(TimeInterval(start=FEB_1, end=FEB_1)) == {late.id} + assert matched(None) == {early.id, late.id} + + +@pytest.mark.django_db +def test_apply_datetime_interval_matches_overlapping_intervals_including_bounds(): + before = DatastreamFactory(phenomenon_begin_time=JAN_1 - timedelta(days=10), phenomenon_end_time=JAN_1 - timedelta(days=1)) + touches_start = DatastreamFactory(phenomenon_begin_time=JAN_1 - timedelta(days=10), phenomenon_end_time=JAN_1) + inside = DatastreamFactory(phenomenon_begin_time=JAN_1 + timedelta(days=1), phenomenon_end_time=JAN_1 + timedelta(days=2)) + spans = DatastreamFactory(phenomenon_begin_time=JAN_1 - timedelta(days=10), phenomenon_end_time=FEB_1 + timedelta(days=10)) + touches_end = DatastreamFactory(phenomenon_begin_time=FEB_1, phenomenon_end_time=FEB_1 + timedelta(days=10)) + after = DatastreamFactory(phenomenon_begin_time=FEB_1 + timedelta(days=1), phenomenon_end_time=FEB_1 + timedelta(days=10)) + + interval = TimeInterval(start=JAN_1, end=FEB_1) + matched = _ids( + APIService.apply_datetime_interval( + Datastream.objects.all(), interval, "phenomenon_begin_time", "phenomenon_end_time" + ) + ) + + assert matched == {touches_start.id, inside.id, spans.id, touches_end.id} + assert not matched & {before.id, after.id} + + +@pytest.mark.django_db +def test_apply_datetime_interval_matches_items_without_an_interval(): + no_data = DatastreamFactory(phenomenon_begin_time=None, phenomenon_end_time=None) + DatastreamFactory(phenomenon_begin_time=FEB_1 + timedelta(days=1), phenomenon_end_time=FEB_1 + timedelta(days=10)) + + interval = TimeInterval(start=JAN_1, end=JAN_1) + matched = _ids( + APIService.apply_datetime_interval( + Datastream.objects.all(), interval, "phenomenon_begin_time", "phenomenon_end_time" + ) + ) + + assert matched == {no_data.id} + + +@pytest.mark.django_db +def test_apply_datetime_interval_supports_open_ended_requests(): + early = DatastreamFactory(phenomenon_begin_time=JAN_1 - timedelta(days=10), phenomenon_end_time=JAN_1 - timedelta(days=1)) + late = DatastreamFactory(phenomenon_begin_time=FEB_1, phenomenon_end_time=FEB_1 + timedelta(days=10)) + + def matched(interval): + return _ids( + APIService.apply_datetime_interval( + Datastream.objects.all(), interval, "phenomenon_begin_time", "phenomenon_end_time" + ) + ) + + assert matched(TimeInterval(start=JAN_1, end=None)) == {late.id} + assert matched(TimeInterval(start=None, end=JAN_1)) == {early.id} diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 44a37853..a268db90 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2350,6 +2350,8 @@ export interface components { DataConnectionQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -2514,6 +2516,8 @@ export interface components { DataProductTaskQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -2717,6 +2721,8 @@ export interface components { DataProductTransformationQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -3004,6 +3010,8 @@ export interface components { DatastreamQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -3357,6 +3365,8 @@ export interface components { EtlMappingQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Etl Task Id * @description Filter mappings by ETL task ID. @@ -3491,6 +3501,8 @@ export interface components { * @default [] */ data_connection_id: string[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -4015,6 +4027,8 @@ export interface components { * @default [] */ datastream_id: (string | "null")[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -4201,6 +4215,8 @@ export interface components { * @default [] */ datastream_id: string[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -4476,6 +4492,8 @@ export interface components { * @default [] */ country: string[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -4719,6 +4737,8 @@ export interface components { * @default [] */ datastream_id: string[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -4941,6 +4961,8 @@ export interface components { * @default [] */ datastream_id: string[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Format * @description Controls the format of the observations response. @@ -5090,6 +5112,8 @@ export interface components { * @default [] */ datastream_id: (string | "null")[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -5642,6 +5666,8 @@ export interface components { * @default [] */ datastream_id: (string | "null")[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -5736,6 +5762,8 @@ export interface components { QualityControlHistoryQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6082,6 +6110,8 @@ export interface components { RatingCurveQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6196,6 +6226,8 @@ export interface components { ResultQualifierQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6265,6 +6297,8 @@ export interface components { RoleQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6668,6 +6702,8 @@ export interface components { * @default [] */ datastream_id: (string | "null")[]; + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6905,6 +6941,8 @@ export interface components { VocabularyQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -6972,6 +7010,8 @@ export interface components { WorkspaceQueryParameters: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** * Include * @description Comma-separated list of related resources to include in the response. @@ -7056,6 +7096,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -7283,6 +7325,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "monitoringSiteId" | "name" | "description" | "fittingMethod" | "points")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -7552,6 +7596,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "monitoringSiteId" | "schedule" | "latestRun" | "transformationTypes" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -8000,6 +8046,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "transformationType" | "outputDatastreamId" | "inputDatastreams" | "ratingCurveId" | "formula" | "aggregationMethod" | "outputIntervalUnits" | "outputInterval" | "timezoneType" | "timezone" | "minValues" | "stopOnNoData" | "stopOnError")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -8291,6 +8339,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -8518,6 +8568,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -9150,6 +9202,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description" | "sourceUrl" | "authHeaderName" | "authHeaderValue" | "timezoneType" | "timezone" | "workspaceId" | "payload" | "placeholderVariables" | "notification" | "taskCount" | "taskAttentionCount")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -9410,6 +9464,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "sourceIdentifier" | "targetDatastreamId")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -9699,6 +9755,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "dataConnectionId" | "schedule" | "latestRun" | "mappingCount" | "name" | "description" | "taskVariables")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10149,6 +10207,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10376,6 +10436,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10603,6 +10665,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "code" | "type" | "description" | "definition" | "sensorModel" | "sensorModelManufacturer" | "sensorModelDefinition")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -10871,6 +10935,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "datastreamId" | "ruleType" | "lastCheckedAt" | "minValue" | "maxValue" | "windowInterval" | "windowIntervalUnits")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -11160,6 +11226,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -11387,6 +11455,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description" | "code" | "type" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "dataDisclaimer" | "isPrivate" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -11872,10 +11942,10 @@ export interface operations { interfaces_api_views_sta_monitoring_site_get_monitoring_site_markers: { parameters: { query?: { - /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ - bbox?: number[] & (unknown | unknown); /** @description Filter markers by workspace ID. */ workspace_id?: string[]; + /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ + bbox?: number[] & (unknown | unknown); /** @description Filter markers by monitoring site type. */ type?: string[]; }; @@ -12032,6 +12102,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "monitoringSiteId" | "schedule" | "latestRun" | "ruleTypeCounts" | "recipients" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -12568,6 +12640,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record. */ properties?: ("id" | "workspaceId" | "datastreamId" | "phenomenonTime" | "result" | "resultQualifierCodes")[]; /** @description Comma-separated list of related resources to include in the response. Only applies to format=record. */ @@ -12781,6 +12855,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "definition" | "description" | "type" | "code")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -13045,6 +13121,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -13272,6 +13350,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "code" | "name" | "description" | "definition")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -13534,6 +13614,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "managedDatastreamId" | "sourceDatastreamId" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "sourceChecksum" | "managedChecksum")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14389,6 +14471,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14620,6 +14704,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "description" | "permissions")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14707,6 +14793,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -14934,6 +15022,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "name" | "description")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -15161,6 +15251,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "workspaceId" | "name" | "symbol" | "definition" | "type")[]; /** @description Comma-separated list of related resources to include in the response. */ @@ -15416,6 +15508,8 @@ export interface operations { query?: { /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); + /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ + datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: ("id" | "ownerEmail" | "pendingTransferToEmail" | "collaboratorRoleId" | "name" | "isPrivate")[]; /** @description Comma-separated list of related resources to include in the response. */ From e917fbf7f8d126ad5c3135fadfc45d31e5285311 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 15:29:57 -0700 Subject: [PATCH 08/25] Hook up new datetime param to datastreams, observations, and QC histories --- .../utils/__tests__/observationsUtils.spec.ts | 3 +- .../src/utils/observationsUtils.ts | 3 +- .../how-to/using-the-typescript-client.md | 6 +- apps/qc-app/docs/API_REFERENCE.md | 6 +- apps/qc-app/e2e/support/mocks.ts | 31 +- apps/qc-app/src/utils/observations.ts | 5 +- django/contracts/openapi/ogc.openapi.json | 380 ------------------ .../interfaces/api/schemas/sta/datastream.py | 40 -- .../interfaces/api/schemas/sta/observation.py | 10 - .../api/services/quality/history.py | 6 + .../interfaces/api/services/sta/datastream.py | 13 +- .../api/services/sta/observation.py | 16 +- .../interfaces/api/views/quality/history.py | 1 + django/interfaces/api/views/sta/datastream.py | 1 + .../interfaces/api/views/sta/observation.py | 1 + .../interfaces/api/test_datastream_views.py | 43 ++ .../interfaces/api/test_observation_views.py | 47 ++- .../interfaces/api/test_qc_history_views.py | 35 ++ .../src/generated/ogc.types.d.ts | 70 ---- .../api/services/sta/datastream.py | 55 +-- .../src/hydroserverpy/api/utils.py | 29 ++ .../tests/api/test_datastream_datetime.py | 119 ++++++ 22 files changed, 352 insertions(+), 568 deletions(-) create mode 100644 packages/hydroserverpy/tests/api/test_datastream_datetime.py diff --git a/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts b/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts index 839bfa81..76d695a0 100644 --- a/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts +++ b/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts @@ -137,8 +137,7 @@ describe('fetchObservations', () => { expect(getObservationsMock).toHaveBeenCalledWith( '123', expect.objectContaining({ - phenomenon_time_min: start, - phenomenon_time_max: end, + datetime: `${start}/${end}`, }) ) }) diff --git a/apps/data-management/src/utils/observationsUtils.ts b/apps/data-management/src/utils/observationsUtils.ts index 557d2971..51a348ae 100644 --- a/apps/data-management/src/utils/observationsUtils.ts +++ b/apps/data-management/src/utils/observationsUtils.ts @@ -40,8 +40,7 @@ export const fetchObservations = async ( sortby: ['phenomenonTime'], limit: 50_000, format: 'column', - phenomenon_time_min: startTime ?? phenomenonBeginTime, - phenomenon_time_max: endTime ?? phenomenonEndTime, + datetime: `${startTime ?? phenomenonBeginTime}/${endTime ?? phenomenonEndTime}`, } if (signal) options.signal = signal diff --git a/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md b/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md index b76f1ce5..30618a3b 100644 --- a/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md +++ b/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md @@ -568,8 +568,7 @@ const observations = await hs.datastreams.getObservations( { format: "row", sortby: ["phenomenonTime"], - phenomenon_time_min: "2025-01-01T00:00:00Z", - phenomenon_time_max: "2025-12-31T23:59:59Z", + datetime: "2025-01-01T00:00:00Z/2025-12-31T23:59:59Z", page_size: 1000, } ); @@ -907,8 +906,7 @@ const sourceRes = await hs.datastreams.getObservations( { format: "record", sortby: ["phenomenonTime"], - phenomenon_time_min: rangeStart, - phenomenon_time_max: rangeEnd, + datetime: `${rangeStart}/${rangeEnd}`, } ); diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index 0372e2b7..9853d4a3 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -55,9 +55,9 @@ qc-utils service layer lives under `packages/qc-utils/src/services/`. ### Observation read -Paged GET against `/data/datastreams/{id}/observations` with -`format=column`, `sortby=phenomenonTime`, `page_size=50000`, and -`phenomenon_time_min` / `phenomenon_time_max` ISO-8601 bounds. The app +Paged GET against `/api/ogc/collections/observations/items` with +`datastream_id={id}`, `format=column`, `sortby=phenomenonTime`, +`limit=50000`, and a `datetime` interval (`start/end`, RFC 3339). The app paginates client-side and caches the merged window in `useObservationStore`. diff --git a/apps/qc-app/e2e/support/mocks.ts b/apps/qc-app/e2e/support/mocks.ts index 4911a2f8..9f1d340f 100644 --- a/apps/qc-app/e2e/support/mocks.ts +++ b/apps/qc-app/e2e/support/mocks.ts @@ -196,16 +196,14 @@ export async function installMocks( const params = new URL(url).searchParams const dsId = params.get('datastream_id') ?? '' const series = observationsById[dsId] ?? observations - // Honour the `phenomenon_time_min` / `phenomenon_time_max` - // params the client always sends. Without this, the app's - // cache-extension logic in `fetchObservationsInRange` (which - // re-fetches the segment outside its cached window every time + // Honour the `datetime` interval the client always sends. Without + // this, the app's cache-extension logic in `fetchObservationsInRange` + // (which re-fetches the segment outside its cached window every time // the range moves) would receive the full fixture series on // each call and stack duplicates into the ObservationRecord — // visible as wrong point counts and a long phantom line // connecting the first and last observations. - const tMin = parseISOorNull(params.get('phenomenon_time_min')) - const tMax = parseISOorNull(params.get('phenomenon_time_max')) + const [tMin, tMax] = parseDatetimeInterval(params.get('datetime')) const sliced = tMin == null && tMax == null ? series : sliceSeries(series, tMin, tMax) // The columnar format spreads its fields at the top level (no `data` @@ -301,15 +299,24 @@ async function safeJson(request: ReturnType | any): Promise { + if (!part || part === '..') return null + const t = Date.parse(part) + return Number.isFinite(t) ? t : null + } + if (!value.includes('/')) { + const instant = parseEnd(value) + return [instant, instant] + } + const [start, end] = value.split('/') + return [parseEnd(start), parseEnd(end)] } /** - * Mirror the real backend's `phenomenon_time_min` / `phenomenon_time_max` - * filtering. Bounds are inclusive on both ends, matching how the QC + * Mirror the real backend's `datetime` filtering. Bounds are inclusive on both ends, matching how the QC * app issues its cache-extension queries. */ function sliceSeries( diff --git a/apps/qc-app/src/utils/observations.ts b/apps/qc-app/src/utils/observations.ts index ffab7965..877a8240 100644 --- a/apps/qc-app/src/utils/observations.ts +++ b/apps/qc-app/src/utils/observations.ts @@ -16,8 +16,9 @@ export const fetchObservationsSync = async ( try { const result = await hs.value.datastreams.getObservations(id, { limit: 50_000, - phenomenon_time_min: startTime?.toISOString() ?? phenomenonBeginTime, - phenomenon_time_max: endTime?.toISOString() ?? phenomenonEndTime, + datetime: `${startTime?.toISOString() ?? phenomenonBeginTime}/${ + endTime?.toISOString() ?? phenomenonEndTime + }`, sortby: ['phenomenonTime'], format: 'column', }) diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 8dd5454a..6d0604c2 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -2819,70 +2819,6 @@ "description": "Number of items to skip.", "title": "Offset" }, - "phenomenon_begin_time_max": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum phenomenon begin time of filtered datastreams.", - "title": "Phenomenon Begin Time Max" - }, - "phenomenon_begin_time_min": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum phenomenon begin time of filtered datastreams.", - "title": "Phenomenon Begin Time Min" - }, - "phenomenon_end_time_max": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum phenomenon end time of filtered datastreams.", - "title": "Phenomenon End Time Max" - }, - "phenomenon_end_time_min": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum phenomenon end time of filtered datastreams.", - "title": "Phenomenon End Time Min" - }, "processing_level_id": { "default": [], "description": "Filter datastreams by processing level ID.", @@ -2943,70 +2879,6 @@ "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", "title": "Q" }, - "result_begin_time_max": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum result begin time of filtered datastreams.", - "title": "Result Begin Time Max" - }, - "result_begin_time_min": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum result begin time of filtered datastreams.", - "title": "Result Begin Time Min" - }, - "result_end_time_max": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum result end time of filtered datastreams.", - "title": "Result End Time Max" - }, - "result_end_time_min": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum result end time of filtered datastreams.", - "title": "Result End Time Min" - }, "result_qualifier_id": { "default": [], "description": "Filter datastreams by observation result qualifier ID.", @@ -8233,38 +8105,6 @@ "description": "Number of items to skip.", "title": "Offset" }, - "phenomenon_time_max": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum phenomenon time of filtered observations.", - "title": "Phenomenon Time Max" - }, - "phenomenon_time_min": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum phenomenon time of filtered observations.", - "title": "Phenomenon Time Min" - }, "properties": { "description": "Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record.", "items": { @@ -18568,182 +18408,6 @@ "description": "Sets the minimum value count of filtered datastreams.", "title": "Value Count Min" } - }, - { - "description": "Sets the maximum phenomenon begin time of filtered datastreams.", - "in": "query", - "name": "phenomenon_begin_time_max", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum phenomenon begin time of filtered datastreams.", - "title": "Phenomenon Begin Time Max" - } - }, - { - "description": "Sets the minimum phenomenon begin time of filtered datastreams.", - "in": "query", - "name": "phenomenon_begin_time_min", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum phenomenon begin time of filtered datastreams.", - "title": "Phenomenon Begin Time Min" - } - }, - { - "description": "Sets the maximum phenomenon end time of filtered datastreams.", - "in": "query", - "name": "phenomenon_end_time_max", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum phenomenon end time of filtered datastreams.", - "title": "Phenomenon End Time Max" - } - }, - { - "description": "Sets the minimum phenomenon end time of filtered datastreams.", - "in": "query", - "name": "phenomenon_end_time_min", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum phenomenon end time of filtered datastreams.", - "title": "Phenomenon End Time Min" - } - }, - { - "description": "Sets the maximum result begin time of filtered datastreams.", - "in": "query", - "name": "result_begin_time_max", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum result begin time of filtered datastreams.", - "title": "Result Begin Time Max" - } - }, - { - "description": "Sets the minimum result begin time of filtered datastreams.", - "in": "query", - "name": "result_begin_time_min", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum result begin time of filtered datastreams.", - "title": "Result Begin Time Min" - } - }, - { - "description": "Sets the maximum result end time of filtered datastreams.", - "in": "query", - "name": "result_end_time_max", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum result end time of filtered datastreams.", - "title": "Result End Time Max" - } - }, - { - "description": "Sets the minimum result end time of filtered datastreams.", - "in": "query", - "name": "result_end_time_min", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum result end time of filtered datastreams.", - "title": "Result End Time Min" - } } ], "responses": { @@ -28633,50 +28297,6 @@ "title": "Format" } }, - { - "description": "Sets the maximum phenomenon time of filtered observations.", - "in": "query", - "name": "phenomenon_time_max", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the maximum phenomenon time of filtered observations.", - "title": "Phenomenon Time Max" - } - }, - { - "description": "Sets the minimum phenomenon time of filtered observations.", - "in": "query", - "name": "phenomenon_time_min", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sets the minimum phenomenon time of filtered observations.", - "title": "Phenomenon Time Min" - } - }, { "description": "Filter observations by result qualifier code.", "in": "query", diff --git a/django/interfaces/api/schemas/sta/datastream.py b/django/interfaces/api/schemas/sta/datastream.py index af30f953..2ccc2d69 100644 --- a/django/interfaces/api/schemas/sta/datastream.py +++ b/django/interfaces/api/schemas/sta/datastream.py @@ -219,46 +219,6 @@ class DatastreamQueryParameters(DatastreamFilterFields, CollectionQueryParameter description="Sets the minimum value count of filtered datastreams.", alias="value_count_min", ) - phenomenon_begin_time__lte: Optional[ISODatetime] = Query( - None, - description="Sets the maximum phenomenon begin time of filtered datastreams.", - alias="phenomenon_begin_time_max", - ) - phenomenon_begin_time__gte: Optional[ISODatetime] = Query( - None, - description="Sets the minimum phenomenon begin time of filtered datastreams.", - alias="phenomenon_begin_time_min", - ) - phenomenon_end_time__lte: Optional[ISODatetime] = Query( - None, - description="Sets the maximum phenomenon end time of filtered datastreams.", - alias="phenomenon_end_time_max", - ) - phenomenon_end_time__gte: Optional[ISODatetime] = Query( - None, - description="Sets the minimum phenomenon end time of filtered datastreams.", - alias="phenomenon_end_time_min", - ) - result_begin_time__lte: Optional[ISODatetime] = Query( - None, - description="Sets the maximum result begin time of filtered datastreams.", - alias="result_begin_time_max", - ) - result_begin_time__gte: Optional[ISODatetime] = Query( - None, - description="Sets the minimum result begin time of filtered datastreams.", - alias="result_begin_time_min", - ) - result_end_time__lte: Optional[ISODatetime] = Query( - None, - description="Sets the maximum result end time of filtered datastreams.", - alias="result_end_time_max", - ) - result_end_time__gte: Optional[ISODatetime] = Query( - None, - description="Sets the minimum result end time of filtered datastreams.", - alias="result_end_time_min", - ) class DatastreamVisualizationBootstrapQueryParameters(BaseQueryParameters): diff --git a/django/interfaces/api/schemas/sta/observation.py b/django/interfaces/api/schemas/sta/observation.py index 5036638e..40a23998 100644 --- a/django/interfaces/api/schemas/sta/observation.py +++ b/django/interfaces/api/schemas/sta/observation.py @@ -101,16 +101,6 @@ class ObservationQueryParameters(ObservationFilterFields, CollectionQueryParamet description="Controls the format of the observations response.", alias="format", ) - phenomenon_time__lte: Optional[ISODatetime] = Query( - None, - description="Sets the maximum phenomenon time of filtered observations.", - alias="phenomenon_time_max", - ) - phenomenon_time__gte: Optional[ISODatetime] = Query( - None, - description="Sets the minimum phenomenon time of filtered observations.", - alias="phenomenon_time_min", - ) result_qualifier_codes: list[str] = Query( [], description="Filter observations by result qualifier code.", diff --git a/django/interfaces/api/services/quality/history.py b/django/interfaces/api/services/quality/history.py index bbe87526..9910fada 100644 --- a/django/interfaces/api/services/quality/history.py +++ b/django/interfaces/api/services/quality/history.py @@ -10,6 +10,7 @@ from processing.quality.models import QCHistory from interfaces.api.http.errors import ConflictError, PermissionDeniedError, NotFoundError from interfaces.api.service import APIService +from interfaces.api.schemas import TimeInterval from interfaces.api.schemas.quality.history import ( QualityControlHistorySortByFields, QualityControlHistoryResponse, @@ -85,6 +86,7 @@ def list( sortby: Optional[list[str]] = None, filtering: Optional[dict] = None, include: Optional[list[str]] = None, + datetime_interval: Optional[TimeInterval] = None, ): requested_includes = self.resolve_include_set(include) queryset = QCHistory.objects @@ -93,6 +95,10 @@ def list( if field in filtering: queryset = self.apply_filters(queryset, field, filtering[field]) + queryset = self.apply_datetime_interval( + queryset, datetime_interval, "phenomenon_time_start", "phenomenon_time_end" + ) + queryset = self.apply_sorting( queryset, sortby, diff --git a/django/interfaces/api/services/sta/datastream.py b/django/interfaces/api/services/sta/datastream.py index 240f68ae..3dc91b56 100644 --- a/django/interfaces/api/services/sta/datastream.py +++ b/django/interfaces/api/services/sta/datastream.py @@ -19,6 +19,7 @@ from interfaces.api.service import APIService from interfaces.api.schemas import ( BoundingBox, + TimeInterval, DatastreamPostBody, DatastreamPatchBody, LinkedResourcePostBody, @@ -119,6 +120,7 @@ def list( filtering: Optional[dict] = None, include: Optional[list[str]] = None, bbox: Optional[BoundingBox] = None, + datetime_interval: Optional[TimeInterval] = None, ): requested_includes = self.resolve_include_set(include) queryset = Datastream.objects @@ -138,14 +140,6 @@ def list( "is_private", "value_count__lte", "value_count__gte", - "phenomenon_begin_time__lte", - "phenomenon_begin_time__gte", - "phenomenon_end_time__lte", - "phenomenon_end_time__gte", - "result_begin_time__lte", - "result_begin_time__gte", - "result_end_time__lte", - "result_end_time__gte", ]: if field in filtering: if field == "is_private": @@ -164,6 +158,9 @@ def list( queryset = self.apply_filters(queryset, field, filtering[field]) queryset = self.apply_site_bbox(queryset, bbox) + queryset = self.apply_datetime_interval( + queryset, datetime_interval, "phenomenon_begin_time", "phenomenon_end_time" + ) queryset = self.apply_tag_filter(queryset, filtering.get("tag")) queryset, has_search = self.apply_search(queryset, filtering.get("q")) queryset = self.apply_sorting( diff --git a/django/interfaces/api/services/sta/observation.py b/django/interfaces/api/services/sta/observation.py index 0b2440f3..c54f85b1 100644 --- a/django/interfaces/api/services/sta/observation.py +++ b/django/interfaces/api/services/sta/observation.py @@ -17,7 +17,7 @@ from interfaces.api.service import APIService from interfaces.api.services.sta.datastream import DatastreamAPIService from interfaces.api.http.errors import BadRequestError, ConflictError, PermissionDeniedError, NotFoundError -from interfaces.api.schemas import BoundingBox +from interfaces.api.schemas import BoundingBox, TimeInterval from interfaces.api.schemas.sta.observation import ( ObservationFields, ObservationSortByFields, @@ -153,6 +153,7 @@ def list( response_format: Optional[str] = None, include: Optional[list[str]] = None, bbox: Optional[BoundingBox] = None, + datetime_interval: Optional[TimeInterval] = None, ): requested_includes = self.resolve_include_set(include) queryset = Observation.objects @@ -164,9 +165,10 @@ def list( "format=row and format=column require exactly one datastream_id filter value" ) - for field in ["datastream_id", "phenomenon_time__lte", "phenomenon_time__gte"]: - if field in filtering: - queryset = self.apply_filters(queryset, field, filtering[field]) + if "datastream_id" in filtering: + queryset = self.apply_filters(queryset, "datastream_id", filtering["datastream_id"]) + + queryset = self.apply_datetime_instant(queryset, datetime_interval, "phenomenon_time") if filtering.get("result_qualifier_codes"): code_filter = Q() @@ -185,11 +187,7 @@ def list( count = ( self.resolve_count(queryset) - if bool( - filtering.get("phenomenon_time__lte") - or filtering.get("phenomenon_time__gte") - or filtering.get("result_qualifier_codes") - ) + if datetime_interval is not None or filtering.get("result_qualifier_codes") else self.sum_datastream_value_count(principal, datastream_ids, bbox) ) diff --git a/django/interfaces/api/views/quality/history.py b/django/interfaces/api/views/quality/history.py index d61729ea..8f974e5a 100644 --- a/django/interfaces/api/views/quality/history.py +++ b/django/interfaces/api/views/quality/history.py @@ -40,6 +40,7 @@ def get_qc_histories( sortby=query.sortby, filtering=query.dict(exclude_unset=True), include=query.include, + datetime_interval=query.datetime, ) diff --git a/django/interfaces/api/views/sta/datastream.py b/django/interfaces/api/views/sta/datastream.py index 3a2cbc75..e9ca9e8c 100644 --- a/django/interfaces/api/views/sta/datastream.py +++ b/django/interfaces/api/views/sta/datastream.py @@ -53,6 +53,7 @@ def get_datastreams( filtering=query.dict(exclude_unset=True), include=query.include, bbox=query.bbox, + datetime_interval=query.datetime, ) diff --git a/django/interfaces/api/views/sta/observation.py b/django/interfaces/api/views/sta/observation.py index d0d93a28..6e38bdc3 100644 --- a/django/interfaces/api/views/sta/observation.py +++ b/django/interfaces/api/views/sta/observation.py @@ -59,6 +59,7 @@ def get_observations( response_format=query.response_format, include=query.include, bbox=query.bbox, + datetime_interval=query.datetime, ) diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index 9fdc96b4..0e219246 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -1,6 +1,8 @@ import uuid +from datetime import timedelta import pytest +from django.utils import timezone from django.db import connection from django.test.utils import CaptureQueriesContext @@ -980,3 +982,44 @@ def test_get_datastreams_returns_400_for_invalid_bbox(client): response = client.get(DATASTREAMS_URL, {"bbox": "-112,40,-111,91"}) assert response.status_code == 400 + + +# --- datetime ---------------------------------------------------------------------------- + + +def _datastream_spanning(begin, end): + return DatastreamFactory(phenomenon_begin_time=begin, phenomenon_end_time=end) + + +def test_get_datastreams_filters_by_overlapping_phenomenon_time(client): + jan = timezone.now().replace(microsecond=0) - timedelta(days=60) + feb = jan + timedelta(days=30) + before = _datastream_spanning(jan - timedelta(days=20), jan - timedelta(days=10)) + overlaps_start = _datastream_spanning(jan - timedelta(days=5), jan + timedelta(days=5)) + inside = _datastream_spanning(jan + timedelta(days=1), jan + timedelta(days=2)) + touches_end = _datastream_spanning(feb, feb + timedelta(days=5)) + after = _datastream_spanning(feb + timedelta(days=1), feb + timedelta(days=5)) + + response = client.get(DATASTREAMS_URL, {"datetime": f"{jan.isoformat()}/{feb.isoformat()}"}) + + assert response.status_code == 200 + ids = _datastream_ids(response) + assert {str(overlaps_start.id), str(inside.id), str(touches_end.id)} <= ids + assert not ids & {str(before.id), str(after.id)} + + +def test_get_datastreams_datetime_matches_datastreams_without_observations(client): + empty = _datastream_spanning(None, None) + old = _datastream_spanning(timezone.now() - timedelta(days=20), timezone.now() - timedelta(days=10)) + + response = client.get(DATASTREAMS_URL, {"datetime": timezone.now().replace(microsecond=0).isoformat()}) + + assert response.status_code == 200 + assert str(empty.id) in _datastream_ids(response) + assert str(old.id) not in _datastream_ids(response) + + +def test_get_datastreams_returns_400_for_invalid_datetime(client): + response = client.get(DATASTREAMS_URL, {"datetime": "../.."}) + + assert response.status_code == 400 diff --git a/django/tests/interfaces/api/test_observation_views.py b/django/tests/interfaces/api/test_observation_views.py index 2e884f8f..167feeb7 100644 --- a/django/tests/interfaces/api/test_observation_views.py +++ b/django/tests/interfaces/api/test_observation_views.py @@ -261,7 +261,7 @@ def test_get_observations_total_count_exact_for_phenomenon_time_filter(client): response = client.get( _observations_url( - datastream.id, phenomenon_time_min=_iso(keep_time - timedelta(minutes=1)) + datastream.id, datetime=f"{_iso(keep_time - timedelta(minutes=1))}/.." ) ) @@ -1012,7 +1012,7 @@ def test_get_observations_bbox_count_uses_matched_datastreams_with_a_time_filter response = client.get( _observations_url( bbox="-112,40,-111,41", - phenomenon_time_min=(timezone.now() - timedelta(days=1)).isoformat(), + datetime=f"{(timezone.now() - timedelta(days=1)).isoformat()}/..", ) ) @@ -1048,3 +1048,46 @@ def test_get_observations_returns_400_for_invalid_bbox(client): response = client.get(_observations_url(bbox="-112,40,-111")) assert response.status_code == 400 + + +# --- datetime ---------------------------------------------------------------------------- + + +def _observations_at(datastream, *times): + return [ObservationFactory(datastream=datastream, phenomenon_time=t) for t in times] + + +def test_get_observations_filters_by_datetime_interval_including_bounds(client): + datastream = DatastreamFactory(value_count=4) + start = timezone.now().replace(microsecond=0) - timedelta(days=10) + end = start + timedelta(days=5) + before, on_start, on_end, after = _observations_at( + datastream, start - timedelta(seconds=1), start, end, end + timedelta(seconds=1) + ) + + response = client.get(_observations_url(datastream.id, datetime=f"{_iso(start)}/{_iso(end)}")) + + assert response.status_code == 200 + body = response.json() + assert {o["id"] for o in body["data"]} == {str(on_start.id), str(on_end.id)} + assert body["meta"]["totalCount"] == 2 + + +def test_get_observations_filters_by_datetime_instant_and_open_ends(client): + datastream = DatastreamFactory(value_count=2) + early_time = timezone.now().replace(microsecond=0) - timedelta(days=10) + late_time = early_time + timedelta(days=5) + early, late = _observations_at(datastream, early_time, late_time) + + def ids(value): + return {o["id"] for o in client.get(_observations_url(datastream.id, datetime=value)).json()["data"]} + + assert ids(_iso(late_time)) == {str(late.id)} + assert ids(f"../{_iso(early_time)}") == {str(early.id)} + assert ids(f"{_iso(late_time)}/") == {str(late.id)} + + +def test_get_observations_returns_400_for_invalid_datetime(client): + response = client.get(_observations_url(datetime="2024-01-01")) + + assert response.status_code == 400 diff --git a/django/tests/interfaces/api/test_qc_history_views.py b/django/tests/interfaces/api/test_qc_history_views.py index d6d3ad25..50e64ebf 100644 --- a/django/tests/interfaces/api/test_qc_history_views.py +++ b/django/tests/interfaces/api/test_qc_history_views.py @@ -1,4 +1,7 @@ +from datetime import timedelta + import pytest +from django.utils import timezone from django.db import connection from django.test.utils import CaptureQueriesContext @@ -306,3 +309,35 @@ def test_get_qc_histories_properties_filters_response_fields(client): assert response.status_code == 200 row = response.json()["data"][0] assert set(row.keys()) == {"id", "createdAt"} + + +# --- datetime ---------------------------------------------------------------------------- + + +def test_get_qc_histories_filters_by_overlapping_phenomenon_time(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + now = timezone.now().replace(microsecond=0) + overlapping = _make_history( + workspace, phenomenon_time_start=now - timedelta(days=10), phenomenon_time_end=now - timedelta(days=5) + ) + uncommitted = _make_history(workspace, phenomenon_time_start=None, phenomenon_time_end=None) + earlier = _make_history( + workspace, phenomenon_time_start=now - timedelta(days=40), phenomenon_time_end=now - timedelta(days=30) + ) + client.force_login(owner) + + response = client.get(QC_HISTORIES_URL, {"datetime": f"{(now - timedelta(days=7)).isoformat()}/.."}) + + assert response.status_code == 200 + ids = {h["id"] for h in response.json()["data"]} + assert ids == {str(overlapping.id), str(uncommitted.id)} + assert str(earlier.id) not in ids + + +def test_get_qc_histories_returns_400_for_invalid_datetime(client): + client.force_login(UserFactory()) + + response = client.get(QC_HISTORIES_URL, {"datetime": "2024-01-01T00:00:00"}) + + assert response.status_code == 400 diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index a268db90..83b8ea45 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -3058,26 +3058,6 @@ export interface components { * @default 0 */ offset: number | null; - /** - * Phenomenon Begin Time Max - * @description Sets the maximum phenomenon begin time of filtered datastreams. - */ - phenomenon_begin_time_max?: string | null; - /** - * Phenomenon Begin Time Min - * @description Sets the minimum phenomenon begin time of filtered datastreams. - */ - phenomenon_begin_time_min?: string | null; - /** - * Phenomenon End Time Max - * @description Sets the maximum phenomenon end time of filtered datastreams. - */ - phenomenon_end_time_max?: string | null; - /** - * Phenomenon End Time Min - * @description Sets the minimum phenomenon end time of filtered datastreams. - */ - phenomenon_end_time_min?: string | null; /** * Processing Level Id * @description Filter datastreams by processing level ID. @@ -3094,26 +3074,6 @@ export interface components { * @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; - /** - * Result Begin Time Max - * @description Sets the maximum result begin time of filtered datastreams. - */ - result_begin_time_max?: string | null; - /** - * Result Begin Time Min - * @description Sets the minimum result begin time of filtered datastreams. - */ - result_begin_time_min?: string | null; - /** - * Result End Time Max - * @description Sets the maximum result end time of filtered datastreams. - */ - result_end_time_max?: string | null; - /** - * Result End Time Min - * @description Sets the minimum result end time of filtered datastreams. - */ - result_end_time_min?: string | null; /** * Result Qualifier Id * @description Filter datastreams by observation result qualifier ID. @@ -4985,16 +4945,6 @@ export interface components { * @default 0 */ offset: number | null; - /** - * Phenomenon Time Max - * @description Sets the maximum phenomenon time of filtered observations. - */ - phenomenon_time_max?: string | null; - /** - * Phenomenon Time Min - * @description Sets the minimum phenomenon time of filtered observations. - */ - phenomenon_time_min?: string | null; /** * Properties * @description Comma-separated list of properties to include in the response. All properties are returned if omitted. Only applies to format=record. @@ -8612,22 +8562,6 @@ export interface operations { value_count_max?: number | null; /** @description Sets the minimum value count of filtered datastreams. */ value_count_min?: number | null; - /** @description Sets the maximum phenomenon begin time of filtered datastreams. */ - phenomenon_begin_time_max?: string | null; - /** @description Sets the minimum phenomenon begin time of filtered datastreams. */ - phenomenon_begin_time_min?: string | null; - /** @description Sets the maximum phenomenon end time of filtered datastreams. */ - phenomenon_end_time_max?: string | null; - /** @description Sets the minimum phenomenon end time of filtered datastreams. */ - phenomenon_end_time_min?: string | null; - /** @description Sets the maximum result begin time of filtered datastreams. */ - result_begin_time_max?: string | null; - /** @description Sets the minimum result begin time of filtered datastreams. */ - result_begin_time_min?: string | null; - /** @description Sets the maximum result end time of filtered datastreams. */ - result_end_time_max?: string | null; - /** @description Sets the minimum result end time of filtered datastreams. */ - result_end_time_min?: string | null; }; header?: never; path?: never; @@ -12656,10 +12590,6 @@ export interface operations { sortby?: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId")[] | null; /** @description Controls the format of the observations response. */ format?: ("record" | "row" | "column") | null; - /** @description Sets the maximum phenomenon time of filtered observations. */ - phenomenon_time_max?: string | null; - /** @description Sets the minimum phenomenon time of filtered observations. */ - phenomenon_time_min?: string | null; /** @description Filter observations by result qualifier code. */ result_qualifier_code?: string[]; }; diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py index 89e7e208..efca1a8f 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py @@ -5,7 +5,7 @@ from datetime import datetime from pydantic.alias_generators import to_camel from hydroserverpy.api.models import Datastream, ObservationCollection -from hydroserverpy.api.utils import normalize_uuid +from hydroserverpy.api.utils import normalize_uuid, build_datetime_interval from ..base import HydroServerBaseService if TYPE_CHECKING: @@ -44,19 +44,15 @@ def list( is_private: bool = ..., value_count_max: int = ..., value_count_min: int = ..., - phenomenon_begin_time_max: datetime = ..., - phenomenon_begin_time_min: datetime = ..., - phenomenon_end_time_max: datetime = ..., - phenomenon_end_time_min: datetime = ..., - result_begin_time_max: datetime = ..., - result_begin_time_min: datetime = ..., - result_end_time_max: datetime = ..., - result_end_time_min: datetime = ..., + phenomenon_time_max: datetime = ..., + phenomenon_time_min: datetime = ..., fetch_all: bool = False, ) -> List["Workspace"]: - """Fetch a collection of HydroServer workspaces.""" + """ + Fetch a collection of HydroServer datastreams. + """ - return super().list( + collection = super().list( offset=offset, limit=limit, sortby=sortby, @@ -74,17 +70,25 @@ def list( is_private=is_private, value_count_max=value_count_max, value_count_min=value_count_min, - phenomenon_begin_time_max=phenomenon_begin_time_max, - phenomenon_begin_time_min=phenomenon_begin_time_min, - phenomenon_end_time_max=phenomenon_end_time_max, - phenomenon_end_time_min=phenomenon_end_time_min, - result_begin_time_max=result_begin_time_max, - result_begin_time_min=result_begin_time_min, - result_end_time_max=result_end_time_max, - result_end_time_min=result_end_time_min, - fetch_all=fetch_all, + datetime=build_datetime_interval(phenomenon_time_min, phenomenon_time_max), + fetch_all=False, ) + collection.filters.pop("datetime", None) + collection.filters.update({ + k: v + for k, v in { + "phenomenon_time_max": phenomenon_time_max, + "phenomenon_time_min": phenomenon_time_min, + }.items() + if v is not ... + }) + + if fetch_all is True: + collection = collection.fetch_all() + + return collection + def create( self, name: str, @@ -251,8 +255,7 @@ def get_observations( "offset": offset, "limit": limit, "sortby": ",".join(sortby) if sortby is not ... else sortby, - "phenomenon_time_max": phenomenon_time_max, - "phenomenon_time_min": phenomenon_time_min, + "datetime": build_datetime_interval(phenomenon_time_min, phenomenon_time_max), "result_qualifier_code": result_qualifier_code, "format": "column" } @@ -271,8 +274,12 @@ def get_observations( sortby=sortby if sortby is not ... else None, filters={ k: v - for k, v in params.items() - if k not in ["datastream_id", "offset", "limit", "sortby", "format"] + for k, v in { + "phenomenon_time_max": phenomenon_time_max, + "phenomenon_time_min": phenomenon_time_min, + "result_qualifier_code": result_qualifier_code, + }.items() + if v is not ... }, ) if fetch_all is True: diff --git a/packages/hydroserverpy/src/hydroserverpy/api/utils.py b/packages/hydroserverpy/src/hydroserverpy/api/utils.py index 1abc7b7c..c527171d 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/utils.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/utils.py @@ -1,4 +1,5 @@ from uuid import UUID +from datetime import datetime, timezone from typing import Union, Optional, Any from pydantic.alias_generators import to_camel @@ -20,3 +21,31 @@ def sortby_to_camel(s: str) -> str: if s.startswith('-'): return '-' + to_camel(s[1:]) return to_camel(s) + + +def build_datetime_interval( + start: Optional[Union[datetime, str, Any]], + end: Optional[Union[datetime, str, Any]], +): + """ + Builds an OGC API datetime interval ("start/end", with ".." for an open end) from optional + start and end values. Returns ... when neither is set. Datetimes and ISO 8601 strings without + a UTC offset are treated as UTC. + """ + + def to_rfc3339(value) -> str: + if value is ... or value is None: + return ".." + if isinstance(value, str): + try: + value = datetime.fromisoformat(value) + except ValueError: + return value + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + + if start in (..., None) and end in (..., None): + return ... + + return f"{to_rfc3339(start)}/{to_rfc3339(end)}" diff --git a/packages/hydroserverpy/tests/api/test_datastream_datetime.py b/packages/hydroserverpy/tests/api/test_datastream_datetime.py new file mode 100644 index 00000000..98e9e884 --- /dev/null +++ b/packages/hydroserverpy/tests/api/test_datastream_datetime.py @@ -0,0 +1,119 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from hydroserverpy.api.services.sta.datastream import DatastreamService +from hydroserverpy.api.utils import build_datetime_interval + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return dict(self._payload) + + +def make_service(*payloads): + client = MagicMock() + client.base_route = "/api/ogc" + client.request.side_effect = [FakeResponse(payload) for payload in payloads] + return DatastreamService(client), client + + +def sent_params(client, call=0): + return client.request.call_args_list[call].kwargs["params"] + + +JAN_1 = datetime(2024, 1, 1, tzinfo=timezone.utc) + + +# --- build_datetime_interval --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "start, end, expected", + [ + (..., ..., ...), + (None, None, ...), + (JAN_1, ..., "2024-01-01T00:00:00+00:00/.."), + (..., JAN_1, "../2024-01-01T00:00:00+00:00"), + (datetime(2024, 1, 1), None, "2024-01-01T00:00:00+00:00/.."), + ("2024-01-01", "2024-02-01T00:00:00Z", "2024-01-01T00:00:00+00:00/2024-02-01T00:00:00+00:00"), + ], +) +def test_build_datetime_interval(start, end, expected): + assert build_datetime_interval(start, end) == expected + + +# --- DatastreamService.list ----------------------------------------------------------------- + + +def test_list_sends_phenomenon_time_bounds_as_a_datetime_interval(): + service, client = make_service({"data": [], "meta": {"offset": 0, "limit": 100, "totalCount": 0}}) + + service.list(phenomenon_time_min=JAN_1) + + params = sent_params(client) + assert params["datetime"] == "2024-01-01T00:00:00+00:00/.." + assert "phenomenon_time_min" not in params + + +def test_list_omits_datetime_without_bounds(): + service, client = make_service({"data": [], "meta": {"offset": 0, "limit": 100, "totalCount": 0}}) + + service.list() + + assert "datetime" not in sent_params(client) + + +def test_list_keeps_the_phenomenon_time_bounds_when_paging(): + page = {"data": [], "meta": {"offset": 0, "limit": 100, "totalCount": 0}} + service, client = make_service(page, page) + + collection = service.list(phenomenon_time_min=JAN_1, phenomenon_time_max=JAN_1) + assert collection.filters["phenomenon_time_min"] == JAN_1 + assert "datetime" not in collection.filters + + collection.next_page() + + assert sent_params(client, 1)["datetime"] == sent_params(client, 0)["datetime"] + assert sent_params(client, 1)["offset"] == 100 + + +def test_list_fetch_all_keeps_the_phenomenon_time_bounds(): + full_page = { + "data": [{"id": f"00000000-0000-0000-0000-00000000000{i}"} for i in range(2)], + "meta": {"offset": 0, "limit": 2, "totalCount": 3}, + } + last_page = {"data": [], "meta": {"offset": 2, "limit": 2, "totalCount": 3}} + service, client = make_service(full_page, last_page) + service.model = MagicMock() + + service.list(limit=2, phenomenon_time_min=JAN_1, fetch_all=True) + + assert client.request.call_count == 2 + assert sent_params(client, 1)["datetime"] == "2024-01-01T00:00:00+00:00/.." + + +# --- DatastreamService.get_observations ----------------------------------------------------- + + +def test_get_observations_sends_a_datetime_interval_and_pages_with_the_same_bounds(): + columnar = { + "data": {"phenomenonTime": [], "result": [], "resultQualifierCodes": []}, + "meta": {"offset": 0, "limit": 1, "totalCount": 0}, + } + service, client = make_service(columnar, columnar) + service.get = MagicMock() + + collection = service.get_observations(uid="ds-1", limit=1, phenomenon_time_min=JAN_1) + assert collection.filters == {"phenomenon_time_min": JAN_1} + + service.get_observations(uid="ds-1", **collection.filters, offset=1, limit=1) + + assert sent_params(client, 0)["datetime"] == "2024-01-01T00:00:00+00:00/.." + assert sent_params(client, 1)["datetime"] == "2024-01-01T00:00:00+00:00/.." + assert "phenomenon_time_min" not in sent_params(client, 0) From 4d85b091b13c691e103c50063d3668a4b032f747 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 15:48:16 -0700 Subject: [PATCH 09/25] Make datastream observation statistics read-only --- django/contracts/openapi/ogc.openapi.json | 146 ++---------------- .../interfaces/api/schemas/sta/datastream.py | 16 +- .../interfaces/api/test_datastream_views.py | 94 +++++++++++ .../contracts/datastreams.contract.ts | 2 +- .../src/generated/ogc.types.d.ts | 24 +-- .../src/hydroserverpy/api/README.md | 6 - .../api/models/sta/datastream.py | 3 +- .../api/services/sta/datastream.py | 16 -- .../tests/api/test_datastream_datetime.py | 61 ++++++++ 9 files changed, 188 insertions(+), 180 deletions(-) diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 6d0604c2..bba4398c 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -2222,16 +2222,16 @@ "timeAggregationInterval", "status", "resultType", - "valueCount", - "phenomenonBeginTime", "phenomenonEndTime", - "resultBeginTime", - "resultEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", "intendedTimeSpacing", "intendedTimeSpacingUnit", + "valueCount", + "phenomenonBeginTime", + "resultBeginTime", + "resultEndTime", "monitoringSiteId", "methodId", "observedPropertyId", @@ -2325,21 +2325,6 @@ "title": "Observedpropertyid", "type": "string" }, - "phenomenonBeginTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Phenomenonbegintime" - }, "phenomenonEndTime": { "anyOf": [ { @@ -2360,36 +2345,6 @@ "title": "Processinglevelid", "type": "string" }, - "resultBeginTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Resultbegintime" - }, - "resultEndTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Resultendtime" - }, "resultType": { "maxLength": 255, "title": "Resulttype", @@ -2444,18 +2399,6 @@ "format": "uuid", "title": "Unitid", "type": "string" - }, - "valueCount": { - "anyOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Valuecount" } }, "title": "DatastreamPatchBody", @@ -2551,21 +2494,6 @@ "title": "Observedpropertyid", "type": "string" }, - "phenomenonBeginTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Phenomenonbegintime" - }, "phenomenonEndTime": { "anyOf": [ { @@ -2586,36 +2514,6 @@ "title": "Processinglevelid", "type": "string" }, - "resultBeginTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Resultbegintime" - }, - "resultEndTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Resultendtime" - }, "resultType": { "maxLength": 255, "title": "Resulttype", @@ -2664,18 +2562,6 @@ "format": "uuid", "title": "Unitid", "type": "string" - }, - "valueCount": { - "anyOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Valuecount" } }, "required": [ @@ -2844,16 +2730,16 @@ "timeAggregationInterval", "status", "resultType", - "valueCount", - "phenomenonBeginTime", "phenomenonEndTime", - "resultBeginTime", - "resultEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", "intendedTimeSpacing", "intendedTimeSpacingUnit", + "valueCount", + "phenomenonBeginTime", + "resultBeginTime", + "resultEndTime", "monitoringSiteId", "methodId", "observedPropertyId", @@ -18007,16 +17893,16 @@ "timeAggregationInterval", "status", "resultType", - "valueCount", - "phenomenonBeginTime", "phenomenonEndTime", - "resultBeginTime", - "resultEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", "intendedTimeSpacing", "intendedTimeSpacingUnit", + "valueCount", + "phenomenonBeginTime", + "resultBeginTime", + "resultEndTime", "monitoringSiteId", "methodId", "observedPropertyId", @@ -18628,16 +18514,16 @@ "timeAggregationInterval", "status", "resultType", - "valueCount", - "phenomenonBeginTime", "phenomenonEndTime", - "resultBeginTime", - "resultEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", "intendedTimeSpacing", "intendedTimeSpacingUnit", + "valueCount", + "phenomenonBeginTime", + "resultBeginTime", + "resultEndTime", "monitoringSiteId", "methodId", "observedPropertyId", diff --git a/django/interfaces/api/schemas/sta/datastream.py b/django/interfaces/api/schemas/sta/datastream.py index 2ccc2d69..2aef4e67 100644 --- a/django/interfaces/api/schemas/sta/datastream.py +++ b/django/interfaces/api/schemas/sta/datastream.py @@ -38,11 +38,7 @@ class DatastreamFields(Schema): time_aggregation_interval: float status: Optional[str] = Field(None, max_length=255) result_type: str = Field(..., max_length=255) - value_count: Optional[int] = Field(None, ge=0) - phenomenon_begin_time: Optional[ISODatetime] = None phenomenon_end_time: Optional[ISODatetime] = None - result_begin_time: Optional[ISODatetime] = None - result_end_time: Optional[ISODatetime] = None is_private: bool = False is_visible: bool = True time_aggregation_interval_unit: Literal["seconds", "minutes", "hours", "days"] @@ -52,6 +48,15 @@ class DatastreamFields(Schema): ] = None +class DatastreamStatisticsFields(Schema): + """Observation statistics the server maintains; returned in responses but never writable.""" + + value_count: Optional[int] = Field(None, ge=0) + phenomenon_begin_time: Optional[ISODatetime] = None + result_begin_time: Optional[ISODatetime] = None + result_end_time: Optional[ISODatetime] = None + + class DatastreamRelatedFields(Schema): monitoring_site_id: uuid.UUID method_id: uuid.UUID @@ -135,6 +140,7 @@ class DatastreamRelatedFields(Schema): "id", "workspaceId", *(to_camel(name) for name in DatastreamFields.model_fields), + *(to_camel(name) for name in DatastreamStatisticsFields.model_fields), *(to_camel(name) for name in DatastreamRelatedFields.model_fields), "tags", "linkedResources", @@ -279,7 +285,7 @@ class DatastreamVisualizationBootstrapResponse(BaseGetResponse): class DatastreamResponse( - BaseGetResponse, DatastreamFields, DatastreamRelatedFields + BaseGetResponse, DatastreamFields, DatastreamStatisticsFields, DatastreamRelatedFields ): id: uuid.UUID workspace_id: uuid.UUID = Field( diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index 0e219246..329e6007 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -6,6 +6,7 @@ from django.db import connection from django.test.utils import CaptureQueriesContext +from core.sta.models import Datastream from tests.core.iam.factories import ( CollaboratorFactory, PermissionFactory, @@ -1023,3 +1024,96 @@ def test_get_datastreams_returns_400_for_invalid_datetime(client): response = client.get(DATASTREAMS_URL, {"datetime": "../.."}) assert response.status_code == 400 + + +# --- server-maintained statistics ------------------------------------------------------- + +STATISTICS = { + "valueCount": 42, + "phenomenonBeginTime": "2020-01-01T00:00:00Z", + "resultBeginTime": "2020-01-01T00:00:00Z", + "resultEndTime": "2020-12-31T00:00:00Z", +} + + +def test_create_datastream_ignores_server_maintained_statistics(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + body = _datastream_body( + MonitoringSiteFactory(workspace=workspace), + MethodFactory(workspace=workspace), + ObservedPropertyFactory(workspace=workspace), + ProcessingLevelFactory(workspace=workspace), + UnitFactory(workspace=workspace), + **STATISTICS, + ) + client.force_login(owner) + + response = client.post(DATASTREAMS_URL, data=body, content_type="application/json") + + assert response.status_code == 201 + datastream = Datastream.objects.get(pk=response.json()["id"]) + assert datastream.value_count in (None, 0) + assert datastream.phenomenon_begin_time is None + assert datastream.result_begin_time is None + + +def test_update_datastream_ignores_server_maintained_statistics(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + begin = timezone.now().replace(microsecond=0) - timedelta(days=2) + datastream = DatastreamFactory( + monitoring_site=MonitoringSiteFactory(workspace=workspace), + value_count=3, + phenomenon_begin_time=begin, + phenomenon_end_time=begin + timedelta(days=1), + ) + client.force_login(owner) + + response = client.patch(_detail_url(datastream.id), data=STATISTICS, content_type="application/json") + + assert response.status_code == 204 + datastream.refresh_from_db() + assert datastream.value_count == 3 + assert datastream.phenomenon_begin_time == begin + + +def test_create_datastream_accepts_a_seeded_phenomenon_end_time(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + body = _datastream_body( + MonitoringSiteFactory(workspace=workspace), + MethodFactory(workspace=workspace), + ObservedPropertyFactory(workspace=workspace), + ProcessingLevelFactory(workspace=workspace), + UnitFactory(workspace=workspace), + phenomenonEndTime="2025-01-01T00:00:00Z", + ) + client.force_login(owner) + + response = client.post(DATASTREAMS_URL, data=body, content_type="application/json") + + assert response.status_code == 201 + datastream = Datastream.objects.get(pk=response.json()["id"]) + assert datastream.phenomenon_end_time.isoformat() == "2025-01-01T00:00:00+00:00" + + +def test_get_datastream_returns_server_maintained_statistics(client): + begin = timezone.now().replace(microsecond=0) - timedelta(days=2) + datastream = DatastreamFactory(value_count=3, phenomenon_begin_time=begin, phenomenon_end_time=begin) + + response = client.get(_detail_url(datastream.id), {"properties": "valueCount,phenomenonBeginTime"}) + + assert response.status_code == 200 + assert response.json()["data"] == {"valueCount": 3, "phenomenonBeginTime": begin.isoformat()} + + +def test_datastream_request_bodies_do_not_declare_server_maintained_statistics(): + from interfaces.api.urls import api + + schemas = api.get_openapi_schema(path_prefix="/api/ogc/")["components"]["schemas"] + + for body in ("DatastreamPostBody", "DatastreamPatchBody"): + assert not set(schemas[body]["properties"]) & set(STATISTICS) + assert "phenomenonEndTime" in schemas[body]["properties"] + assert set(STATISTICS) <= set(schemas["DatastreamResponse"]["properties"]) diff --git a/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts b/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts index 24273bc4..5548c21f 100644 --- a/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts @@ -10,7 +10,7 @@ export namespace DatastreamContract { export type PostBody = Data.components['schemas']['DatastreamPostBody'] export type PatchBody = Data.components['schemas']['DatastreamPatchBody'] export type DeleteBody = never - export const writableKeys = ["aggregationStatistic","description","intendedTimeSpacing","intendedTimeSpacingUnit","isPrivate","isVisible","methodId","monitoringSiteId","name","noDataValue","observationType","observedPropertyId","phenomenonBeginTime","phenomenonEndTime","processingLevelId","resultBeginTime","resultEndTime","resultType","sampledMedium","status","tags","timeAggregationInterval","timeAggregationIntervalUnit","unitId","valueCount"] as const + export const writableKeys = ["aggregationStatistic","description","intendedTimeSpacing","intendedTimeSpacingUnit","isPrivate","isVisible","methodId","monitoringSiteId","name","noDataValue","observationType","observedPropertyId","phenomenonEndTime","processingLevelId","resultType","sampledMedium","status","tags","timeAggregationInterval","timeAggregationIntervalUnit","unitId"] as const export declare const __types: { SummaryResponse: SummaryResponse DetailResponse: DetailResponse diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 83b8ea45..9bd0b01b 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2847,7 +2847,7 @@ export interface components { * Properties * @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; }; /** DatastreamPatchBody */ DatastreamPatchBody: { @@ -2884,8 +2884,6 @@ export interface components { * Format: uuid */ observedPropertyId?: string; - /** Phenomenonbegintime */ - phenomenonBeginTime?: string | null; /** Phenomenonendtime */ phenomenonEndTime?: string | null; /** @@ -2893,10 +2891,6 @@ export interface components { * Format: uuid */ processingLevelId?: string; - /** Resultbegintime */ - resultBeginTime?: string | null; - /** Resultendtime */ - resultEndTime?: string | null; /** Resulttype */ resultType?: string; /** Sampledmedium */ @@ -2919,8 +2913,6 @@ export interface components { * Format: uuid */ unitId?: string; - /** Valuecount */ - valueCount?: number | null; }; /** DatastreamPostBody */ DatastreamPostBody: { @@ -2965,8 +2957,6 @@ export interface components { * Format: uuid */ observedPropertyId: string; - /** Phenomenonbegintime */ - phenomenonBeginTime?: string | null; /** Phenomenonendtime */ phenomenonEndTime?: string | null; /** @@ -2974,10 +2964,6 @@ export interface components { * Format: uuid */ processingLevelId: string; - /** Resultbegintime */ - resultBeginTime?: string | null; - /** Resultendtime */ - resultEndTime?: string | null; /** Resulttype */ resultType: string; /** Sampledmedium */ @@ -3003,8 +2989,6 @@ export interface components { * Format: uuid */ unitId: string; - /** Valuecount */ - valueCount?: number | null; }; /** DatastreamQueryParameters */ DatastreamQueryParameters: { @@ -3068,7 +3052,7 @@ export interface components { * Properties * @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** * Q * @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. @@ -8521,7 +8505,7 @@ export interface operations { /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; /** @description Number of items to skip. */ @@ -8644,7 +8628,7 @@ export interface operations { parameters: { query?: { /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; }; diff --git a/packages/hydroserverpy/src/hydroserverpy/api/README.md b/packages/hydroserverpy/src/hydroserverpy/api/README.md index 7ee41d76..53c25859 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/README.md +++ b/packages/hydroserverpy/src/hydroserverpy/api/README.md @@ -575,8 +575,6 @@ datastream = hs_api.datastreams.get(uid='00000000-0000-0000-0000-000000000000') #### Example: Create Datastream ```python -from datetime import datetime - ... # Create a new datastream on HydroServer @@ -590,11 +588,7 @@ new_datastream = hs_api.datastreams.create( time_aggregation_interval=1, status='Ongoing', result_type='Timeseries', - value_count=0, - phenomenon_begin_time=datetime(year=2024, month=1, day=1), phenomenon_end_time=None, - result_begin_time=datetime(year=2024, month=1, day=1), - result_end_time=None, is_visible=True, is_private=False, monitoring_site='00000000-0000-0000-0000-000000000000', diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py index 4c330fa3..e04201c1 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py @@ -52,8 +52,7 @@ class Datastream(HydroServerBaseModel): _editable_fields: ClassVar[set[str]] = { "name", "description", "observation_type", "sampled_medium", "no_data_value", "aggregation_statistic", - "time_aggregation_interval", "status", "result_type", "value_count", "phenomenon_begin_time", - "phenomenon_end_time", "result_begin_time", "result_end_time", "is_private", "is_visible", + "time_aggregation_interval", "status", "result_type", "phenomenon_end_time", "is_private", "is_visible", "time_aggregation_interval_unit", "intended_time_spacing", "intended_time_spacing_unit", "monitoring_site_id", "method_id", "observed_property_id", "processing_level_id", "unit_id" } diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py index efca1a8f..2236e6d7 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py @@ -110,11 +110,7 @@ def create( Literal["seconds", "minutes", "hours", "days"] ] = None, status: Optional[str] = None, - value_count: Optional[int] = None, - phenomenon_begin_time: Optional[datetime] = None, phenomenon_end_time: Optional[datetime] = None, - result_begin_time: Optional[datetime] = None, - result_end_time: Optional[datetime] = None, is_private: bool = False, is_visible: bool = True, tags: Optional[Dict[str, str]] = None, @@ -141,11 +137,7 @@ def create( "intendedTimeSpacing": intended_time_spacing, "intendedTimeSpacingUnit": intended_time_spacing_unit, "status": status, - "valueCount": value_count, - "phenomenonBeginTime": phenomenon_begin_time, "phenomenonEndTime": phenomenon_end_time, - "resultBeginTime": result_begin_time, - "resultEndTime": result_end_time, "isPrivate": is_private, "isVisible": is_visible, "tags": tags or {}, @@ -182,11 +174,7 @@ def update( Literal["seconds", "minutes", "hours", "days"] ] = ..., status: Optional[str] = ..., - value_count: Optional[int] = ..., - phenomenon_begin_time: Optional[datetime] = ..., phenomenon_end_time: Optional[datetime] = ..., - result_begin_time: Optional[datetime] = ..., - result_end_time: Optional[datetime] = ..., is_private: bool = ..., is_visible: bool = ..., tags: Dict[str, Optional[str]] = ..., @@ -215,11 +203,7 @@ def update( "intendedTimeSpacing": intended_time_spacing, "intendedTimeSpacingUnit": intended_time_spacing_unit, "status": status, - "valueCount": value_count, - "phenomenonBeginTime": phenomenon_begin_time, "phenomenonEndTime": phenomenon_end_time, - "resultBeginTime": result_begin_time, - "resultEndTime": result_end_time, "isPrivate": is_private, "isVisible": is_visible, "tags": tags, diff --git a/packages/hydroserverpy/tests/api/test_datastream_datetime.py b/packages/hydroserverpy/tests/api/test_datastream_datetime.py index 98e9e884..1e780c7a 100644 --- a/packages/hydroserverpy/tests/api/test_datastream_datetime.py +++ b/packages/hydroserverpy/tests/api/test_datastream_datetime.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timezone from unittest.mock import MagicMock @@ -28,6 +29,7 @@ def sent_params(client, call=0): JAN_1 = datetime(2024, 1, 1, tzinfo=timezone.utc) +FEB_1 = datetime(2024, 2, 1, tzinfo=timezone.utc) # --- build_datetime_interval --------------------------------------------------------------- @@ -117,3 +119,62 @@ def test_get_observations_sends_a_datetime_interval_and_pages_with_the_same_boun assert sent_params(client, 0)["datetime"] == "2024-01-01T00:00:00+00:00/.." assert sent_params(client, 1)["datetime"] == "2024-01-01T00:00:00+00:00/.." assert "phenomenon_time_min" not in sent_params(client, 0) + + +# --- server-maintained statistics ------------------------------------------------------- + +STATISTIC_KEYS = {"valueCount", "phenomenonBeginTime", "resultBeginTime", "resultEndTime"} + + +def _datastream_payload(**overrides): + payload = { + "id": "00000000-0000-0000-0000-000000000001", + "name": "Datastream", + "description": "", + "observationType": "OM_Measurement", + "sampledMedium": "Water", + "noDataValue": -9999, + "aggregationStatistic": "Average", + "timeAggregationInterval": 15, + "timeAggregationIntervalUnit": "minutes", + "resultType": "Time Series Coverage", + "valueCount": 3, + "phenomenonEndTime": "2024-01-01T00:00:00+00:00", + "monitoringSiteId": "00000000-0000-0000-0000-000000000002", + "workspaceId": "00000000-0000-0000-0000-000000000003", + "methodId": "00000000-0000-0000-0000-000000000004", + "observedPropertyId": "00000000-0000-0000-0000-000000000005", + "processingLevelId": "00000000-0000-0000-0000-000000000006", + "unitId": "00000000-0000-0000-0000-000000000007", + "tags": {}, + "linkedResources": {}, + } + payload.update(overrides) + return payload + + +def test_create_does_not_send_server_maintained_statistics(): + service, client = make_service({"id": "00000000-0000-0000-0000-000000000001"}, {"data": _datastream_payload()}) + client.datastreams = service + + service.create( + name="Datastream", description="", monitoring_site="s", method="m", observed_property="o", + processing_level="p", unit="u", observation_type="OM_Measurement", result_type="Time Series Coverage", + sampled_medium="Water", no_data_value=-9999, aggregation_statistic="Average", + time_aggregation_interval=15, time_aggregation_interval_unit="minutes", + ) + + assert not set(json.loads(client.request.call_args_list[0].kwargs["data"])) & STATISTIC_KEYS + + +def test_save_sends_only_the_writable_phenomenon_end_time(): + service, client = make_service({"data": _datastream_payload()}) + client.datastreams = service + datastream = service.get("00000000-0000-0000-0000-000000000001") + + datastream.value_count = 99 + datastream.phenomenon_begin_time = JAN_1 + datastream.phenomenon_end_time = FEB_1 + + assert datastream.unsaved_changes == {"phenomenon_end_time": FEB_1} + From 73eb164d46d70365c2b5b6cffe5022e732626a59 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 17:12:18 -0700 Subject: [PATCH 10/25] Make datastream's phenomenon_end_time read-only --- .../how-to/using-the-python-client.md | 14 +++---- .../part-3-automating-data-ingestion.md | 3 +- django/contracts/openapi/ogc.openapi.json | 38 ++----------------- .../interfaces/api/schemas/sta/datastream.py | 2 +- .../sensorthings/adapter/datastream.py | 22 +---------- .../interfaces/api/test_datastream_views.py | 24 ++---------- .../interfaces/api/test_observation_views.py | 3 +- .../sensorthings/test_st_datastreams.py | 7 +++- .../contracts/datastreams.contract.ts | 2 +- .../src/generated/ogc.types.d.ts | 12 ++---- .../src/hydroserverpy/api/README.md | 1 - .../api/models/sta/datastream.py | 29 ++++---------- .../api/services/sta/datastream.py | 4 -- .../tests/api/test_datastream_datetime.py | 20 ++++++++-- 14 files changed, 54 insertions(+), 127 deletions(-) diff --git a/apps/docs/docs/user-guides/how-to/using-the-python-client.md b/apps/docs/docs/user-guides/how-to/using-the-python-client.md index 44ae5767..6590e95f 100644 --- a/apps/docs/docs/user-guides/how-to/using-the-python-client.md +++ b/apps/docs/docs/user-guides/how-to/using-the-python-client.md @@ -374,11 +374,11 @@ Result-qualifier creation accepts `name` and `description` by position or by key | `intended_time_spacing_unit` | `str \| None` | Yes | `"seconds"`, `"minutes"`, `"hours"`, `"days"` | | `status` | `str \| None` | Yes | | | `result_type` | `str` | Yes | | -| `value_count` | `int \| None` | Yes | | -| `phenomenon_begin_time` | `datetime \| None` | Yes | | -| `phenomenon_end_time` | `datetime \| None` | Yes | | -| `result_begin_time` | `datetime \| None` | Yes | | -| `result_end_time` | `datetime \| None` | Yes | | +| `value_count` | `int \| None` | No | Maintained from observations | +| `phenomenon_begin_time` | `datetime \| None` | No | Maintained from observations | +| `phenomenon_end_time` | `datetime \| None` | No | Maintained from observations | +| `result_begin_time` | `datetime \| None` | No | Maintained from observations | +| `result_end_time` | `datetime \| None` | No | Maintained from observations | | `is_private` | `bool` | Yes | | | `is_visible` | `bool` | Yes | | | `monitoring_site_id` | `UUID` | Yes | | @@ -412,9 +412,7 @@ hs_api.datastreams.create(name, description, monitoring_site, method, observed_p unit, observation_type, result_type, sampled_medium, no_data_value, aggregation_statistic, time_aggregation_interval, time_aggregation_interval_unit, intended_time_spacing=None, - intended_time_spacing_unit=None, status=None, value_count=None, - phenomenon_begin_time=None, phenomenon_end_time=None, - result_begin_time=None, result_end_time=None, + intended_time_spacing_unit=None, status=None, is_private=False, is_visible=True, uid=None) -> Datastream ``` diff --git a/apps/docs/docs/user-guides/tutorials/getting-started-with-hydroserverpy/part-3-automating-data-ingestion.md b/apps/docs/docs/user-guides/tutorials/getting-started-with-hydroserverpy/part-3-automating-data-ingestion.md index 27da3b0f..76f2bca0 100644 --- a/apps/docs/docs/user-guides/tutorials/getting-started-with-hydroserverpy/part-3-automating-data-ingestion.md +++ b/apps/docs/docs/user-guides/tutorials/getting-started-with-hydroserverpy/part-3-automating-data-ingestion.md @@ -86,7 +86,6 @@ stage_datastream = hs_api.datastreams.create( time_aggregation_interval_unit='minutes', intended_time_spacing=15, intended_time_spacing_unit='minutes', - phenomenon_end_time='2025-01-01T00:00:00Z', status='Ongoing', is_private=False, ) @@ -96,7 +95,7 @@ print(f"{stage_datastream.name}: {stage_datastream.uid}") ## Create a data connection -A data connection describes where your data comes from and how to read it. Our source is the USGS Instantaneous Values API, which returns JSON. We'll define two run-time placeholder variables — `start_date` and `end_date` — that get substituted into the URL each time the task runs. HydroServer automatically sets `start_date` to the timestamp of the most recent observation already in the datastream, so you never re-fetch data you already have. +A data connection describes where your data comes from and how to read it. Our source is the USGS Instantaneous Values API, which returns JSON. We'll define two run-time placeholder variables — `start_date` and `end_date` — that get substituted into the URL each time the task runs. HydroServer automatically sets `start_date` to the timestamp of the most recent observation already in the datastream, so you never re-fetch data you already have. Our datastream is new and has no observations yet, so the first run starts from 1970-01-01 and pulls in all the history USGS has for this gage; later runs pick up from the most recent observation. ```python usgs_data_connection = hs_api.dataconnections.create( diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index bba4398c..ab575e4a 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -2222,7 +2222,6 @@ "timeAggregationInterval", "status", "resultType", - "phenomenonEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", @@ -2230,6 +2229,7 @@ "intendedTimeSpacingUnit", "valueCount", "phenomenonBeginTime", + "phenomenonEndTime", "resultBeginTime", "resultEndTime", "monitoringSiteId", @@ -2325,21 +2325,6 @@ "title": "Observedpropertyid", "type": "string" }, - "phenomenonEndTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Phenomenonendtime" - }, "processingLevelId": { "format": "uuid", "title": "Processinglevelid", @@ -2494,21 +2479,6 @@ "title": "Observedpropertyid", "type": "string" }, - "phenomenonEndTime": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Phenomenonendtime" - }, "processingLevelId": { "format": "uuid", "title": "Processinglevelid", @@ -2730,7 +2700,6 @@ "timeAggregationInterval", "status", "resultType", - "phenomenonEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", @@ -2738,6 +2707,7 @@ "intendedTimeSpacingUnit", "valueCount", "phenomenonBeginTime", + "phenomenonEndTime", "resultBeginTime", "resultEndTime", "monitoringSiteId", @@ -17893,7 +17863,6 @@ "timeAggregationInterval", "status", "resultType", - "phenomenonEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", @@ -17901,6 +17870,7 @@ "intendedTimeSpacingUnit", "valueCount", "phenomenonBeginTime", + "phenomenonEndTime", "resultBeginTime", "resultEndTime", "monitoringSiteId", @@ -18514,7 +18484,6 @@ "timeAggregationInterval", "status", "resultType", - "phenomenonEndTime", "isPrivate", "isVisible", "timeAggregationIntervalUnit", @@ -18522,6 +18491,7 @@ "intendedTimeSpacingUnit", "valueCount", "phenomenonBeginTime", + "phenomenonEndTime", "resultBeginTime", "resultEndTime", "monitoringSiteId", diff --git a/django/interfaces/api/schemas/sta/datastream.py b/django/interfaces/api/schemas/sta/datastream.py index 2aef4e67..9505e52a 100644 --- a/django/interfaces/api/schemas/sta/datastream.py +++ b/django/interfaces/api/schemas/sta/datastream.py @@ -38,7 +38,6 @@ class DatastreamFields(Schema): time_aggregation_interval: float status: Optional[str] = Field(None, max_length=255) result_type: str = Field(..., max_length=255) - phenomenon_end_time: Optional[ISODatetime] = None is_private: bool = False is_visible: bool = True time_aggregation_interval_unit: Literal["seconds", "minutes", "hours", "days"] @@ -53,6 +52,7 @@ class DatastreamStatisticsFields(Schema): value_count: Optional[int] = Field(None, ge=0) phenomenon_begin_time: Optional[ISODatetime] = None + phenomenon_end_time: Optional[ISODatetime] = None result_begin_time: Optional[ISODatetime] = None result_end_time: Optional[ISODatetime] = None diff --git a/django/interfaces/sensorthings/adapter/datastream.py b/django/interfaces/sensorthings/adapter/datastream.py index ed8e794e..6a8545e8 100644 --- a/django/interfaces/sensorthings/adapter/datastream.py +++ b/django/interfaces/sensorthings/adapter/datastream.py @@ -151,30 +151,12 @@ def create_datastreams(self, payload, context=None): raise HttpError(403, "This operation is not permitted.") def update_datastreams(self, payload, context=None): - for datastream_id, dto in payload.items(): - datastream = datastream_service.get_datastream_for_action( + for datastream_id in payload: + datastream_service.get_datastream_for_action( principal=context.principal if context else None, uid=datastream_id, action="edit", ) - if dto.phenomenon_time is not None: - parts = str(dto.phenomenon_time).split("/") - datastream.phenomenon_begin_time = parts[0] - datastream.phenomenon_end_time = parts[-1] - else: - datastream.phenomenon_begin_time = None - datastream.phenomenon_end_time = None - - if dto.result_time is not None: - parts = str(dto.result_time).split("/") - datastream.result_begin_time = parts[0] - datastream.result_end_time = parts[-1] - else: - datastream.result_begin_time = None - datastream.result_end_time = None - - datastream.save() - def delete_datastreams(self, entity_ids, context=None): raise HttpError(403, "This operation is not permitted.") diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index 329e6007..7907cef0 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -1031,6 +1031,7 @@ def test_get_datastreams_returns_400_for_invalid_datetime(client): STATISTICS = { "valueCount": 42, "phenomenonBeginTime": "2020-01-01T00:00:00Z", + "phenomenonEndTime": "2020-12-31T00:00:00Z", "resultBeginTime": "2020-01-01T00:00:00Z", "resultEndTime": "2020-12-31T00:00:00Z", } @@ -1055,6 +1056,7 @@ def test_create_datastream_ignores_server_maintained_statistics(client): datastream = Datastream.objects.get(pk=response.json()["id"]) assert datastream.value_count in (None, 0) assert datastream.phenomenon_begin_time is None + assert datastream.phenomenon_end_time is None assert datastream.result_begin_time is None @@ -1076,26 +1078,7 @@ def test_update_datastream_ignores_server_maintained_statistics(client): datastream.refresh_from_db() assert datastream.value_count == 3 assert datastream.phenomenon_begin_time == begin - - -def test_create_datastream_accepts_a_seeded_phenomenon_end_time(client): - owner = UserFactory() - workspace = WorkspaceFactory(owner=owner) - body = _datastream_body( - MonitoringSiteFactory(workspace=workspace), - MethodFactory(workspace=workspace), - ObservedPropertyFactory(workspace=workspace), - ProcessingLevelFactory(workspace=workspace), - UnitFactory(workspace=workspace), - phenomenonEndTime="2025-01-01T00:00:00Z", - ) - client.force_login(owner) - - response = client.post(DATASTREAMS_URL, data=body, content_type="application/json") - - assert response.status_code == 201 - datastream = Datastream.objects.get(pk=response.json()["id"]) - assert datastream.phenomenon_end_time.isoformat() == "2025-01-01T00:00:00+00:00" + assert datastream.phenomenon_end_time == begin + timedelta(days=1) def test_get_datastream_returns_server_maintained_statistics(client): @@ -1115,5 +1098,4 @@ def test_datastream_request_bodies_do_not_declare_server_maintained_statistics() for body in ("DatastreamPostBody", "DatastreamPatchBody"): assert not set(schemas[body]["properties"]) & set(STATISTICS) - assert "phenomenonEndTime" in schemas[body]["properties"] assert set(STATISTICS) <= set(schemas["DatastreamResponse"]["properties"]) diff --git a/django/tests/interfaces/api/test_observation_views.py b/django/tests/interfaces/api/test_observation_views.py index 167feeb7..45f79588 100644 --- a/django/tests/interfaces/api/test_observation_views.py +++ b/django/tests/interfaces/api/test_observation_views.py @@ -988,7 +988,8 @@ def _datastream_with_observations(longitude, latitude, count=2): monitoring_site=MonitoringSiteFactory(longitude=longitude, latitude=latitude), value_count=count, ) - ObservationFactory.create_batch(count, datastream=datastream) + for minutes in range(count): + ObservationFactory(datastream=datastream, phenomenon_time=timezone.now() - timedelta(minutes=minutes)) return datastream diff --git a/django/tests/interfaces/sensorthings/test_st_datastreams.py b/django/tests/interfaces/sensorthings/test_st_datastreams.py index a2820e78..cbcdb1b7 100644 --- a/django/tests/interfaces/sensorthings/test_st_datastreams.py +++ b/django/tests/interfaces/sensorthings/test_st_datastreams.py @@ -1,6 +1,11 @@ +import json +from datetime import timedelta + import pytest +from django.utils import timezone -from tests.core.sta.factories import DatastreamFactory +from tests.core.iam.factories import UserFactory, WorkspaceFactory +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory pytestmark = pytest.mark.django_db diff --git a/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts b/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts index 5548c21f..4d481a7c 100644 --- a/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/datastreams.contract.ts @@ -10,7 +10,7 @@ export namespace DatastreamContract { export type PostBody = Data.components['schemas']['DatastreamPostBody'] export type PatchBody = Data.components['schemas']['DatastreamPatchBody'] export type DeleteBody = never - export const writableKeys = ["aggregationStatistic","description","intendedTimeSpacing","intendedTimeSpacingUnit","isPrivate","isVisible","methodId","monitoringSiteId","name","noDataValue","observationType","observedPropertyId","phenomenonEndTime","processingLevelId","resultType","sampledMedium","status","tags","timeAggregationInterval","timeAggregationIntervalUnit","unitId"] as const + export const writableKeys = ["aggregationStatistic","description","intendedTimeSpacing","intendedTimeSpacingUnit","isPrivate","isVisible","methodId","monitoringSiteId","name","noDataValue","observationType","observedPropertyId","processingLevelId","resultType","sampledMedium","status","tags","timeAggregationInterval","timeAggregationIntervalUnit","unitId"] as const export declare const __types: { SummaryResponse: SummaryResponse DetailResponse: DetailResponse diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 9bd0b01b..a79364b1 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2847,7 +2847,7 @@ export interface components { * Properties * @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; }; /** DatastreamPatchBody */ DatastreamPatchBody: { @@ -2884,8 +2884,6 @@ export interface components { * Format: uuid */ observedPropertyId?: string; - /** Phenomenonendtime */ - phenomenonEndTime?: string | null; /** * Processinglevelid * Format: uuid @@ -2957,8 +2955,6 @@ export interface components { * Format: uuid */ observedPropertyId: string; - /** Phenomenonendtime */ - phenomenonEndTime?: string | null; /** * Processinglevelid * Format: uuid @@ -3052,7 +3048,7 @@ export interface components { * Properties * @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** * Q * @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. @@ -8505,7 +8501,7 @@ export interface operations { /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ datetime?: string; /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; /** @description Number of items to skip. */ @@ -8628,7 +8624,7 @@ export interface operations { parameters: { query?: { /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ - properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "phenomenonEndTime" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; + properties?: ("id" | "workspaceId" | "name" | "description" | "observationType" | "sampledMedium" | "noDataValue" | "aggregationStatistic" | "timeAggregationInterval" | "status" | "resultType" | "isPrivate" | "isVisible" | "timeAggregationIntervalUnit" | "intendedTimeSpacing" | "intendedTimeSpacingUnit" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "monitoringSiteId" | "methodId" | "observedPropertyId" | "processingLevelId" | "unitId" | "tags" | "linkedResources")[]; /** @description Comma-separated list of related resources to include in the response. */ include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; }; diff --git a/packages/hydroserverpy/src/hydroserverpy/api/README.md b/packages/hydroserverpy/src/hydroserverpy/api/README.md index 53c25859..4676c1f0 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/README.md +++ b/packages/hydroserverpy/src/hydroserverpy/api/README.md @@ -588,7 +588,6 @@ new_datastream = hs_api.datastreams.create( time_aggregation_interval=1, status='Ongoing', result_type='Timeseries', - phenomenon_end_time=None, is_visible=True, is_private=False, monitoring_site='00000000-0000-0000-0000-000000000000', diff --git a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py index e04201c1..29062202 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/models/sta/datastream.py @@ -52,7 +52,7 @@ class Datastream(HydroServerBaseModel): _editable_fields: ClassVar[set[str]] = { "name", "description", "observation_type", "sampled_medium", "no_data_value", "aggregation_statistic", - "time_aggregation_interval", "status", "result_type", "phenomenon_end_time", "is_private", "is_visible", + "time_aggregation_interval", "status", "result_type", "is_private", "is_visible", "time_aggregation_interval_unit", "intended_time_spacing", "intended_time_spacing_unit", "monitoring_site_id", "method_id", "observed_property_id", "processing_level_id", "unit_id" } @@ -214,28 +214,13 @@ def delete_observations( phenomenon_time_end=phenomenon_time_end, ) - # TODO: Find a better long-term solution for this issue. def sync_phenomenon_end_time(self): - """Ensures the phenomenon_end_time field matches the actual end time of the observations.""" - - path = f"/{self.client.base_route}/collections/observations/items" - response = self.client.request( - "get", - path, - params={ - "datastream_id": str(self.uid), - "limit": 1, - "sortby": "-phenomenonTime", - }, - ).json() - - response = response["data"] - if len(response) > 0: - self.phenomenon_end_time = datetime.fromisoformat(response[0]["phenomenonTime"]) - else: - self.phenomenon_end_time = None - - self.save() + """ + Reloads phenomenon_end_time and the datastream's other observation statistics, which + HydroServer maintains whenever observations are written. + """ + + self.refresh() @field_validator("linked_resources", mode="before") def transform_linked_resources(cls, v): diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py index 2236e6d7..e63255b7 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py @@ -110,7 +110,6 @@ def create( Literal["seconds", "minutes", "hours", "days"] ] = None, status: Optional[str] = None, - phenomenon_end_time: Optional[datetime] = None, is_private: bool = False, is_visible: bool = True, tags: Optional[Dict[str, str]] = None, @@ -137,7 +136,6 @@ def create( "intendedTimeSpacing": intended_time_spacing, "intendedTimeSpacingUnit": intended_time_spacing_unit, "status": status, - "phenomenonEndTime": phenomenon_end_time, "isPrivate": is_private, "isVisible": is_visible, "tags": tags or {}, @@ -174,7 +172,6 @@ def update( Literal["seconds", "minutes", "hours", "days"] ] = ..., status: Optional[str] = ..., - phenomenon_end_time: Optional[datetime] = ..., is_private: bool = ..., is_visible: bool = ..., tags: Dict[str, Optional[str]] = ..., @@ -203,7 +200,6 @@ def update( "intendedTimeSpacing": intended_time_spacing, "intendedTimeSpacingUnit": intended_time_spacing_unit, "status": status, - "phenomenonEndTime": phenomenon_end_time, "isPrivate": is_private, "isVisible": is_visible, "tags": tags, diff --git a/packages/hydroserverpy/tests/api/test_datastream_datetime.py b/packages/hydroserverpy/tests/api/test_datastream_datetime.py index 1e780c7a..809e4642 100644 --- a/packages/hydroserverpy/tests/api/test_datastream_datetime.py +++ b/packages/hydroserverpy/tests/api/test_datastream_datetime.py @@ -123,7 +123,7 @@ def test_get_observations_sends_a_datetime_interval_and_pages_with_the_same_boun # --- server-maintained statistics ------------------------------------------------------- -STATISTIC_KEYS = {"valueCount", "phenomenonBeginTime", "resultBeginTime", "resultEndTime"} +STATISTIC_KEYS = {"valueCount", "phenomenonBeginTime", "phenomenonEndTime", "resultBeginTime", "resultEndTime"} def _datastream_payload(**overrides): @@ -167,7 +167,7 @@ def test_create_does_not_send_server_maintained_statistics(): assert not set(json.loads(client.request.call_args_list[0].kwargs["data"])) & STATISTIC_KEYS -def test_save_sends_only_the_writable_phenomenon_end_time(): +def test_save_does_not_send_changed_server_maintained_statistics(): service, client = make_service({"data": _datastream_payload()}) client.datastreams = service datastream = service.get("00000000-0000-0000-0000-000000000001") @@ -176,5 +176,19 @@ def test_save_sends_only_the_writable_phenomenon_end_time(): datastream.phenomenon_begin_time = JAN_1 datastream.phenomenon_end_time = FEB_1 - assert datastream.unsaved_changes == {"phenomenon_end_time": FEB_1} + assert datastream.unsaved_changes == {} + + +def test_sync_phenomenon_end_time_reloads_it_from_hydroserver(): + service, client = make_service( + {"data": _datastream_payload()}, + {"data": _datastream_payload(phenomenonEndTime="2024-02-01T00:00:00+00:00")}, + ) + client.datastreams = service + datastream = service.get("00000000-0000-0000-0000-000000000001") + + datastream.sync_phenomenon_end_time() + + assert datastream.phenomenon_end_time == FEB_1 + assert client.request.call_args_list[1].args[0] == "get" From 95b8338437dee4d0b45be4726fd6b2697fcce3e7 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 18:42:06 -0700 Subject: [PATCH 11/25] Add datetime filter for workspaces and monitoring sites --- django/interfaces/api/service.py | 36 ++++- .../interfaces/api/services/iam/workspace.py | 5 + .../api/services/sta/monitoring_site.py | 5 + django/interfaces/api/views/iam/workspace.py | 1 + .../api/views/sta/monitoring_site.py | 1 + .../api/test_monitoring_site_views.py | 128 +++++++++++++++++- .../interfaces/api/test_workspace_views.py | 85 +++++++++++- 7 files changed, 258 insertions(+), 3 deletions(-) diff --git a/django/interfaces/api/service.py b/django/interfaces/api/service.py index ff2ad775..349acef3 100644 --- a/django/interfaces/api/service.py +++ b/django/interfaces/api/service.py @@ -8,10 +8,11 @@ from django.contrib.postgres.search import SearchQuery, SearchRank from django.core.exceptions import EmptyResultSet from django.db import connection -from django.db.models import QuerySet, Model, Q, F +from django.db.models import QuerySet, Model, Q, F, Exists, OuterRef from core.iam.models import Workspace, ServiceAccount from core.iam.permissions.anonymous import AnonymousPrincipal +from core.sta.models import Datastream from interfaces.api.http.errors import BadRequestError, NotFoundError from interfaces.api.schemas.base import PaginationMeta from interfaces.api.schemas.extent import BoundingBox, TimeInterval @@ -181,6 +182,39 @@ def apply_datetime_interval( return queryset.filter(cls.datetime_interval_q(interval, start_field, end_field)) + @classmethod + def apply_visible_datastream_datetime( + cls, + principal: User | ServiceAccount | AnonymousPrincipal, + queryset, + interval: Optional[TimeInterval], + datastream_field: str, + ): + """ + Filters items by the observed time of the datastreams the principal can view, where + datastream_field links a datastream to the item's pk. An item matches when one of those + datastreams overlaps the interval, or when none of them has observations, since an item + without a time matches any datetime (OGC API - Features Core Req 26C). Datastreams the + principal can't view never affect the result. + """ + + if interval is None: + return queryset + + observed_datastreams = principal.filter_by_permission( + Datastream.objects.filter( + **{datastream_field: OuterRef("pk")}, phenomenon_begin_time__isnull=False + ), + "can_view", + ) + overlapping_datastreams = cls.apply_datetime_interval( + observed_datastreams, interval, "phenomenon_begin_time", "phenomenon_end_time" + ) + + return queryset.filter( + Exists(overlapping_datastreams) | ~Exists(observed_datastreams) + ) + @staticmethod def apply_sorting( queryset: QuerySet, diff --git a/django/interfaces/api/services/iam/workspace.py b/django/interfaces/api/services/iam/workspace.py index c7d43311..c1c369c6 100644 --- a/django/interfaces/api/services/iam/workspace.py +++ b/django/interfaces/api/services/iam/workspace.py @@ -12,6 +12,7 @@ from interfaces.api.http.errors import BadRequestError, ConflictError, PermissionDeniedError from interfaces.api.schemas import ( BoundingBox, + TimeInterval, WorkspacePostBody, WorkspacePatchBody, WorkspaceTransferBody, @@ -85,6 +86,7 @@ def list( filtering: Optional[dict] = None, include: Optional[list[str]] = None, bbox: Optional[BoundingBox] = None, + datetime_interval: Optional[TimeInterval] = None, ): requested_includes = self.resolve_include_set(include) queryset = Workspace.objects @@ -119,6 +121,9 @@ def list( queryset = self.apply_filters(queryset, field, filtering[field]) queryset = self.apply_visible_site_bbox(principal, queryset, bbox) + queryset = self.apply_visible_datastream_datetime( + principal, queryset, datetime_interval, "monitoring_site__workspace_id" + ) queryset, has_search = self.apply_search(queryset, filtering.get("q")) queryset = self.apply_sorting( queryset, diff --git a/django/interfaces/api/services/sta/monitoring_site.py b/django/interfaces/api/services/sta/monitoring_site.py index 524d02e2..412813ba 100644 --- a/django/interfaces/api/services/sta/monitoring_site.py +++ b/django/interfaces/api/services/sta/monitoring_site.py @@ -22,6 +22,7 @@ ) from interfaces.api.schemas import ( BoundingBox, + TimeInterval, MonitoringSiteResponse, MonitoringSitePostBody, MonitoringSitePatchBody, @@ -347,6 +348,7 @@ def list( filtering: Optional[dict] = None, include: Optional[list[str]] = None, bbox: Optional[BoundingBox] = None, + datetime_interval: Optional[TimeInterval] = None, ): requested_includes = self.resolve_include_set(include) queryset = MonitoringSite.objects @@ -371,6 +373,9 @@ def list( queryset = self.apply_filters(queryset, field, filtering[field]) queryset = self.apply_bbox(queryset, bbox) + queryset = self.apply_visible_datastream_datetime( + principal, queryset, datetime_interval, "monitoring_site_id" + ) queryset = self.apply_tag_filter(queryset, filtering.get("tag")) queryset, has_search = self.apply_search(queryset, filtering.get("q")) queryset = self.apply_sorting( diff --git a/django/interfaces/api/views/iam/workspace.py b/django/interfaces/api/views/iam/workspace.py index bf503419..f27094f7 100644 --- a/django/interfaces/api/views/iam/workspace.py +++ b/django/interfaces/api/views/iam/workspace.py @@ -50,6 +50,7 @@ def get_workspaces( filtering=query.dict(exclude_unset=True), include=query.include, bbox=query.bbox, + datetime_interval=query.datetime, ) diff --git a/django/interfaces/api/views/sta/monitoring_site.py b/django/interfaces/api/views/sta/monitoring_site.py index af7acac9..d0603966 100644 --- a/django/interfaces/api/views/sta/monitoring_site.py +++ b/django/interfaces/api/views/sta/monitoring_site.py @@ -57,6 +57,7 @@ def get_monitoring_sites( limit=query.limit, sortby=query.sortby, bbox=query.bbox, + datetime_interval=query.datetime, filtering=query.dict(exclude_unset=True), include=query.include, ) diff --git a/django/tests/interfaces/api/test_monitoring_site_views.py b/django/tests/interfaces/api/test_monitoring_site_views.py index cbac94de..4f852204 100644 --- a/django/tests/interfaces/api/test_monitoring_site_views.py +++ b/django/tests/interfaces/api/test_monitoring_site_views.py @@ -11,7 +11,7 @@ UserFactory, WorkspaceFactory, ) -from tests.core.sta.factories import MonitoringSiteFactory, MonitoringSiteTypeFactory +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory, MonitoringSiteTypeFactory pytestmark = pytest.mark.django_db @@ -135,6 +135,132 @@ def test_get_monitoring_sites_filters_by_a_bbox_crossing_the_antimeridian(client assert _site_ids(response) == {str(east.id), str(west.id)} +# --- datetime ---------------------------------------------------------------------------- +# +# A site's time is the observed time of each of its datastreams the requester can view. It +# matches a datetime when one of those datastreams overlaps it, or when none of them has +# observations (no time matches any datetime, per OGC API - Features Core Req 26C). + + +def _observed_datastream(monitoring_site, begin, end, **kwargs): + return DatastreamFactory( + monitoring_site=monitoring_site, + phenomenon_begin_time=begin, + phenomenon_end_time=end, + **kwargs, + ) + + +def test_get_monitoring_sites_datetime_matches_sites_with_an_overlapping_datastream(client): + overlapping = MonitoringSiteFactory() + _observed_datastream(overlapping, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z") + touching = MonitoringSiteFactory() + _observed_datastream(touching, "2024-03-01T00:00:00Z", "2024-04-01T00:00:00Z") + before = MonitoringSiteFactory() + _observed_datastream(before, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z") + + response = client.get( + MONITORING_SITES_URL, {"datetime": "2024-02-01T00:00:00Z/2024-03-01T00:00:00Z"} + ) + + assert response.status_code == 200 + assert _site_ids(response) == {str(overlapping.id), str(touching.id)} + + +def test_get_monitoring_sites_datetime_requires_one_datastream_to_overlap(client): + # Neither datastream has observations in March, even though the site's overall span does. + site = MonitoringSiteFactory() + _observed_datastream(site, "2024-01-01T00:00:00Z", "2024-02-01T00:00:00Z") + _observed_datastream(site, "2024-06-01T00:00:00Z", "2024-07-01T00:00:00Z") + + march = client.get(MONITORING_SITES_URL, {"datetime": "2024-03-01T00:00:00Z/2024-03-31T00:00:00Z"}) + june = client.get(MONITORING_SITES_URL, {"datetime": "2024-06-15T00:00:00Z"}) + + assert str(site.id) not in _site_ids(march) + assert str(site.id) in _site_ids(june) + + +@pytest.mark.parametrize( + "datetime_value, expected", + [ + ("2024-06-01T00:00:00Z/..", {"later"}), + ("../2024-01-31T00:00:00Z", {"earlier"}), + ("2024-06-01T00:00:00Z/", {"later"}), + ], +) +def test_get_monitoring_sites_datetime_accepts_half_bounded_intervals(client, datetime_value, expected): + sites = { + "earlier": MonitoringSiteFactory(), + "later": MonitoringSiteFactory(), + } + _observed_datastream(sites["earlier"], "2024-01-01T00:00:00Z", "2024-02-01T00:00:00Z") + _observed_datastream(sites["later"], "2024-06-01T00:00:00Z", "2024-07-01T00:00:00Z") + + response = client.get(MONITORING_SITES_URL, {"datetime": datetime_value}) + + assert response.status_code == 200 + assert _site_ids(response) == {str(sites[name].id) for name in expected} + + +def test_get_monitoring_sites_datetime_matches_sites_without_observed_datastreams(client): + without_datastreams = MonitoringSiteFactory() + without_observations = MonitoringSiteFactory() + DatastreamFactory(monitoring_site=without_observations) + + response = client.get(MONITORING_SITES_URL, {"datetime": "2024-02-01T00:00:00Z"}) + + assert _site_ids(response) == {str(without_datastreams.id), str(without_observations.id)} + + +def test_get_monitoring_sites_datetime_ignores_private_datastreams_the_requester_cannot_view(client): + # A private datastream that overlaps must not make the site match... + hidden_overlap = MonitoringSiteFactory() + _observed_datastream(hidden_overlap, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z") + _observed_datastream(hidden_overlap, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z", private=True) + # ...and a site whose only observed datastream is private has no visible time, so it matches. + only_private = MonitoringSiteFactory() + _observed_datastream(only_private, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z", private=True) + + response = client.get(MONITORING_SITES_URL, {"datetime": "2024-02-01T00:00:00Z"}) + + assert str(hidden_overlap.id) not in _site_ids(response) + assert str(only_private.id) in _site_ids(response) + + +def test_get_monitoring_sites_datetime_uses_private_datastreams_the_owner_can_view(client): + owner = UserFactory() + site = MonitoringSiteFactory(workspace=WorkspaceFactory(owner=owner)) + _observed_datastream(site, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z", private=True) + client.force_login(owner) + + overlapping = client.get(MONITORING_SITES_URL, {"datetime": "2024-02-01T00:00:00Z"}) + elsewhere = client.get(MONITORING_SITES_URL, {"datetime": "2020-01-01T00:00:00Z"}) + + assert str(site.id) in _site_ids(overlapping) + assert str(site.id) not in _site_ids(elsewhere) + + +def test_get_monitoring_sites_datetime_combines_with_bbox(client): + inside = MonitoringSiteFactory(longitude=-111.5, latitude=40.5) + _observed_datastream(inside, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z") + inside_elsewhen = MonitoringSiteFactory(longitude=-111.5, latitude=40.5) + _observed_datastream(inside_elsewhen, "2020-01-01T00:00:00Z", "2020-03-01T00:00:00Z") + outside = MonitoringSiteFactory(longitude=-100, latitude=40.5) + _observed_datastream(outside, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z") + + response = client.get( + MONITORING_SITES_URL, {"bbox": "-112,40,-111,41", "datetime": "2024-02-01T00:00:00Z"} + ) + + assert _site_ids(response) == {str(inside.id)} + + +def test_get_monitoring_sites_returns_400_for_invalid_datetime(client): + response = client.get(MONITORING_SITES_URL, {"datetime": "2024-02-01"}) + + assert response.status_code == 400 + + def test_get_monitoring_sites_returns_400_for_malformed_tag(client): response = client.get(MONITORING_SITES_URL, {"tag": "no-colon-in-here"}) diff --git a/django/tests/interfaces/api/test_workspace_views.py b/django/tests/interfaces/api/test_workspace_views.py index 705304d6..e131d3e9 100644 --- a/django/tests/interfaces/api/test_workspace_views.py +++ b/django/tests/interfaces/api/test_workspace_views.py @@ -10,7 +10,7 @@ UserFactory, WorkspaceFactory, ) -from tests.core.sta.factories import MonitoringSiteFactory +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory pytestmark = pytest.mark.django_db @@ -725,3 +725,86 @@ def test_get_workspaces_returns_400_for_invalid_bbox(client): response = client.get(WORKSPACES_URL, {"bbox": "-112,40,-111"}) assert response.status_code == 400 + + +# --- datetime ---------------------------------------------------------------------------- +# +# A workspace's time is the observed time of each datastream the requester can view across its +# sites. It matches a datetime when one of those datastreams overlaps it, or when none of them +# has observations (no time matches any datetime, per OGC API - Features Core Req 26C). + + +def _observed_datastream(workspace, begin, end, **kwargs): + return DatastreamFactory( + monitoring_site=MonitoringSiteFactory(workspace=workspace), + phenomenon_begin_time=begin, + phenomenon_end_time=end, + **kwargs, + ) + + +def test_get_workspaces_datetime_matches_workspaces_with_an_overlapping_datastream_on_any_site(client): + overlapping = WorkspaceFactory() + _observed_datastream(overlapping, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z") + _observed_datastream(overlapping, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z") + before = WorkspaceFactory() + _observed_datastream(before, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z") + + response = client.get(WORKSPACES_URL, {"datetime": "2024-02-01T00:00:00Z/.."}) + + assert response.status_code == 200 + assert str(overlapping.id) in _workspace_ids(response) + assert str(before.id) not in _workspace_ids(response) + + +def test_get_workspaces_datetime_requires_one_datastream_to_overlap(client): + workspace = WorkspaceFactory() + _observed_datastream(workspace, "2024-01-01T00:00:00Z", "2024-02-01T00:00:00Z") + _observed_datastream(workspace, "2024-06-01T00:00:00Z", "2024-07-01T00:00:00Z") + + response = client.get(WORKSPACES_URL, {"datetime": "2024-03-01T00:00:00Z/2024-03-31T00:00:00Z"}) + + assert str(workspace.id) not in _workspace_ids(response) + + +def test_get_workspaces_datetime_matches_workspaces_without_observed_datastreams(client): + empty = WorkspaceFactory() + without_observations = WorkspaceFactory() + DatastreamFactory(monitoring_site=MonitoringSiteFactory(workspace=without_observations)) + + response = client.get(WORKSPACES_URL, {"datetime": "2024-02-01T00:00:00Z"}) + + assert str(empty.id) in _workspace_ids(response) + assert str(without_observations.id) in _workspace_ids(response) + + +def test_get_workspaces_datetime_ignores_private_datastreams_the_requester_cannot_view(client): + hidden_overlap = WorkspaceFactory() + _observed_datastream(hidden_overlap, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z") + _observed_datastream(hidden_overlap, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z", private=True) + only_private = WorkspaceFactory() + _observed_datastream(only_private, "2023-01-01T00:00:00Z", "2023-02-01T00:00:00Z", private=True) + + response = client.get(WORKSPACES_URL, {"datetime": "2024-02-01T00:00:00Z"}) + + assert str(hidden_overlap.id) not in _workspace_ids(response) + assert str(only_private.id) in _workspace_ids(response) + + +def test_get_workspaces_datetime_uses_private_datastreams_the_owner_can_view(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner, private=True) + _observed_datastream(workspace, "2024-01-01T00:00:00Z", "2024-03-01T00:00:00Z", private=True) + client.force_login(owner) + + overlapping = client.get(WORKSPACES_URL, {"datetime": "2024-02-01T00:00:00Z"}) + elsewhere = client.get(WORKSPACES_URL, {"datetime": "2020-01-01T00:00:00Z"}) + + assert str(workspace.id) in _workspace_ids(overlapping) + assert str(workspace.id) not in _workspace_ids(elsewhere) + + +def test_get_workspaces_returns_400_for_invalid_datetime(client): + response = client.get(WORKSPACES_URL, {"datetime": "2024-02-01T00:00:00"}) + + assert response.status_code == 400 From f986af9c20ffd0b9ab5c67d41b79a9a5c7956998 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 19:27:06 -0700 Subject: [PATCH 12/25] Clamp API limit param instead of returning an error --- django/contracts/openapi/ogc.openapi.json | 1126 +++++------------ django/interfaces/api/schemas/base.py | 20 +- django/interfaces/api/service.py | 6 +- django/tests/interfaces/api/test_limit.py | 100 ++ .../src/generated/ogc.types.d.ts | 248 ++-- 5 files changed, 591 insertions(+), 909 deletions(-) create mode 100644 django/tests/interfaces/api/test_limit.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index ab575e4a..0533cf7a 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -277,19 +277,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -697,19 +690,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -1295,19 +1281,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -1904,19 +1883,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -2608,19 +2580,12 @@ "title": "Is Private" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "method_id": { "default": [], @@ -3359,19 +3324,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -3738,19 +3696,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -4891,19 +4842,12 @@ "title": "Include" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -5296,19 +5240,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -5857,19 +5794,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -6670,19 +6600,12 @@ "title": "Is Private" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -7289,19 +7212,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -7933,19 +7849,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -8325,19 +8234,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -10193,19 +10095,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -10465,19 +10360,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "managed_datastream_id": { "default": [], @@ -10777,19 +10665,12 @@ "title": "Include" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -11055,19 +10936,12 @@ "type": "boolean" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -11470,19 +11344,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -11770,19 +11637,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -11961,19 +11821,12 @@ "title": "Include" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -12561,19 +12414,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -12786,19 +12632,12 @@ "title": "Include" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -13199,19 +13038,12 @@ "type": "array" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "monitoring_site_id": { "default": [], @@ -13827,19 +13659,12 @@ "title": "Include" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -14046,19 +13871,12 @@ "title": "Is Private" }, "limit": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" }, "offset": { "anyOf": [ @@ -14325,24 +14143,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -14851,24 +14662,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -15489,24 +15293,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -16189,24 +15986,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -16696,24 +16486,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -17380,24 +17163,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -17936,24 +17712,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -18808,24 +18577,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -19579,24 +19341,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -20210,24 +19965,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -20873,24 +20621,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -21615,24 +21356,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -22109,24 +21843,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -22631,24 +22358,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -23162,24 +22882,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -23879,24 +23592,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -24542,24 +24248,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -25080,24 +24779,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -25724,24 +25416,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -26729,24 +26414,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -27398,24 +27076,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -28066,24 +27737,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -28618,24 +28282,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -29288,24 +28945,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -29814,24 +29464,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -30472,24 +30115,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -30983,24 +30619,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -31790,24 +31419,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -32461,24 +32083,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -33029,24 +32644,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -33338,24 +32946,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -33860,24 +33461,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -34387,24 +33981,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -35051,24 +34638,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -35695,24 +35275,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { @@ -36061,24 +35634,17 @@ } }, { - "description": "The maximum number of items to return.", + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", "in": "query", "name": "limit", "required": false, "schema": { - "anyOf": [ - { - "maximum": 100000, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], "default": 100, - "description": "The maximum number of items to return.", - "title": "Limit" + "description": "The maximum number of items to return. (default: 100, maximum: 100000)", + "maximum": 100000, + "minimum": 0, + "title": "Limit", + "type": "integer" } }, { diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index ffea6261..dacbea6e 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -5,16 +5,20 @@ from ninja import Schema, Query from pydantic.alias_generators import to_camel from pydantic import ( + AfterValidator, AliasGenerator, AliasChoices, ConfigDict, field_validator, model_serializer, SerializationInfo, + WithJsonSchema, ) from core.types import Unset +MAX_LIMIT = 100000 + base_alias_generator = AliasGenerator( serialization_alias=lambda field_name: to_camel(field_name), validation_alias=lambda field_name: AliasChoices(to_camel(field_name), field_name), @@ -108,6 +112,12 @@ def convert_null_strings(value: Any) -> Any: # noqa return value +def clamp_limit(value: Optional[int]) -> Optional[int]: + """Caps a page size at MAX_LIMIT.""" + + return value if value is None else min(value, MAX_LIMIT) + + class CollectionQueryParameters(BaseQueryParameters): properties: Optional[str] = Query( None, @@ -119,8 +129,14 @@ class CollectionQueryParameters(BaseQueryParameters): description="Comma-separated list of related resources to include in the response.", ) offset: Optional[int] = Query(0, ge=0, description="Number of items to skip.") - limit: Optional[int] = Query( - 100, ge=0, le=100000, description="The maximum number of items to return." + limit: Annotated[ + Optional[int], + AfterValidator(clamp_limit), + WithJsonSchema({"type": "integer", "minimum": 0, "maximum": MAX_LIMIT}), + ] = Query( + 100, + ge=0, + description=f"The maximum number of items to return. (default: 100, maximum: {MAX_LIMIT})", ) diff --git a/django/interfaces/api/service.py b/django/interfaces/api/service.py index 349acef3..b8fe52f7 100644 --- a/django/interfaces/api/service.py +++ b/django/interfaces/api/service.py @@ -14,7 +14,7 @@ from core.iam.permissions.anonymous import AnonymousPrincipal from core.sta.models import Datastream from interfaces.api.http.errors import BadRequestError, NotFoundError -from interfaces.api.schemas.base import PaginationMeta +from interfaces.api.schemas.base import PaginationMeta, clamp_limit from interfaces.api.schemas.extent import BoundingBox, TimeInterval User = get_user_model() @@ -432,8 +432,8 @@ def apply_pagination( raise BadRequestError("Offset must be >= 0.") if limit < 0: raise BadRequestError("Limit must be >= 0.") - if limit > 100000: - raise BadRequestError("Limit must be <= 100000.") + + limit = clamp_limit(limit) if count is None: count = queryset.count() diff --git a/django/tests/interfaces/api/test_limit.py b/django/tests/interfaces/api/test_limit.py new file mode 100644 index 00000000..c91ad985 --- /dev/null +++ b/django/tests/interfaces/api/test_limit.py @@ -0,0 +1,100 @@ +import pytest + +from interfaces.api.http.errors import BadRequestError +from interfaces.api.schemas import base +from interfaces.api.service import APIService +from interfaces.api.urls import api +from processing.orchestration.models import TaskRun +from core.iam.models import Workspace +from tests.core.iam.factories import UserFactory, WorkspaceFactory +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory, ObservationFactory +from tests.processing.etl.factories import DataConnectionFactory, EtlTaskFactory, PayloadFactory + +pytestmark = pytest.mark.django_db + +# A limit above the maximum isn't an error; the maximum is used instead (OGC API - Features +# Core Req 22C). Tests that count returned items lower the maximum so they don't need 100k rows. + +MONITORING_SITES_URL = "/api/ogc/collections/monitoring-sites/items" + + +@pytest.fixture +def max_limit_of_two(monkeypatch): + monkeypatch.setattr(base, "MAX_LIMIT", 2) + + +def test_get_items_uses_the_maximum_when_limit_is_larger(client): + response = client.get(MONITORING_SITES_URL, {"limit": 200000}) + + assert response.status_code == 200 + assert response.json()["meta"]["limit"] == 100000 + + +def test_get_items_returns_at_most_the_maximum_number_of_items(client, max_limit_of_two): + MonitoringSiteFactory.create_batch(3) + + response = client.get(MONITORING_SITES_URL, {"limit": 5}) + + assert response.status_code == 200 + assert len(response.json()["data"]) == 2 + assert response.json()["meta"]["limit"] == 2 + + +def test_get_observations_row_format_uses_the_maximum(client, max_limit_of_two): + datastream = DatastreamFactory() + ObservationFactory.create_batch(3, datastream=datastream) + + response = client.get( + "/api/ogc/collections/observations/items", + {"datastream_id": str(datastream.id), "format": "row", "limit": 5}, + ) + + assert response.status_code == 200 + assert len(response.json()["data"]["rows"]) == 2 + + +def test_get_task_runs_uses_the_maximum(client, max_limit_of_two): + owner = UserFactory() + data_connection = DataConnectionFactory(workspace=WorkspaceFactory(owner=owner)) + PayloadFactory(data_connection=data_connection) + task = EtlTaskFactory(data_connection=data_connection) + for _ in range(3): + TaskRun.objects.create(task=task, status="SUCCESS") + client.force_login(owner) + + response = client.get(f"/api/ogc/collections/etl-tasks/items/{task.id}/runs", {"limit": 5}) + + assert response.status_code == 200 + assert len(response.json()["data"]) == 2 + assert response.json()["meta"]["limit"] == 2 + + +def test_get_items_returns_400_for_a_negative_limit(client): + response = client.get(MONITORING_SITES_URL, {"limit": -1}) + + assert response.status_code == 400 + + +def test_limit_declares_the_maximum_in_the_openapi_document(): + parameters = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"][MONITORING_SITES_URL]["get"][ + "parameters" + ] + limit = next(p for p in parameters if p["name"] == "limit") + + assert limit["schema"]["type"] == "integer" + assert limit["schema"]["minimum"] == 0 + assert limit["schema"]["maximum"] == 100000 + + +def test_apply_pagination_uses_the_maximum_when_limit_is_larger(max_limit_of_two): + WorkspaceFactory.create_batch(3) + + page, meta = APIService.apply_pagination(Workspace.objects.order_by("pk"), limit=5) + + assert len(page) == 2 + assert meta.limit == 2 + + +def test_apply_pagination_rejects_a_negative_limit(): + with pytest.raises(BadRequestError): + APIService.apply_pagination(Workspace.objects.all(), limit=-1) diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index a79364b1..75f938b4 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2230,10 +2230,10 @@ export interface components { include?: ("role" | "user" | "serviceAccount")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -2359,10 +2359,10 @@ export interface components { include?: "workspace"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -2537,10 +2537,10 @@ export interface components { latest_run_status: (string | "null")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter data product tasks by monitoring_site ID. @@ -2736,10 +2736,10 @@ export interface components { input_datastream_id: string[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -3004,10 +3004,10 @@ export interface components { is_private?: boolean | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Method Id * @description Filter datastreams by method ID. @@ -3320,10 +3320,10 @@ export interface components { include?: "targetDatastream"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -3476,10 +3476,10 @@ export interface components { latest_run_status: (string | "null")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter ETL tasks by monitoring_site ID. @@ -3846,10 +3846,10 @@ export interface components { include?: string | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -3976,10 +3976,10 @@ export interface components { include?: ("workspace" | "type")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter methods by monitoring_site ID. @@ -4164,10 +4164,10 @@ export interface components { include?: "datastream"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -4446,10 +4446,10 @@ export interface components { is_private?: boolean | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -4692,10 +4692,10 @@ export interface components { latest_run_status: (string | "null")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter monitoring tasks by monitoring_site ID. @@ -4915,10 +4915,10 @@ export interface components { include?: ("datastream" | "workspace" | "resultQualifiers")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -5051,10 +5051,10 @@ export interface components { include?: ("workspace" | "type")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter observed properties by monitoring_site ID. @@ -5605,10 +5605,10 @@ export interface components { include?: "workspace"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter processing levels by monitoring_site ID. @@ -5701,10 +5701,10 @@ export interface components { include?: ("managedDatastream" | "sourceDatastream")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Managed Datastream Id * @description Filter histories by managed datastream ID. @@ -5810,10 +5810,10 @@ export interface components { include?: string | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -5901,10 +5901,10 @@ export interface components { include_ancestors: boolean; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -6049,10 +6049,10 @@ export interface components { include?: "monitoringSite"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter rating curves by monitoring site ID. @@ -6165,10 +6165,10 @@ export interface components { include?: "workspace"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -6236,10 +6236,10 @@ export interface components { include?: string | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -6421,10 +6421,10 @@ export interface components { include?: "workspace"[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -6499,10 +6499,10 @@ export interface components { include?: string | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -6641,10 +6641,10 @@ export interface components { include?: ("workspace" | "type")[]; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Monitoring Site Id * @description Filter units by monitoring_site ID. @@ -6880,10 +6880,10 @@ export interface components { include?: string | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -6959,10 +6959,10 @@ export interface components { is_private?: boolean | null; /** * Limit - * @description The maximum number of items to return. + * @description The maximum number of items to return. (default: 100, maximum: 100000) * @default 100 */ - limit: number | null; + limit: number; /** * Offset * @description Number of items to skip. @@ -7034,8 +7034,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -7263,8 +7263,8 @@ export interface operations { include?: "monitoringSite"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "fittingMethod" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-fittingMethod")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -7534,8 +7534,8 @@ export interface operations { include?: "monitoringSite"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -7811,8 +7811,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; /** @description Filters task runs by their status. */ @@ -7984,8 +7984,8 @@ export interface operations { include?: ("outputDatastream" | "ratingCurve")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "outputDatastreamId" | "transformationType" | "-id" | "-outputDatastreamId" | "-transformationType")[] | null; /** @description Filter transformations by type. */ @@ -8277,8 +8277,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -8506,8 +8506,8 @@ export interface operations { include?: ("workspace" | "monitoringSite" | "method" | "observedProperty" | "processingLevel" | "unit" | "sampledMedium" | "aggregationStatistic" | "status")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "observationType" | "sampledMedium" | "status" | "resultType" | "isPrivate" | "isVisible" | "aggregationStatistic" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "-name" | "-observationType" | "-sampledMedium" | "-status" | "-resultType" | "-isPrivate" | "-isVisible" | "-aggregationStatistic" | "-valueCount" | "-phenomenonBeginTime" | "-phenomenonEndTime" | "-resultBeginTime" | "-resultEndTime")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -8801,8 +8801,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Filter by linked resource type. */ type?: string[]; }; @@ -9124,8 +9124,8 @@ export interface operations { include?: "workspace"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "name" | "timestampKey" | "timestampFormat" | "timezoneType" | "timezone" | "workspaceId" | "workspaceName" | "-id" | "-name" | "-timestampKey" | "-timestampFormat" | "-timezoneType" | "-timezone" | "-workspaceId" | "-workspaceName")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -9386,8 +9386,8 @@ export interface operations { include?: "targetDatastream"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "sourceIdentifier" | "targetDatastreamId" | "-id" | "-sourceIdentifier" | "-targetDatastreamId")[] | null; /** @description Filter mappings by source identifier. */ @@ -9677,8 +9677,8 @@ export interface operations { include?: "dataConnection"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "name" | "dataConnectionId" | "dataConnectionName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-dataConnectionId" | "-dataConnectionName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -9956,8 +9956,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; /** @description Filters task runs by their status. */ @@ -10129,8 +10129,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -10358,8 +10358,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -10587,8 +10587,8 @@ export interface operations { include?: ("workspace" | "type")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "code" | "type" | "sensorModel" | "sensorModelManufacturer" | "definition" | "sensorModelDefinition" | "-name" | "-code" | "-type" | "-sensorModel" | "-sensorModelManufacturer" | "-definition" | "-sensorModelDefinition")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -10857,8 +10857,8 @@ export interface operations { include?: "datastream"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "ruleType" | "datastreamId" | "lastCheckedAt" | "-id" | "-ruleType" | "-datastreamId" | "-lastCheckedAt")[] | null; /** @description Filter rules by datastream ID. */ @@ -11148,8 +11148,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -11377,8 +11377,8 @@ export interface operations { include?: ("workspace" | "type")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "code" | "type" | "isPrivate" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "-name" | "-code" | "-type" | "-isPrivate" | "-latitude" | "-longitude" | "-elevation_m" | "-elevationDatum" | "-adminArea1" | "-adminArea2" | "-country")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -11609,8 +11609,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Filter by linked resource type. */ type?: string[]; }; @@ -12024,8 +12024,8 @@ export interface operations { include?: "monitoringSite"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -12297,8 +12297,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; /** @description Filters task runs by their status. */ @@ -12562,8 +12562,8 @@ export interface operations { include?: ("datastream" | "workspace" | "resultQualifiers")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Filter observations by datastream ID. */ datastream_id?: string[]; /** @description Select one or more fields to sort the response by. */ @@ -12773,8 +12773,8 @@ export interface operations { include?: ("workspace" | "type")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "type" | "code" | "-name" | "-type" | "-code")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -13039,8 +13039,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -13268,8 +13268,8 @@ export interface operations { include?: "workspace"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("code" | "name" | "-code" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -13532,8 +13532,8 @@ export interface operations { include?: ("managedDatastream" | "sourceDatastream")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd")[] | null; /** @description Filter histories by managed datastream ID. */ @@ -13746,8 +13746,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "status" | "committedAt" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "-status" | "-committedAt")[] | null; status?: ("in_progress" | "committed") | null; @@ -14096,8 +14096,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("id" | "order" | "operationType" | "createdAt" | "-id" | "-order" | "-operationType" | "-createdAt")[] | null; }; @@ -14389,8 +14389,8 @@ export interface operations { include?: "workspace"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -14622,8 +14622,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Filter roles by workspace ID. */ @@ -14711,8 +14711,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -14940,8 +14940,8 @@ export interface operations { include?: string | null; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "-name")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -15169,8 +15169,8 @@ export interface operations { include?: ("workspace" | "type")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "symbol" | "type" | "-name" | "-symbol" | "-type")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -15426,8 +15426,8 @@ export interface operations { include?: ("owner" | "pendingTransferTo" | "collaboratorRole")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "isPrivate" | "-name" | "-isPrivate")[] | null; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ @@ -15666,8 +15666,8 @@ export interface operations { include?: ("role" | "user" | "serviceAccount")[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Filter collaborators by role ID. */ role_id?: string[]; /** @description Select one or more fields to sort the response by. */ @@ -15874,8 +15874,8 @@ export interface operations { include?: "workspace"[]; /** @description Number of items to skip. */ offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; + /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ + limit?: number; /** @description Select one or more fields to sort the response by. */ sortby?: ("name" | "isActive" | "keyExpiresAt" | "createdAt" | "lastUsedAt" | "-name" | "-isActive" | "-keyExpiresAt" | "-createdAt" | "-lastUsedAt")[] | null; }; From 3709aad824cc3aded4ad5327cbc08a06fef20cd0 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 19:44:50 -0700 Subject: [PATCH 13/25] Fix sortby to allow comma-separated fields --- django/contracts/openapi/ogc.openapi.json | 2540 ++++++++--------- django/interfaces/api/schemas/__init__.py | 3 +- django/interfaces/api/schemas/base.py | 34 + .../api/schemas/etl/data_connection.py | 10 +- django/interfaces/api/schemas/etl/mapping.py | 10 +- django/interfaces/api/schemas/etl/task.py | 10 +- .../api/schemas/iam/collaborator.py | 10 +- django/interfaces/api/schemas/iam/role.py | 10 +- .../api/schemas/iam/service_account.py | 10 +- .../interfaces/api/schemas/iam/workspace.py | 10 +- .../interfaces/api/schemas/monitoring/rule.py | 10 +- .../interfaces/api/schemas/monitoring/task.py | 10 +- .../api/schemas/orchestration/run.py | 10 +- .../api/schemas/products/rating_curve.py | 10 +- .../interfaces/api/schemas/products/task.py | 10 +- .../api/schemas/products/transformation.py | 10 +- .../interfaces/api/schemas/quality/history.py | 10 +- .../api/schemas/quality/operation.py | 10 +- .../interfaces/api/schemas/quality/session.py | 10 +- .../interfaces/api/schemas/sta/datastream.py | 10 +- django/interfaces/api/schemas/sta/method.py | 10 +- .../api/schemas/sta/monitoring_site.py | 10 +- .../interfaces/api/schemas/sta/observation.py | 10 +- .../api/schemas/sta/observed_property.py | 10 +- .../api/schemas/sta/processing_level.py | 10 +- .../api/schemas/sta/result_qualifier.py | 10 +- django/interfaces/api/schemas/sta/unit.py | 10 +- .../interfaces/api/schemas/sta/vocabulary.py | 10 +- django/tests/interfaces/api/test_sortby.py | 101 + .../src/generated/ogc.types.d.ts | 118 +- 30 files changed, 1613 insertions(+), 1433 deletions(-) create mode 100644 django/tests/interfaces/api/test_sortby.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 0533cf7a..21e47e59 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -322,26 +322,21 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "roleId", - "-id", - "-roleId" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "roleId", + "-id", + "-roleId", + "+id", + "+roleId" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" } }, "title": "CollaboratorQueryParameters", @@ -757,38 +752,39 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "timestampKey", - "timestampFormat", - "timezoneType", - "timezone", - "workspaceId", - "workspaceName", - "-id", - "-name", - "-timestampKey", - "-timestampFormat", - "-timezoneType", - "-timezone", - "-workspaceId", - "-workspaceName" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "name", + "timestampKey", + "timestampFormat", + "timezoneType", + "timezone", + "workspaceId", + "workspaceName", + "-id", + "-name", + "-timestampKey", + "-timestampFormat", + "-timezoneType", + "-timezone", + "-workspaceId", + "-workspaceName", + "+id", + "+name", + "+timestampKey", + "+timestampFormat", + "+timezoneType", + "+timezone", + "+workspaceId", + "+workspaceName" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -1363,40 +1359,42 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "monitoringSiteId", - "monitoringSiteName", - "workspaceId", - "workspaceName", - "latestRunStatus", - "latestRunStartedAt", - "latestRunFinishedAt", - "-id", - "-name", - "-monitoringSiteId", - "-monitoringSiteName", - "-workspaceId", - "-workspaceName", - "-latestRunStatus", - "-latestRunStartedAt", - "-latestRunFinishedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "name", + "monitoringSiteId", + "monitoringSiteName", + "workspaceId", + "workspaceName", + "latestRunStatus", + "latestRunStartedAt", + "latestRunFinishedAt", + "-id", + "-name", + "-monitoringSiteId", + "-monitoringSiteName", + "-workspaceId", + "-workspaceName", + "-latestRunStatus", + "-latestRunStartedAt", + "-latestRunFinishedAt", + "+id", + "+name", + "+monitoringSiteId", + "+monitoringSiteName", + "+workspaceId", + "+workspaceName", + "+latestRunStatus", + "+latestRunStartedAt", + "+latestRunFinishedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "transformation_type": { "default": [], @@ -1939,28 +1937,24 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "outputDatastreamId", - "transformationType", - "-id", - "-outputDatastreamId", - "-transformationType" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "outputDatastreamId", + "transformationType", + "-id", + "-outputDatastreamId", + "-transformationType", + "+id", + "+outputDatastreamId", + "+transformationType" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "task_id": { "default": [], @@ -2729,48 +2723,54 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "observationType", - "sampledMedium", - "status", - "resultType", - "isPrivate", - "isVisible", - "aggregationStatistic", - "valueCount", - "phenomenonBeginTime", - "phenomenonEndTime", - "resultBeginTime", - "resultEndTime", - "-name", - "-observationType", - "-sampledMedium", - "-status", - "-resultType", - "-isPrivate", - "-isVisible", - "-aggregationStatistic", - "-valueCount", - "-phenomenonBeginTime", - "-phenomenonEndTime", - "-resultBeginTime", - "-resultEndTime" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "observationType", + "sampledMedium", + "status", + "resultType", + "isPrivate", + "isVisible", + "aggregationStatistic", + "valueCount", + "phenomenonBeginTime", + "phenomenonEndTime", + "resultBeginTime", + "resultEndTime", + "-name", + "-observationType", + "-sampledMedium", + "-status", + "-resultType", + "-isPrivate", + "-isVisible", + "-aggregationStatistic", + "-valueCount", + "-phenomenonBeginTime", + "-phenomenonEndTime", + "-resultBeginTime", + "-resultEndTime", + "+name", + "+observationType", + "+sampledMedium", + "+status", + "+resultType", + "+isPrivate", + "+isVisible", + "+aggregationStatistic", + "+valueCount", + "+phenomenonBeginTime", + "+phenomenonEndTime", + "+resultBeginTime", + "+resultEndTime" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "status": { "default": [], @@ -3359,28 +3359,24 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "sourceIdentifier", - "targetDatastreamId", - "-id", - "-sourceIdentifier", - "-targetDatastreamId" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "sourceIdentifier", + "targetDatastreamId", + "-id", + "-sourceIdentifier", + "-targetDatastreamId", + "+id", + "+sourceIdentifier", + "+targetDatastreamId" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "source_identifier": { "default": [], @@ -3759,40 +3755,42 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "dataConnectionId", - "dataConnectionName", - "workspaceId", - "workspaceName", - "latestRunStatus", - "latestRunStartedAt", - "latestRunFinishedAt", - "-id", - "-name", - "-dataConnectionId", - "-dataConnectionName", - "-workspaceId", - "-workspaceName", - "-latestRunStatus", - "-latestRunStartedAt", - "-latestRunFinishedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "name", + "dataConnectionId", + "dataConnectionName", + "workspaceId", + "workspaceName", + "latestRunStatus", + "latestRunStartedAt", + "latestRunFinishedAt", + "-id", + "-name", + "-dataConnectionId", + "-dataConnectionName", + "-workspaceId", + "-workspaceName", + "-latestRunStatus", + "-latestRunStartedAt", + "-latestRunFinishedAt", + "+id", + "+name", + "+dataConnectionId", + "+dataConnectionName", + "+workspaceId", + "+workspaceName", + "+latestRunStatus", + "+latestRunStartedAt", + "+latestRunFinishedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -5330,36 +5328,36 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "code", - "type", - "sensorModel", - "sensorModelManufacturer", - "definition", - "sensorModelDefinition", - "-name", - "-code", - "-type", - "-sensorModel", - "-sensorModelManufacturer", - "-definition", - "-sensorModelDefinition" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "code", + "type", + "sensorModel", + "sensorModelManufacturer", + "definition", + "sensorModelDefinition", + "-name", + "-code", + "-type", + "-sensorModel", + "-sensorModelManufacturer", + "-definition", + "-sensorModelDefinition", + "+name", + "+code", + "+type", + "+sensorModel", + "+sensorModelManufacturer", + "+definition", + "+sensorModelDefinition" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "type": { "default": [], @@ -5843,30 +5841,27 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "ruleType", - "datastreamId", - "lastCheckedAt", - "-id", - "-ruleType", - "-datastreamId", - "-lastCheckedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "ruleType", + "datastreamId", + "lastCheckedAt", + "-id", + "-ruleType", + "-datastreamId", + "-lastCheckedAt", + "+id", + "+ruleType", + "+datastreamId", + "+lastCheckedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "task_id": { "default": [], @@ -6661,44 +6656,48 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "code", - "type", - "isPrivate", - "latitude", - "longitude", - "elevation_m", - "elevationDatum", - "adminArea1", - "adminArea2", - "country", - "-name", - "-code", - "-type", - "-isPrivate", - "-latitude", - "-longitude", - "-elevation_m", - "-elevationDatum", - "-adminArea1", - "-adminArea2", - "-country" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "code", + "type", + "isPrivate", + "latitude", + "longitude", + "elevation_m", + "elevationDatum", + "adminArea1", + "adminArea2", + "country", + "-name", + "-code", + "-type", + "-isPrivate", + "-latitude", + "-longitude", + "-elevation_m", + "-elevationDatum", + "-adminArea1", + "-adminArea2", + "-country", + "+name", + "+code", + "+type", + "+isPrivate", + "+latitude", + "+longitude", + "+elevation_m", + "+elevationDatum", + "+adminArea1", + "+adminArea2", + "+country" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "tag": { "default": [], @@ -7284,40 +7283,42 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "monitoringSiteId", - "monitoringSiteName", - "workspaceId", - "workspaceName", - "latestRunStatus", - "latestRunStartedAt", - "latestRunFinishedAt", - "-id", - "-name", - "-monitoringSiteId", - "-monitoringSiteName", - "-workspaceId", - "-workspaceName", - "-latestRunStatus", - "-latestRunStartedAt", - "-latestRunFinishedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "name", + "monitoringSiteId", + "monitoringSiteName", + "workspaceId", + "workspaceName", + "latestRunStatus", + "latestRunStartedAt", + "latestRunFinishedAt", + "-id", + "-name", + "-monitoringSiteId", + "-monitoringSiteName", + "-workspaceId", + "-workspaceName", + "-latestRunStatus", + "-latestRunStartedAt", + "-latestRunFinishedAt", + "+id", + "+name", + "+monitoringSiteId", + "+monitoringSiteName", + "+workspaceId", + "+workspaceName", + "+latestRunStatus", + "+latestRunStartedAt", + "+latestRunFinishedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -7896,26 +7897,21 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "phenomenonTime", - "datastreamId", - "-phenomenonTime", - "-datastreamId" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "phenomenonTime", + "datastreamId", + "-phenomenonTime", + "-datastreamId", + "+phenomenonTime", + "+datastreamId" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" } }, "title": "ObservationQueryParameters", @@ -8303,28 +8299,24 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "type", - "code", - "-name", - "-type", - "-code" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "type", + "code", + "-name", + "-type", + "-code", + "+name", + "+type", + "+code" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "type": { "default": [], @@ -10163,26 +10155,21 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "code", - "name", - "-code", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "code", + "name", + "-code", + "-name", + "+code", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -10410,30 +10397,27 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "createdAt", - "phenomenonTimeStart", - "phenomenonTimeEnd", - "-id", - "-createdAt", - "-phenomenonTimeStart", - "-phenomenonTimeEnd" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "createdAt", + "phenomenonTimeStart", + "phenomenonTimeEnd", + "-id", + "-createdAt", + "-phenomenonTimeStart", + "-phenomenonTimeEnd", + "+id", + "+createdAt", + "+phenomenonTimeStart", + "+phenomenonTimeEnd" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "source_datastream_id": { "default": [], @@ -10704,30 +10688,27 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "order", - "operationType", - "createdAt", - "-id", - "-order", - "-operationType", - "-createdAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "order", + "operationType", + "createdAt", + "-id", + "-order", + "-operationType", + "-createdAt", + "+id", + "+order", + "+operationType", + "+createdAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" } }, "title": "QualityControlOperationQueryParameters", @@ -11012,34 +10993,33 @@ "title": "Range Start" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "createdAt", - "phenomenonTimeStart", - "phenomenonTimeEnd", - "status", - "committedAt", - "-id", - "-createdAt", - "-phenomenonTimeStart", - "-phenomenonTimeEnd", - "-status", - "-committedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "createdAt", + "phenomenonTimeStart", + "phenomenonTimeEnd", + "status", + "committedAt", + "-id", + "-createdAt", + "-phenomenonTimeStart", + "-phenomenonTimeEnd", + "-status", + "-committedAt", + "+id", + "+createdAt", + "+phenomenonTimeStart", + "+phenomenonTimeEnd", + "+status", + "+committedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "status": { "anyOf": [ @@ -11404,36 +11384,36 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "monitoringSiteId", - "monitoringSiteName", - "workspaceId", - "workspaceName", - "fittingMethod", - "-id", - "-name", - "-monitoringSiteId", - "-monitoringSiteName", - "-workspaceId", - "-workspaceName", - "-fittingMethod" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "id", + "name", + "monitoringSiteId", + "monitoringSiteName", + "workspaceId", + "workspaceName", + "fittingMethod", + "-id", + "-name", + "-monitoringSiteId", + "-monitoringSiteName", + "-workspaceId", + "-workspaceName", + "-fittingMethod", + "+id", + "+name", + "+monitoringSiteId", + "+monitoringSiteName", + "+workspaceId", + "+workspaceName", + "+fittingMethod" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -11685,24 +11665,18 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -11858,24 +11832,18 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "workspace_id": { "default": [], @@ -12455,32 +12423,30 @@ "type": "array" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "isActive", - "keyExpiresAt", - "createdAt", - "lastUsedAt", - "-name", - "-isActive", - "-keyExpiresAt", - "-createdAt", - "-lastUsedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "isActive", + "keyExpiresAt", + "createdAt", + "lastUsedAt", + "-name", + "-isActive", + "-keyExpiresAt", + "-createdAt", + "-lastUsedAt", + "+name", + "+isActive", + "+keyExpiresAt", + "+createdAt", + "+lastUsedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" } }, "title": "ServiceAccountQueryParameters", @@ -12681,7 +12647,11 @@ "-id", "-status", "-startedAt", - "-finishedAt" + "-finishedAt", + "+id", + "+status", + "+startedAt", + "+finishedAt" ], "type": "string" }, @@ -13106,28 +13076,24 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "symbol", - "type", - "-name", - "-symbol", - "-type" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "symbol", + "type", + "-name", + "-symbol", + "-type", + "+name", + "+symbol", + "+type" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" }, "type": { "default": [], @@ -13706,24 +13672,18 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" } }, "title": "VocabularyQueryParameters", @@ -13921,26 +13881,21 @@ "title": "Q" }, "sortby": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "isPrivate", - "-name", - "-isPrivate" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" + "items": { + "enum": [ + "name", + "isPrivate", + "-name", + "-isPrivate", + "+name", + "+isPrivate" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" } }, "title": "WorkspaceQueryParameters", @@ -14158,29 +14113,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -14677,41 +14628,43 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "monitoringSiteId", - "monitoringSiteName", - "workspaceId", - "workspaceName", - "fittingMethod", - "-id", - "-name", - "-monitoringSiteId", - "-monitoringSiteName", - "-workspaceId", - "-workspaceName", - "-fittingMethod" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "name", + "monitoringSiteId", + "monitoringSiteName", + "workspaceId", + "workspaceName", + "fittingMethod", + "-id", + "-name", + "-monitoringSiteId", + "-monitoringSiteName", + "-workspaceId", + "-workspaceName", + "-fittingMethod", + "+id", + "+name", + "+monitoringSiteId", + "+monitoringSiteName", + "+workspaceId", + "+workspaceName", + "+fittingMethod" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -15308,45 +15261,49 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "monitoringSiteId", - "monitoringSiteName", - "workspaceId", - "workspaceName", - "latestRunStatus", - "latestRunStartedAt", - "latestRunFinishedAt", - "-id", - "-name", - "-monitoringSiteId", - "-monitoringSiteName", - "-workspaceId", - "-workspaceName", - "-latestRunStatus", - "-latestRunStartedAt", - "-latestRunFinishedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "name", + "monitoringSiteId", + "monitoringSiteName", + "workspaceId", + "workspaceName", + "latestRunStatus", + "latestRunStartedAt", + "latestRunFinishedAt", + "-id", + "-name", + "-monitoringSiteId", + "-monitoringSiteName", + "-workspaceId", + "-workspaceName", + "-latestRunStatus", + "-latestRunStartedAt", + "-latestRunFinishedAt", + "+id", + "+name", + "+monitoringSiteId", + "+monitoringSiteName", + "+workspaceId", + "+workspaceName", + "+latestRunStatus", + "+latestRunStartedAt", + "+latestRunFinishedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -16001,6 +15958,7 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, @@ -16016,13 +15974,18 @@ "-id", "-status", "-startedAt", - "-finishedAt" + "-finishedAt", + "+id", + "+status", + "+startedAt", + "+finishedAt" ], "type": "string" }, "title": "Sortby", "type": "array" - } + }, + "style": "form" }, { "description": "Filters task runs by their status.", @@ -16501,33 +16464,31 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "outputDatastreamId", - "transformationType", - "-id", - "-outputDatastreamId", - "-transformationType" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "outputDatastreamId", + "transformationType", + "-id", + "-outputDatastreamId", + "-transformationType", + "+id", + "+outputDatastreamId", + "+transformationType" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Filter transformations by type.", @@ -17178,29 +17139,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -17727,53 +17684,61 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "observationType", - "sampledMedium", - "status", - "resultType", - "isPrivate", - "isVisible", - "aggregationStatistic", - "valueCount", - "phenomenonBeginTime", - "phenomenonEndTime", - "resultBeginTime", - "resultEndTime", - "-name", - "-observationType", - "-sampledMedium", - "-status", - "-resultType", - "-isPrivate", - "-isVisible", - "-aggregationStatistic", - "-valueCount", - "-phenomenonBeginTime", - "-phenomenonEndTime", - "-resultBeginTime", - "-resultEndTime" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "observationType", + "sampledMedium", + "status", + "resultType", + "isPrivate", + "isVisible", + "aggregationStatistic", + "valueCount", + "phenomenonBeginTime", + "phenomenonEndTime", + "resultBeginTime", + "resultEndTime", + "-name", + "-observationType", + "-sampledMedium", + "-status", + "-resultType", + "-isPrivate", + "-isVisible", + "-aggregationStatistic", + "-valueCount", + "-phenomenonBeginTime", + "-phenomenonEndTime", + "-resultBeginTime", + "-resultEndTime", + "+name", + "+observationType", + "+sampledMedium", + "+status", + "+resultType", + "+isPrivate", + "+isVisible", + "+aggregationStatistic", + "+valueCount", + "+phenomenonBeginTime", + "+phenomenonEndTime", + "+resultBeginTime", + "+resultEndTime" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -19356,43 +19321,46 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "timestampKey", - "timestampFormat", - "timezoneType", - "timezone", - "workspaceId", - "workspaceName", - "-id", - "-name", - "-timestampKey", - "-timestampFormat", - "-timezoneType", - "-timezone", - "-workspaceId", - "-workspaceName" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "name", + "timestampKey", + "timestampFormat", + "timezoneType", + "timezone", + "workspaceId", + "workspaceName", + "-id", + "-name", + "-timestampKey", + "-timestampFormat", + "-timezoneType", + "-timezone", + "-workspaceId", + "-workspaceName", + "+id", + "+name", + "+timestampKey", + "+timestampFormat", + "+timezoneType", + "+timezone", + "+workspaceId", + "+workspaceName" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -19980,33 +19948,31 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "sourceIdentifier", - "targetDatastreamId", - "-id", - "-sourceIdentifier", - "-targetDatastreamId" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "sourceIdentifier", + "targetDatastreamId", + "-id", + "-sourceIdentifier", + "-targetDatastreamId", + "+id", + "+sourceIdentifier", + "+targetDatastreamId" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Filter mappings by source identifier.", @@ -20636,45 +20602,49 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "dataConnectionId", - "dataConnectionName", - "workspaceId", - "workspaceName", - "latestRunStatus", - "latestRunStartedAt", - "latestRunFinishedAt", - "-id", - "-name", - "-dataConnectionId", - "-dataConnectionName", - "-workspaceId", - "-workspaceName", - "-latestRunStatus", - "-latestRunStartedAt", - "-latestRunFinishedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "name", + "dataConnectionId", + "dataConnectionName", + "workspaceId", + "workspaceName", + "latestRunStatus", + "latestRunStartedAt", + "latestRunFinishedAt", + "-id", + "-name", + "-dataConnectionId", + "-dataConnectionName", + "-workspaceId", + "-workspaceName", + "-latestRunStatus", + "-latestRunStartedAt", + "-latestRunFinishedAt", + "+id", + "+name", + "+dataConnectionId", + "+dataConnectionName", + "+workspaceId", + "+workspaceName", + "+latestRunStatus", + "+latestRunStartedAt", + "+latestRunFinishedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -21371,6 +21341,7 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, @@ -21386,13 +21357,18 @@ "-id", "-status", "-startedAt", - "-finishedAt" + "-finishedAt", + "+id", + "+status", + "+startedAt", + "+finishedAt" ], "type": "string" }, "title": "Sortby", "type": "array" - } + }, + "style": "form" }, { "description": "Filters task runs by their status.", @@ -21858,29 +21834,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -22373,29 +22345,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -22897,41 +22865,43 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "code", - "type", - "sensorModel", - "sensorModelManufacturer", - "definition", - "sensorModelDefinition", - "-name", - "-code", - "-type", - "-sensorModel", - "-sensorModelManufacturer", - "-definition", - "-sensorModelDefinition" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "code", + "type", + "sensorModel", + "sensorModelManufacturer", + "definition", + "sensorModelDefinition", + "-name", + "-code", + "-type", + "-sensorModel", + "-sensorModelManufacturer", + "-definition", + "-sensorModelDefinition", + "+name", + "+code", + "+type", + "+sensorModel", + "+sensorModelManufacturer", + "+definition", + "+sensorModelDefinition" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -23607,35 +23577,34 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "ruleType", - "datastreamId", - "lastCheckedAt", - "-id", - "-ruleType", - "-datastreamId", - "-lastCheckedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "ruleType", + "datastreamId", + "lastCheckedAt", + "-id", + "-ruleType", + "-datastreamId", + "-lastCheckedAt", + "+id", + "+ruleType", + "+datastreamId", + "+lastCheckedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Filter rules by datastream ID.", @@ -24263,29 +24232,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -24794,49 +24759,55 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "code", - "type", - "isPrivate", - "latitude", - "longitude", - "elevation_m", - "elevationDatum", - "adminArea1", - "adminArea2", - "country", - "-name", - "-code", - "-type", - "-isPrivate", - "-latitude", - "-longitude", - "-elevation_m", - "-elevationDatum", - "-adminArea1", - "-adminArea2", - "-country" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "code", + "type", + "isPrivate", + "latitude", + "longitude", + "elevation_m", + "elevationDatum", + "adminArea1", + "adminArea2", + "country", + "-name", + "-code", + "-type", + "-isPrivate", + "-latitude", + "-longitude", + "-elevation_m", + "-elevationDatum", + "-adminArea1", + "-adminArea2", + "-country", + "+name", + "+code", + "+type", + "+isPrivate", + "+latitude", + "+longitude", + "+elevation_m", + "+elevationDatum", + "+adminArea1", + "+adminArea2", + "+country" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -26429,45 +26400,49 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "name", - "monitoringSiteId", - "monitoringSiteName", - "workspaceId", - "workspaceName", - "latestRunStatus", - "latestRunStartedAt", - "latestRunFinishedAt", - "-id", - "-name", - "-monitoringSiteId", - "-monitoringSiteName", - "-workspaceId", - "-workspaceName", - "-latestRunStatus", - "-latestRunStartedAt", - "-latestRunFinishedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "name", + "monitoringSiteId", + "monitoringSiteName", + "workspaceId", + "workspaceName", + "latestRunStatus", + "latestRunStartedAt", + "latestRunFinishedAt", + "-id", + "-name", + "-monitoringSiteId", + "-monitoringSiteName", + "-workspaceId", + "-workspaceName", + "-latestRunStatus", + "-latestRunStartedAt", + "-latestRunFinishedAt", + "+id", + "+name", + "+monitoringSiteId", + "+monitoringSiteName", + "+workspaceId", + "+workspaceName", + "+latestRunStatus", + "+latestRunStartedAt", + "+latestRunFinishedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -27091,6 +27066,7 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, @@ -27106,13 +27082,18 @@ "-id", "-status", "-startedAt", - "-finishedAt" + "-finishedAt", + "+id", + "+status", + "+startedAt", + "+finishedAt" ], "type": "string" }, "title": "Sortby", "type": "array" - } + }, + "style": "form" }, { "description": "Filters task runs by their status.", @@ -27768,31 +27749,28 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "phenomenonTime", - "datastreamId", - "-phenomenonTime", - "-datastreamId" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "phenomenonTime", + "datastreamId", + "-phenomenonTime", + "-datastreamId", + "+phenomenonTime", + "+datastreamId" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Controls the format of the observations response.", @@ -28297,33 +28275,31 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "type", - "code", - "-name", - "-type", - "-code" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "type", + "code", + "-name", + "-type", + "-code", + "+name", + "+type", + "+code" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -28960,29 +28936,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -29479,31 +29451,28 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "code", - "name", - "-code", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "code", + "name", + "-code", + "-name", + "+code", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -30130,35 +30099,34 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "createdAt", - "phenomenonTimeStart", - "phenomenonTimeEnd", - "-id", - "-createdAt", - "-phenomenonTimeStart", - "-phenomenonTimeEnd" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "createdAt", + "phenomenonTimeStart", + "phenomenonTimeEnd", + "-id", + "-createdAt", + "-phenomenonTimeStart", + "-phenomenonTimeEnd", + "+id", + "+createdAt", + "+phenomenonTimeStart", + "+phenomenonTimeEnd" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Filter histories by managed datastream ID.", @@ -30634,39 +30602,40 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "createdAt", - "phenomenonTimeStart", - "phenomenonTimeEnd", - "status", - "committedAt", - "-id", - "-createdAt", - "-phenomenonTimeStart", - "-phenomenonTimeEnd", - "-status", - "-committedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "createdAt", + "phenomenonTimeStart", + "phenomenonTimeEnd", + "status", + "committedAt", + "-id", + "-createdAt", + "-phenomenonTimeStart", + "-phenomenonTimeEnd", + "-status", + "-committedAt", + "+id", + "+createdAt", + "+phenomenonTimeStart", + "+phenomenonTimeEnd", + "+status", + "+committedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "in": "query", @@ -31434,35 +31403,34 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "order", - "operationType", - "createdAt", - "-id", - "-order", - "-operationType", - "-createdAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "order", + "operationType", + "createdAt", + "-id", + "-order", + "-operationType", + "-createdAt", + "+id", + "+order", + "+operationType", + "+createdAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" } ], "responses": { @@ -32098,29 +32066,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -32659,29 +32623,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Filter roles by workspace ID.", @@ -32961,29 +32921,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -33476,29 +33432,25 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "-name" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "-name", + "+name" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -33996,33 +33948,31 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "symbol", - "type", - "-name", - "-symbol", - "-type" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "symbol", + "type", + "-name", + "-symbol", + "-type", + "+name", + "+symbol", + "+type" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -34653,31 +34603,28 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "isPrivate", - "-name", - "-isPrivate" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "isPrivate", + "-name", + "-isPrivate", + "+name", + "+isPrivate" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" }, { "description": "Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND.", @@ -35306,31 +35253,28 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "id", - "roleId", - "-id", - "-roleId" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "id", + "roleId", + "-id", + "-roleId", + "+id", + "+roleId" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" } ], "responses": { @@ -35649,37 +35593,37 @@ }, { "description": "Select one or more fields to sort the response by.", + "explode": false, "in": "query", "name": "sortby", "required": false, "schema": { - "anyOf": [ - { - "items": { - "enum": [ - "name", - "isActive", - "keyExpiresAt", - "createdAt", - "lastUsedAt", - "-name", - "-isActive", - "-keyExpiresAt", - "-createdAt", - "-lastUsedAt" - ], - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], "default": [], "description": "Select one or more fields to sort the response by.", - "title": "Sortby" - } + "items": { + "enum": [ + "name", + "isActive", + "keyExpiresAt", + "createdAt", + "lastUsedAt", + "-name", + "-isActive", + "-keyExpiresAt", + "-createdAt", + "-lastUsedAt", + "+name", + "+isActive", + "+keyExpiresAt", + "+createdAt", + "+lastUsedAt" + ], + "type": "string" + }, + "title": "Sortby", + "type": "array" + }, + "style": "form" } ], "responses": { diff --git a/django/interfaces/api/schemas/__init__.py b/django/interfaces/api/schemas/__init__.py index af667c9b..696a76be 100644 --- a/django/interfaces/api/schemas/__init__.py +++ b/django/interfaces/api/schemas/__init__.py @@ -1,6 +1,7 @@ from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, CollectionQueryParameters, PaginationMeta, PaginatedResponse, - CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema) + CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema, + split_sortby, sortby_array_schema) from .extent import (BoundingBox, BoundingBoxQuery, DatetimeQuery, ExtentQueryParameters, TimeInterval, parse_bbox, parse_datetime) from interfaces.api.schemas.iam.user import UserContactResponse diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index dacbea6e..21f7cb4e 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -63,6 +63,40 @@ def comma_array_schema(literal_type: Any) -> dict: } +def split_sortby(value: Any) -> Optional[list[str]]: + """ + Splits sortby values like split_comma_separated and drops a leading '+', which means + ascending, the same as no prefix (OGC API - Features Part 8 Req 5). An unencoded '+' arrives + as a space, which the split already trims. + """ + + fields = split_comma_separated(value) + + if fields is None: + return None + + return [ + field[1:] if field.startswith("+") and field[1:2] not in ("+", "-") else field + for field in fields + ] + + +def sortby_array_schema(literal_type: Any) -> dict: + """ + Generates the JSON schema for a comma-separated sortby parameter. The enum lists each field + in the given Literal type, which holds 'field' and '-field', plus '+field' for ascending. + """ + + schema = comma_array_schema(literal_type) + fields = schema["items"]["enum"] + schema["items"]["enum"] = [ + *fields, + *[f"+{field}" for field in fields if not field.startswith("-")], + ] + + return schema + + def parse_requested_properties(info: SerializationInfo) -> Optional[set[str]]: """ Parses and retrieves the requested properties from the serialization context. diff --git a/django/interfaces/api/schemas/etl/data_connection.py b/django/interfaces/api/schemas/etl/data_connection.py index d3630608..0595dd32 100644 --- a/django/interfaces/api/schemas/etl/data_connection.py +++ b/django/interfaces/api/schemas/etl/data_connection.py @@ -18,6 +18,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.orchestration.schedule import ( ScheduleResponse, @@ -95,9 +97,11 @@ class DataConnectionItemQueryParameters(DataConnectionFilterFields, BaseQueryPar class DataConnectionQueryParameters(DataConnectionFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[DataConnectionSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[DataConnectionSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(DataConnectionSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/etl/mapping.py b/django/interfaces/api/schemas/etl/mapping.py index 36bfec2b..ef921258 100644 --- a/django/interfaces/api/schemas/etl/mapping.py +++ b/django/interfaces/api/schemas/etl/mapping.py @@ -15,6 +15,8 @@ DatastreamResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -73,9 +75,11 @@ class EtlMappingItemQueryParameters(EtlMappingFilterFields, BaseQueryParameters) class EtlMappingQueryParameters(EtlMappingFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[EtlMappingSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[EtlMappingSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(EtlMappingSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") source_identifier: list[str] = Query( [], description="Filter mappings by source identifier." ) diff --git a/django/interfaces/api/schemas/etl/task.py b/django/interfaces/api/schemas/etl/task.py index 8ceecbad..478e28f5 100644 --- a/django/interfaces/api/schemas/etl/task.py +++ b/django/interfaces/api/schemas/etl/task.py @@ -16,6 +16,8 @@ DataConnectionResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.orchestration.schedule import ( ScheduleResponse, @@ -91,9 +93,11 @@ class EtlTaskItemQueryParameters(EtlTaskFilterFields, BaseQueryParameters): class EtlTaskQueryParameters(EtlTaskFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[EtlTaskSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[EtlTaskSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(EtlTaskSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/iam/collaborator.py b/django/interfaces/api/schemas/iam/collaborator.py index ff679cdc..40173128 100644 --- a/django/interfaces/api/schemas/iam/collaborator.py +++ b/django/interfaces/api/schemas/iam/collaborator.py @@ -12,6 +12,8 @@ RoleResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.iam.user import UserContactResponse from interfaces.api.schemas.iam.service_account import ServiceAccountContactResponse @@ -74,9 +76,11 @@ class CollaboratorFilterFields(Schema): class CollaboratorQueryParameters(CollaboratorFilterFields, CollectionQueryParameters): role_id: list[uuid.UUID] = Query([], description="Filter collaborators by role ID.") - sortby: Optional[list[CollaboratorSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[CollaboratorSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(CollaboratorSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") class CollaboratorResponse(BaseGetResponse): diff --git a/django/interfaces/api/schemas/iam/role.py b/django/interfaces/api/schemas/iam/role.py index 165aceed..302031a4 100644 --- a/django/interfaces/api/schemas/iam/role.py +++ b/django/interfaces/api/schemas/iam/role.py @@ -14,6 +14,8 @@ ExtentQueryParameters, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) RESOURCE_TYPES = Literal["*", *resource_types] @@ -59,9 +61,11 @@ class RoleItemQueryParameters(RoleFilterFields, BaseQueryParameters): class RoleQueryParameters(RoleFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[RoleSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[RoleSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(RoleSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") workspace_id: list[uuid.UUID | Literal["null"]] = Query( [], description="Filter roles by workspace ID." ) diff --git a/django/interfaces/api/schemas/iam/service_account.py b/django/interfaces/api/schemas/iam/service_account.py index 9f51cb4e..4c479e7f 100644 --- a/django/interfaces/api/schemas/iam/service_account.py +++ b/django/interfaces/api/schemas/iam/service_account.py @@ -16,6 +16,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -87,9 +89,11 @@ class ServiceAccountItemQueryParameters(ServiceAccountFilterFields, BaseQueryPar class ServiceAccountQueryParameters(ServiceAccountFilterFields, CollectionQueryParameters): - sortby: Optional[list[ServiceAccountSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[ServiceAccountSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(ServiceAccountSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") class ServiceAccountResponse(BaseGetResponse, ServiceAccountFields): diff --git a/django/interfaces/api/schemas/iam/workspace.py b/django/interfaces/api/schemas/iam/workspace.py index 925c2bb6..7744dba8 100644 --- a/django/interfaces/api/schemas/iam/workspace.py +++ b/django/interfaces/api/schemas/iam/workspace.py @@ -16,6 +16,8 @@ RoleResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -86,9 +88,11 @@ class WorkspaceItemQueryParameters(WorkspaceFilterFields, BaseQueryParameters): class WorkspaceQueryParameters(WorkspaceFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[WorkspaceSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[WorkspaceSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(WorkspaceSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/monitoring/rule.py b/django/interfaces/api/schemas/monitoring/rule.py index d9e99804..79bcfcd4 100644 --- a/django/interfaces/api/schemas/monitoring/rule.py +++ b/django/interfaces/api/schemas/monitoring/rule.py @@ -15,6 +15,8 @@ DatastreamResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -78,9 +80,11 @@ class MonitoringRuleItemQueryParameters(MonitoringRuleFilterFields, BaseQueryPar class MonitoringRuleQueryParameters(MonitoringRuleFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[MonitoringRuleSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[MonitoringRuleSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(MonitoringRuleSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") datastream_id: list[uuid.UUID] = Query( [], description="Filter rules by datastream ID." ) diff --git a/django/interfaces/api/schemas/monitoring/task.py b/django/interfaces/api/schemas/monitoring/task.py index b66030b7..2a8f46e7 100644 --- a/django/interfaces/api/schemas/monitoring/task.py +++ b/django/interfaces/api/schemas/monitoring/task.py @@ -16,6 +16,8 @@ MonitoringSiteResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.orchestration.schedule import ( ScheduleResponse, @@ -93,9 +95,11 @@ class MonitoringTaskItemQueryParameters(MonitoringTaskFilterFields, BaseQueryPar class MonitoringTaskQueryParameters(MonitoringTaskFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[MonitoringTaskSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[MonitoringTaskSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(MonitoringTaskSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/orchestration/run.py b/django/interfaces/api/schemas/orchestration/run.py index 807c3e9d..fe0e7ffe 100644 --- a/django/interfaces/api/schemas/orchestration/run.py +++ b/django/interfaces/api/schemas/orchestration/run.py @@ -11,6 +11,8 @@ CollectionQueryParameters, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -34,9 +36,11 @@ class TaskRunQueryParameters(CollectionQueryParameters): description="Comma-separated list of properties to include in the response. " "All properties are returned if omitted.", ) - sortby: list[TaskRunSortByFields] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + list[TaskRunSortByFields], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(TaskRunSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") status: list[Literal["PENDING", "STARTED", "SUCCESS", "FAILURE"]] = Query( [], description="Filters task runs by their status." ) diff --git a/django/interfaces/api/schemas/products/rating_curve.py b/django/interfaces/api/schemas/products/rating_curve.py index 693ffc09..0386896d 100644 --- a/django/interfaces/api/schemas/products/rating_curve.py +++ b/django/interfaces/api/schemas/products/rating_curve.py @@ -15,6 +15,8 @@ MonitoringSiteResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -83,9 +85,11 @@ class RatingCurveItemQueryParameters(RatingCurveFilterFields, BaseQueryParameter class RatingCurveQueryParameters(RatingCurveFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[RatingCurveSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[RatingCurveSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(RatingCurveSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/products/task.py b/django/interfaces/api/schemas/products/task.py index 4864c6a6..bb0ca289 100644 --- a/django/interfaces/api/schemas/products/task.py +++ b/django/interfaces/api/schemas/products/task.py @@ -16,6 +16,8 @@ MonitoringSiteResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.orchestration.schedule import ( ScheduleResponse, @@ -92,9 +94,11 @@ class DataProductTaskItemQueryParameters(DataProductTaskFilterFields, BaseQueryP class DataProductTaskQueryParameters(DataProductTaskFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[DataProductTaskSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[DataProductTaskSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(DataProductTaskSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/products/transformation.py b/django/interfaces/api/schemas/products/transformation.py index 00e02f41..09c1e96f 100644 --- a/django/interfaces/api/schemas/products/transformation.py +++ b/django/interfaces/api/schemas/products/transformation.py @@ -15,6 +15,8 @@ DatastreamResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.products.rating_curve import RatingCurveResponse @@ -108,9 +110,11 @@ class DataProductTransformationItemQueryParameters( class DataProductTransformationQueryParameters( DataProductTransformationFilterFields, CollectionQueryParameters, ExtentQueryParameters ): - sortby: Optional[list[DataProductTransformationSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[DataProductTransformationSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(DataProductTransformationSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") transformation_type: list[str] = Query( [], description="Filter transformations by type." ) diff --git a/django/interfaces/api/schemas/quality/history.py b/django/interfaces/api/schemas/quality/history.py index ca7d40fe..04307953 100644 --- a/django/interfaces/api/schemas/quality/history.py +++ b/django/interfaces/api/schemas/quality/history.py @@ -14,6 +14,8 @@ DatastreamResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -80,9 +82,11 @@ class QualityControlHistoryItemQueryParameters( class QualityControlHistoryQueryParameters( QualityControlHistoryFilterFields, CollectionQueryParameters, ExtentQueryParameters ): - sortby: Optional[list[QualityControlHistorySortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[QualityControlHistorySortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(QualityControlHistorySortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") managed_datastream_id: list[uuid.UUID] = Query( [], description="Filter histories by managed datastream ID." ) diff --git a/django/interfaces/api/schemas/quality/operation.py b/django/interfaces/api/schemas/quality/operation.py index 74ee4d81..e0300a26 100644 --- a/django/interfaces/api/schemas/quality/operation.py +++ b/django/interfaces/api/schemas/quality/operation.py @@ -14,6 +14,8 @@ UserContactResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.iam.collaborator import DELETED_USER_CONTACT @@ -70,9 +72,11 @@ class QualityControlOperationItemQueryParameters(QualityControlOperationFilterFi class QualityControlOperationQueryParameters(QualityControlOperationFilterFields, CollectionQueryParameters): - sortby: Optional[list[QualityControlOperationSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[QualityControlOperationSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(QualityControlOperationSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") class QualityControlOperationResponse(BaseGetResponse): diff --git a/django/interfaces/api/schemas/quality/session.py b/django/interfaces/api/schemas/quality/session.py index 5dfcfeba..79c8b630 100644 --- a/django/interfaces/api/schemas/quality/session.py +++ b/django/interfaces/api/schemas/quality/session.py @@ -14,6 +14,8 @@ UserContactResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.iam.collaborator import DELETED_USER_CONTACT @@ -65,9 +67,11 @@ class QualityControlSessionItemQueryParameters(QualityControlSessionFilterFields class QualityControlSessionQueryParameters(QualityControlSessionFilterFields, CollectionQueryParameters): - sortby: Optional[list[QualityControlSessionSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[QualityControlSessionSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(QualityControlSessionSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") status: Optional[SessionStatus] = None range_start: Optional[ISODatetime] = Query(None, description="Return sessions overlapping with this range start.") range_end: Optional[ISODatetime] = Query(None, description="Return sessions overlapping with this range end.") diff --git a/django/interfaces/api/schemas/sta/datastream.py b/django/interfaces/api/schemas/sta/datastream.py index 9505e52a..0e64b070 100644 --- a/django/interfaces/api/schemas/sta/datastream.py +++ b/django/interfaces/api/schemas/sta/datastream.py @@ -21,6 +21,8 @@ ProcessingLevelResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.sta.linked_resource import LinkedResourceGetResponse from interfaces.api.schemas.sta.tags import reject_empty_tag_keys_and_values @@ -173,9 +175,11 @@ class DatastreamItemQueryParameters(DatastreamFilterFields, BaseQueryParameters) class DatastreamQueryParameters(DatastreamFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[DatastreamSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[DatastreamSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(DatastreamSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/method.py b/django/interfaces/api/schemas/sta/method.py index 08418108..616c48ef 100644 --- a/django/interfaces/api/schemas/sta/method.py +++ b/django/interfaces/api/schemas/sta/method.py @@ -16,6 +16,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.sta.vocabulary import VocabularyResponse @@ -86,9 +88,11 @@ class MethodItemQueryParameters(MethodFilterFields, BaseQueryParameters): class MethodQueryParameters(MethodFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[MethodSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[MethodSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(MethodSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/monitoring_site.py b/django/interfaces/api/schemas/sta/monitoring_site.py index bea79f3f..3f33dcdd 100644 --- a/django/interfaces/api/schemas/sta/monitoring_site.py +++ b/django/interfaces/api/schemas/sta/monitoring_site.py @@ -19,6 +19,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.sta.linked_resource import LinkedResourceGetResponse from interfaces.api.schemas.sta.tags import reject_empty_tag_keys_and_values @@ -125,9 +127,11 @@ class MonitoringSiteItemQueryParameters(MonitoringSiteFilterFields, BaseQueryPar class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[MonitoringSiteSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[MonitoringSiteSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(MonitoringSiteSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/observation.py b/django/interfaces/api/schemas/sta/observation.py index 40a23998..96e6f61d 100644 --- a/django/interfaces/api/schemas/sta/observation.py +++ b/django/interfaces/api/schemas/sta/observation.py @@ -19,6 +19,8 @@ DatastreamResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.sta.result_qualifier import ResultQualifierResponse @@ -93,9 +95,11 @@ class ObservationQueryParameters(ObservationFilterFields, CollectionQueryParamet datastream_id: list[uuid.UUID] = Query( [], description="Filter observations by datastream ID." ) - sortby: Optional[list[ObservationSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[ObservationSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(ObservationSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") response_format: Optional[Literal["record", "row", "column"]] = Query( None, description="Controls the format of the observations response.", diff --git a/django/interfaces/api/schemas/sta/observed_property.py b/django/interfaces/api/schemas/sta/observed_property.py index 79b75712..4dfda4b3 100644 --- a/django/interfaces/api/schemas/sta/observed_property.py +++ b/django/interfaces/api/schemas/sta/observed_property.py @@ -16,6 +16,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.sta.vocabulary import VocabularyResponse @@ -83,9 +85,11 @@ class ObservedPropertyItemQueryParameters(ObservedPropertyFilterFields, BaseQuer class ObservedPropertyQueryParameters(ObservedPropertyFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[ObservedPropertySortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[ObservedPropertySortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(ObservedPropertySortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/processing_level.py b/django/interfaces/api/schemas/sta/processing_level.py index 5411bed4..00b3ef0e 100644 --- a/django/interfaces/api/schemas/sta/processing_level.py +++ b/django/interfaces/api/schemas/sta/processing_level.py @@ -15,6 +15,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -70,9 +72,11 @@ class ProcessingLevelItemQueryParameters(ProcessingLevelFilterFields, BaseQueryP class ProcessingLevelQueryParameters(ProcessingLevelFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[ProcessingLevelSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[ProcessingLevelSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(ProcessingLevelSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/result_qualifier.py b/django/interfaces/api/schemas/sta/result_qualifier.py index ab464787..e1f4a17a 100644 --- a/django/interfaces/api/schemas/sta/result_qualifier.py +++ b/django/interfaces/api/schemas/sta/result_qualifier.py @@ -15,6 +15,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -74,9 +76,11 @@ class ResultQualifierItemQueryParameters( class ResultQualifierQueryParameters( ResultQualifierFilterFields, CollectionQueryParameters, ExtentQueryParameters ): - sortby: Optional[list[ResultQualifierSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[ResultQualifierSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(ResultQualifierSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/unit.py b/django/interfaces/api/schemas/sta/unit.py index 80187b0d..7a476c0e 100644 --- a/django/interfaces/api/schemas/sta/unit.py +++ b/django/interfaces/api/schemas/sta/unit.py @@ -16,6 +16,8 @@ WorkspaceResponse, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) from interfaces.api.schemas.sta.vocabulary import VocabularyResponse @@ -78,9 +80,11 @@ class UnitItemQueryParameters(UnitFilterFields, BaseQueryParameters): class UnitQueryParameters(UnitFilterFields, CollectionQueryParameters, ExtentQueryParameters): - sortby: Optional[list[UnitSortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[UnitSortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(UnitSortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/interfaces/api/schemas/sta/vocabulary.py b/django/interfaces/api/schemas/sta/vocabulary.py index b7072755..4a003a74 100644 --- a/django/interfaces/api/schemas/sta/vocabulary.py +++ b/django/interfaces/api/schemas/sta/vocabulary.py @@ -14,6 +14,8 @@ ExtentQueryParameters, split_comma_separated, comma_array_schema, + split_sortby, + sortby_array_schema, ) @@ -55,9 +57,11 @@ class VocabularyItemQueryParameters( class VocabularyQueryParameters( VocabularyFilterFields, CollectionQueryParameters, ExtentQueryParameters ): - sortby: Optional[list[VocabularySortByFields]] = Query( - [], description="Select one or more fields to sort the response by." - ) + sortby: Annotated[ + Optional[list[VocabularySortByFields]], + BeforeValidator(split_sortby), + WithJsonSchema(sortby_array_schema(VocabularySortByFields)), + ] = Query([], description="Select one or more fields to sort the response by.") q: Optional[str] = Query( None, description="Full-text search query. Comma-separated terms are combined with OR; " diff --git a/django/tests/interfaces/api/test_sortby.py b/django/tests/interfaces/api/test_sortby.py new file mode 100644 index 00000000..05d2bcd2 --- /dev/null +++ b/django/tests/interfaces/api/test_sortby.py @@ -0,0 +1,101 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from interfaces.api.urls import api +from processing.orchestration.models import TaskRun +from tests.core.iam.factories import UserFactory, WorkspaceFactory +from tests.core.sta.factories import MonitoringSiteFactory +from tests.processing.etl.factories import DataConnectionFactory, EtlTaskFactory, PayloadFactory + +pytestmark = pytest.mark.django_db + +# sortby takes comma-separated fields, each with an optional '+' (ascending) or '-' (descending) +# prefix (OGC API - Features Part 8 Req 5). The repeated form (sortby=a&sortby=b) still works. + +MONITORING_SITES_URL = "/api/ogc/collections/monitoring-sites/items" + + +def _names(response): + return [site["name"] for site in response.json()["data"]] + + +@pytest.fixture +def sites(): + MonitoringSiteFactory(name="b", code="1") + MonitoringSiteFactory(name="a", code="1") + MonitoringSiteFactory(name="a", code="2") + + +@pytest.mark.parametrize( + "query", + [ + "sortby=name,-code", + "sortby=name&sortby=-code", + "sortby=%2Bname,-code", + "sortby=%2Bname&sortby=-code", + ], +) +def test_get_items_sorts_by_comma_separated_or_repeated_fields(client, sites, query): + response = client.get(f"{MONITORING_SITES_URL}?{query}") + + assert response.status_code == 200 + assert [(s["name"], s["code"]) for s in response.json()["data"]] == [("a", "2"), ("a", "1"), ("b", "1")] + + +def test_get_items_treats_an_unencoded_plus_as_ascending(client, sites): + # An unencoded '+' in a query string decodes to a space. + response = client.get(f"{MONITORING_SITES_URL}?sortby=+name") + + assert response.status_code == 200 + assert _names(response) == ["a", "a", "b"] + + +@pytest.mark.parametrize( + "sortby", ["name,%2Bname", "name,-name", "bogus", "%2B-name", "%2B%2Bname", "%2B"] +) +def test_get_items_returns_400_for_invalid_sortby(client, sortby): + response = client.get(f"{MONITORING_SITES_URL}?sortby={sortby}") + + assert response.status_code == 400 + + +def _etl_task(owner): + data_connection = DataConnectionFactory(workspace=WorkspaceFactory(owner=owner)) + PayloadFactory(data_connection=data_connection) + return EtlTaskFactory(data_connection=data_connection) + + +def test_get_task_runs_accepts_a_plus_prefix(client): + owner = UserFactory() + task = _etl_task(owner) + now = timezone.now() + older = TaskRun.objects.create(task=task, status="SUCCESS", started_at=now - timedelta(hours=1)) + newer = TaskRun.objects.create(task=task, status="SUCCESS", started_at=now) + client.force_login(owner) + + response = client.get(f"/api/ogc/collections/etl-tasks/items/{task.id}/runs?sortby=%2BstartedAt") + + assert response.status_code == 200 + assert [run["id"] for run in response.json()["data"]] == [str(older.id), str(newer.id)] + + +def test_every_sortby_parameter_is_comma_separated_with_plus_and_minus_prefixes(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + sortby_parameters = [ + parameter + for path_item in paths.values() + for operation in path_item.values() + for parameter in operation.get("parameters", []) + if parameter["name"] == "sortby" + ] + + assert sortby_parameters + for parameter in sortby_parameters: + assert parameter["style"] == "form" + assert parameter["explode"] is False + assert parameter["schema"]["type"] == "array" + values = set(parameter["schema"]["items"]["enum"]) + ascending = {value for value in values if value[0] not in "+-"} + assert values == ascending | {f"+{f}" for f in ascending} | {f"-{f}" for f in ascending} diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 75f938b4..1a085782 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2256,7 +2256,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "roleId" | "-id" | "-roleId")[] | null; + sortby: ("id" | "roleId" | "-id" | "-roleId" | "+id" | "+roleId")[]; }; /** CollaboratorResponse */ CollaboratorResponse: { @@ -2390,7 +2390,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "name" | "timestampKey" | "timestampFormat" | "timezoneType" | "timezone" | "workspaceId" | "workspaceName" | "-id" | "-name" | "-timestampKey" | "-timestampFormat" | "-timezoneType" | "-timezone" | "-workspaceId" | "-workspaceName")[] | null; + sortby: ("id" | "name" | "timestampKey" | "timestampFormat" | "timezoneType" | "timezone" | "workspaceId" | "workspaceName" | "-id" | "-name" | "-timestampKey" | "-timestampFormat" | "-timezoneType" | "-timezone" | "-workspaceId" | "-workspaceName" | "+id" | "+name" | "+timestampKey" | "+timestampFormat" | "+timezoneType" | "+timezone" | "+workspaceId" | "+workspaceName")[]; /** * Workspace Id * @description Filter data connections by workspace ID. @@ -2580,7 +2580,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; + sortby: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt" | "+id" | "+name" | "+monitoringSiteId" | "+monitoringSiteName" | "+workspaceId" | "+workspaceName" | "+latestRunStatus" | "+latestRunStartedAt" | "+latestRunFinishedAt")[]; /** * Transformation Type * @description Filter data product tasks by transformation type. @@ -2762,7 +2762,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "outputDatastreamId" | "transformationType" | "-id" | "-outputDatastreamId" | "-transformationType")[] | null; + sortby: ("id" | "outputDatastreamId" | "transformationType" | "-id" | "-outputDatastreamId" | "-transformationType" | "+id" | "+outputDatastreamId" | "+transformationType")[]; /** * Task Id * @description Filter transformations by data product task ID. @@ -3077,7 +3077,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "observationType" | "sampledMedium" | "status" | "resultType" | "isPrivate" | "isVisible" | "aggregationStatistic" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "-name" | "-observationType" | "-sampledMedium" | "-status" | "-resultType" | "-isPrivate" | "-isVisible" | "-aggregationStatistic" | "-valueCount" | "-phenomenonBeginTime" | "-phenomenonEndTime" | "-resultBeginTime" | "-resultEndTime")[] | null; + sortby: ("name" | "observationType" | "sampledMedium" | "status" | "resultType" | "isPrivate" | "isVisible" | "aggregationStatistic" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "-name" | "-observationType" | "-sampledMedium" | "-status" | "-resultType" | "-isPrivate" | "-isVisible" | "-aggregationStatistic" | "-valueCount" | "-phenomenonBeginTime" | "-phenomenonEndTime" | "-resultBeginTime" | "-resultEndTime" | "+name" | "+observationType" | "+sampledMedium" | "+status" | "+resultType" | "+isPrivate" | "+isVisible" | "+aggregationStatistic" | "+valueCount" | "+phenomenonBeginTime" | "+phenomenonEndTime" | "+resultBeginTime" | "+resultEndTime")[]; /** * Status * @description Filter monitoring_sites by status. @@ -3340,7 +3340,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "sourceIdentifier" | "targetDatastreamId" | "-id" | "-sourceIdentifier" | "-targetDatastreamId")[] | null; + sortby: ("id" | "sourceIdentifier" | "targetDatastreamId" | "-id" | "-sourceIdentifier" | "-targetDatastreamId" | "+id" | "+sourceIdentifier" | "+targetDatastreamId")[]; /** * Source Identifier * @description Filter mappings by source identifier. @@ -3507,7 +3507,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "name" | "dataConnectionId" | "dataConnectionName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-dataConnectionId" | "-dataConnectionName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; + sortby: ("id" | "name" | "dataConnectionId" | "dataConnectionName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-dataConnectionId" | "-dataConnectionName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt" | "+id" | "+name" | "+dataConnectionId" | "+dataConnectionName" | "+workspaceId" | "+workspaceName" | "+latestRunStatus" | "+latestRunStartedAt" | "+latestRunFinishedAt")[]; /** * Workspace Id * @description Filter ETL tasks by workspace ID. @@ -4019,7 +4019,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "code" | "type" | "sensorModel" | "sensorModelManufacturer" | "definition" | "sensorModelDefinition" | "-name" | "-code" | "-type" | "-sensorModel" | "-sensorModelManufacturer" | "-definition" | "-sensorModelDefinition")[] | null; + sortby: ("name" | "code" | "type" | "sensorModel" | "sensorModelManufacturer" | "definition" | "sensorModelDefinition" | "-name" | "-code" | "-type" | "-sensorModel" | "-sensorModelManufacturer" | "-definition" | "-sensorModelDefinition" | "+name" | "+code" | "+type" | "+sensorModel" | "+sensorModelManufacturer" | "+definition" | "+sensorModelDefinition")[]; /** * Type * @description Filter methods by type @@ -4190,7 +4190,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "ruleType" | "datastreamId" | "lastCheckedAt" | "-id" | "-ruleType" | "-datastreamId" | "-lastCheckedAt")[] | null; + sortby: ("id" | "ruleType" | "datastreamId" | "lastCheckedAt" | "-id" | "-ruleType" | "-datastreamId" | "-lastCheckedAt" | "+id" | "+ruleType" | "+datastreamId" | "+lastCheckedAt")[]; /** * Task Id * @description Filter rules by monitoring task ID. @@ -4471,7 +4471,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "code" | "type" | "isPrivate" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "-name" | "-code" | "-type" | "-isPrivate" | "-latitude" | "-longitude" | "-elevation_m" | "-elevationDatum" | "-adminArea1" | "-adminArea2" | "-country")[] | null; + sortby: ("name" | "code" | "type" | "isPrivate" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "-name" | "-code" | "-type" | "-isPrivate" | "-latitude" | "-longitude" | "-elevation_m" | "-elevationDatum" | "-adminArea1" | "-adminArea2" | "-country" | "+name" | "+code" | "+type" | "+isPrivate" | "+latitude" | "+longitude" | "+elevation_m" | "+elevationDatum" | "+adminArea1" | "+adminArea2" | "+country")[]; /** * Tag * @description Filter monitoring sites by tag. Format tag filters as {key}:{value} @@ -4729,7 +4729,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; + sortby: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt" | "+id" | "+name" | "+monitoringSiteId" | "+monitoringSiteName" | "+workspaceId" | "+workspaceName" | "+latestRunStatus" | "+latestRunStartedAt" | "+latestRunFinishedAt")[]; /** * Workspace Id * @description Filter monitoring tasks by workspace ID. @@ -4941,7 +4941,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId")[] | null; + sortby: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId" | "+phenomenonTime" | "+datastreamId")[]; }; /** ObservationResponse */ ObservationResponse: { @@ -5082,7 +5082,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "type" | "code" | "-name" | "-type" | "-code")[] | null; + sortby: ("name" | "type" | "code" | "-name" | "-type" | "-code" | "+name" | "+type" | "+code")[]; /** * Type * @description Filter observed properties by type @@ -5636,7 +5636,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("code" | "name" | "-code" | "-name")[] | null; + sortby: ("code" | "name" | "-code" | "-name" | "+code" | "+name")[]; /** * Workspace Id * @description Filter processing levels by workspace ID. @@ -5727,7 +5727,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd")[] | null; + sortby: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "+id" | "+createdAt" | "+phenomenonTimeStart" | "+phenomenonTimeEnd")[]; /** * Source Datastream Id * @description Filter histories by source datastream ID. @@ -5830,7 +5830,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "order" | "operationType" | "createdAt" | "-id" | "-order" | "-operationType" | "-createdAt")[] | null; + sortby: ("id" | "order" | "operationType" | "createdAt" | "-id" | "-order" | "-operationType" | "-createdAt" | "+id" | "+order" | "+operationType" | "+createdAt")[]; }; /** QualityControlOperationResponse */ QualityControlOperationResponse: { @@ -5931,7 +5931,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "status" | "committedAt" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "-status" | "-committedAt")[] | null; + sortby: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "status" | "committedAt" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "-status" | "-committedAt" | "+id" | "+createdAt" | "+phenomenonTimeStart" | "+phenomenonTimeEnd" | "+status" | "+committedAt")[]; /** Status */ status?: ("in_progress" | "committed") | null; }; @@ -6080,7 +6080,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "fittingMethod" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-fittingMethod")[] | null; + sortby: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "fittingMethod" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-fittingMethod" | "+id" | "+name" | "+monitoringSiteId" | "+monitoringSiteName" | "+workspaceId" | "+workspaceName" | "+fittingMethod")[]; /** * Workspace Id * @description Filter rating curves by workspace ID. @@ -6190,7 +6190,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "-name")[] | null; + sortby: ("name" | "-name" | "+name")[]; /** * Workspace Id * @description Filter terms by workspace ID. @@ -6256,7 +6256,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "-name")[] | null; + sortby: ("name" | "-name" | "+name")[]; /** * Workspace Id * @description Filter roles by workspace ID. @@ -6441,7 +6441,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "isActive" | "keyExpiresAt" | "createdAt" | "lastUsedAt" | "-name" | "-isActive" | "-keyExpiresAt" | "-createdAt" | "-lastUsedAt")[] | null; + sortby: ("name" | "isActive" | "keyExpiresAt" | "createdAt" | "lastUsedAt" | "-name" | "-isActive" | "-keyExpiresAt" | "-createdAt" | "-lastUsedAt" | "+name" | "+isActive" | "+keyExpiresAt" | "+createdAt" | "+lastUsedAt")[]; }; /** ServiceAccountResponse */ ServiceAccountResponse: { @@ -6519,7 +6519,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; + sortby: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt" | "+id" | "+status" | "+startedAt" | "+finishedAt")[]; /** * Started At Max * @description Filters for task runs started on or before this date and time. @@ -6672,7 +6672,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "symbol" | "type" | "-name" | "-symbol" | "-type")[] | null; + sortby: ("name" | "symbol" | "type" | "-name" | "-symbol" | "-type" | "+name" | "+symbol" | "+type")[]; /** * Type * @description Filter units by type @@ -6905,7 +6905,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "-name")[] | null; + sortby: ("name" | "-name" | "+name")[]; }; /** WorkspaceItemQueryParameters */ WorkspaceItemQueryParameters: { @@ -6984,7 +6984,7 @@ export interface components { * @description Select one or more fields to sort the response by. * @default [] */ - sortby: ("name" | "isPrivate" | "-name" | "-isPrivate")[] | null; + sortby: ("name" | "isPrivate" | "-name" | "-isPrivate" | "+name" | "+isPrivate")[]; }; /** WorkspaceResponse */ WorkspaceResponse: { @@ -7037,7 +7037,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -7266,7 +7266,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "fittingMethod" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-fittingMethod")[] | null; + sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "fittingMethod" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-fittingMethod" | "+id" | "+name" | "+monitoringSiteId" | "+monitoringSiteName" | "+workspaceId" | "+workspaceName" | "+fittingMethod")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter rating curves by monitoring site ID. */ @@ -7537,7 +7537,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; + sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt" | "+id" | "+name" | "+monitoringSiteId" | "+monitoringSiteName" | "+workspaceId" | "+workspaceName" | "+latestRunStatus" | "+latestRunStartedAt" | "+latestRunFinishedAt")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter data product tasks by monitoring_site ID. */ @@ -7814,7 +7814,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; + sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt" | "+id" | "+status" | "+startedAt" | "+finishedAt")[]; /** @description Filters task runs by their status. */ status?: ("PENDING" | "STARTED" | "SUCCESS" | "FAILURE")[]; /** @description Filters for task runs started on or before this date and time. */ @@ -7987,7 +7987,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "outputDatastreamId" | "transformationType" | "-id" | "-outputDatastreamId" | "-transformationType")[] | null; + sortby?: ("id" | "outputDatastreamId" | "transformationType" | "-id" | "-outputDatastreamId" | "-transformationType" | "+id" | "+outputDatastreamId" | "+transformationType")[]; /** @description Filter transformations by type. */ transformation_type?: string[]; /** @description Filter transformations by output datastream ID. */ @@ -8280,7 +8280,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -8509,7 +8509,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "observationType" | "sampledMedium" | "status" | "resultType" | "isPrivate" | "isVisible" | "aggregationStatistic" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "-name" | "-observationType" | "-sampledMedium" | "-status" | "-resultType" | "-isPrivate" | "-isVisible" | "-aggregationStatistic" | "-valueCount" | "-phenomenonBeginTime" | "-phenomenonEndTime" | "-resultBeginTime" | "-resultEndTime")[] | null; + sortby?: ("name" | "observationType" | "sampledMedium" | "status" | "resultType" | "isPrivate" | "isVisible" | "aggregationStatistic" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "-name" | "-observationType" | "-sampledMedium" | "-status" | "-resultType" | "-isPrivate" | "-isVisible" | "-aggregationStatistic" | "-valueCount" | "-phenomenonBeginTime" | "-phenomenonEndTime" | "-resultBeginTime" | "-resultEndTime" | "+name" | "+observationType" | "+sampledMedium" | "+status" | "+resultType" | "+isPrivate" | "+isVisible" | "+aggregationStatistic" | "+valueCount" | "+phenomenonBeginTime" | "+phenomenonEndTime" | "+resultBeginTime" | "+resultEndTime")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter datastreams by workspace ID. */ @@ -9127,7 +9127,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "name" | "timestampKey" | "timestampFormat" | "timezoneType" | "timezone" | "workspaceId" | "workspaceName" | "-id" | "-name" | "-timestampKey" | "-timestampFormat" | "-timezoneType" | "-timezone" | "-workspaceId" | "-workspaceName")[] | null; + sortby?: ("id" | "name" | "timestampKey" | "timestampFormat" | "timezoneType" | "timezone" | "workspaceId" | "workspaceName" | "-id" | "-name" | "-timestampKey" | "-timestampFormat" | "-timezoneType" | "-timezone" | "-workspaceId" | "-workspaceName" | "+id" | "+name" | "+timestampKey" | "+timestampFormat" | "+timezoneType" | "+timezone" | "+workspaceId" | "+workspaceName")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter data connections by workspace ID. */ @@ -9389,7 +9389,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "sourceIdentifier" | "targetDatastreamId" | "-id" | "-sourceIdentifier" | "-targetDatastreamId")[] | null; + sortby?: ("id" | "sourceIdentifier" | "targetDatastreamId" | "-id" | "-sourceIdentifier" | "-targetDatastreamId" | "+id" | "+sourceIdentifier" | "+targetDatastreamId")[]; /** @description Filter mappings by source identifier. */ source_identifier?: string[]; /** @description Filter mappings by target datastream ID. */ @@ -9680,7 +9680,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "name" | "dataConnectionId" | "dataConnectionName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-dataConnectionId" | "-dataConnectionName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; + sortby?: ("id" | "name" | "dataConnectionId" | "dataConnectionName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-dataConnectionId" | "-dataConnectionName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt" | "+id" | "+name" | "+dataConnectionId" | "+dataConnectionName" | "+workspaceId" | "+workspaceName" | "+latestRunStatus" | "+latestRunStartedAt" | "+latestRunFinishedAt")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter ETL tasks by monitoring_site ID. */ @@ -9959,7 +9959,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; + sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt" | "+id" | "+status" | "+startedAt" | "+finishedAt")[]; /** @description Filters task runs by their status. */ status?: ("PENDING" | "STARTED" | "SUCCESS" | "FAILURE")[]; /** @description Filters for task runs started on or before this date and time. */ @@ -10132,7 +10132,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -10361,7 +10361,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -10590,7 +10590,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "code" | "type" | "sensorModel" | "sensorModelManufacturer" | "definition" | "sensorModelDefinition" | "-name" | "-code" | "-type" | "-sensorModel" | "-sensorModelManufacturer" | "-definition" | "-sensorModelDefinition")[] | null; + sortby?: ("name" | "code" | "type" | "sensorModel" | "sensorModelManufacturer" | "definition" | "sensorModelDefinition" | "-name" | "-code" | "-type" | "-sensorModel" | "-sensorModelManufacturer" | "-definition" | "-sensorModelDefinition" | "+name" | "+code" | "+type" | "+sensorModel" | "+sensorModelManufacturer" | "+definition" | "+sensorModelDefinition")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter methods by workspace ID. */ @@ -10860,7 +10860,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "ruleType" | "datastreamId" | "lastCheckedAt" | "-id" | "-ruleType" | "-datastreamId" | "-lastCheckedAt")[] | null; + sortby?: ("id" | "ruleType" | "datastreamId" | "lastCheckedAt" | "-id" | "-ruleType" | "-datastreamId" | "-lastCheckedAt" | "+id" | "+ruleType" | "+datastreamId" | "+lastCheckedAt")[]; /** @description Filter rules by datastream ID. */ datastream_id?: string[]; /** @description Filter rules by rule type. */ @@ -11151,7 +11151,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -11380,7 +11380,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "code" | "type" | "isPrivate" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "-name" | "-code" | "-type" | "-isPrivate" | "-latitude" | "-longitude" | "-elevation_m" | "-elevationDatum" | "-adminArea1" | "-adminArea2" | "-country")[] | null; + sortby?: ("name" | "code" | "type" | "isPrivate" | "latitude" | "longitude" | "elevation_m" | "elevationDatum" | "adminArea1" | "adminArea2" | "country" | "-name" | "-code" | "-type" | "-isPrivate" | "-latitude" | "-longitude" | "-elevation_m" | "-elevationDatum" | "-adminArea1" | "-adminArea2" | "-country" | "+name" | "+code" | "+type" | "+isPrivate" | "+latitude" | "+longitude" | "+elevation_m" | "+elevationDatum" | "+adminArea1" | "+adminArea2" | "+country")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter monitoring sites by workspace ID. */ @@ -12027,7 +12027,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt")[] | null; + sortby?: ("id" | "name" | "monitoringSiteId" | "monitoringSiteName" | "workspaceId" | "workspaceName" | "latestRunStatus" | "latestRunStartedAt" | "latestRunFinishedAt" | "-id" | "-name" | "-monitoringSiteId" | "-monitoringSiteName" | "-workspaceId" | "-workspaceName" | "-latestRunStatus" | "-latestRunStartedAt" | "-latestRunFinishedAt" | "+id" | "+name" | "+monitoringSiteId" | "+monitoringSiteName" | "+workspaceId" | "+workspaceName" | "+latestRunStatus" | "+latestRunStartedAt" | "+latestRunFinishedAt")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter monitoring tasks by monitoring_site ID. */ @@ -12300,7 +12300,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt")[]; + sortby?: ("id" | "status" | "startedAt" | "finishedAt" | "-id" | "-status" | "-startedAt" | "-finishedAt" | "+id" | "+status" | "+startedAt" | "+finishedAt")[]; /** @description Filters task runs by their status. */ status?: ("PENDING" | "STARTED" | "SUCCESS" | "FAILURE")[]; /** @description Filters for task runs started on or before this date and time. */ @@ -12567,7 +12567,7 @@ export interface operations { /** @description Filter observations by datastream ID. */ datastream_id?: string[]; /** @description Select one or more fields to sort the response by. */ - sortby?: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId")[] | null; + sortby?: ("phenomenonTime" | "datastreamId" | "-phenomenonTime" | "-datastreamId" | "+phenomenonTime" | "+datastreamId")[]; /** @description Controls the format of the observations response. */ format?: ("record" | "row" | "column") | null; /** @description Filter observations by result qualifier code. */ @@ -12776,7 +12776,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "type" | "code" | "-name" | "-type" | "-code")[] | null; + sortby?: ("name" | "type" | "code" | "-name" | "-type" | "-code" | "+name" | "+type" | "+code")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter observed properties by workspace ID. */ @@ -13042,7 +13042,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -13271,7 +13271,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("code" | "name" | "-code" | "-name")[] | null; + sortby?: ("code" | "name" | "-code" | "-name" | "+code" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter processing levels by workspace ID. */ @@ -13535,7 +13535,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd")[] | null; + sortby?: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "+id" | "+createdAt" | "+phenomenonTimeStart" | "+phenomenonTimeEnd")[]; /** @description Filter histories by managed datastream ID. */ managed_datastream_id?: string[]; /** @description Filter histories by source datastream ID. */ @@ -13749,7 +13749,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "status" | "committedAt" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "-status" | "-committedAt")[] | null; + sortby?: ("id" | "createdAt" | "phenomenonTimeStart" | "phenomenonTimeEnd" | "status" | "committedAt" | "-id" | "-createdAt" | "-phenomenonTimeStart" | "-phenomenonTimeEnd" | "-status" | "-committedAt" | "+id" | "+createdAt" | "+phenomenonTimeStart" | "+phenomenonTimeEnd" | "+status" | "+committedAt")[]; status?: ("in_progress" | "committed") | null; /** @description Return sessions overlapping with this range start. */ range_start?: string | null; @@ -14099,7 +14099,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "order" | "operationType" | "createdAt" | "-id" | "-order" | "-operationType" | "-createdAt")[] | null; + sortby?: ("id" | "order" | "operationType" | "createdAt" | "-id" | "-order" | "-operationType" | "-createdAt" | "+id" | "+order" | "+operationType" | "+createdAt")[]; }; header?: never; path: { @@ -14392,7 +14392,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter terms by workspace ID. */ @@ -14625,7 +14625,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Filter roles by workspace ID. */ workspace_id?: (string | "null")[]; }; @@ -14714,7 +14714,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -14943,7 +14943,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "-name")[] | null; + sortby?: ("name" | "-name" | "+name")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; }; @@ -15172,7 +15172,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "symbol" | "type" | "-name" | "-symbol" | "-type")[] | null; + sortby?: ("name" | "symbol" | "type" | "-name" | "-symbol" | "-type" | "+name" | "+symbol" | "+type")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Filter units by workspace ID. */ @@ -15429,7 +15429,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "isPrivate" | "-name" | "-isPrivate")[] | null; + sortby?: ("name" | "isPrivate" | "-name" | "-isPrivate" | "+name" | "+isPrivate")[]; /** @description Full-text search query. Comma-separated terms are combined with OR; whitespace-separated words within a term are combined with AND. */ q?: string | null; /** @description Whether the workspace is associated with the authenticated user */ @@ -15671,7 +15671,7 @@ export interface operations { /** @description Filter collaborators by role ID. */ role_id?: string[]; /** @description Select one or more fields to sort the response by. */ - sortby?: ("id" | "roleId" | "-id" | "-roleId")[] | null; + sortby?: ("id" | "roleId" | "-id" | "-roleId" | "+id" | "+roleId")[]; }; header?: never; path: { @@ -15877,7 +15877,7 @@ export interface operations { /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ limit?: number; /** @description Select one or more fields to sort the response by. */ - sortby?: ("name" | "isActive" | "keyExpiresAt" | "createdAt" | "lastUsedAt" | "-name" | "-isActive" | "-keyExpiresAt" | "-createdAt" | "-lastUsedAt")[] | null; + sortby?: ("name" | "isActive" | "keyExpiresAt" | "createdAt" | "lastUsedAt" | "-name" | "-isActive" | "-keyExpiresAt" | "-createdAt" | "-lastUsedAt" | "+name" | "+isActive" | "+keyExpiresAt" | "+createdAt" | "+lastUsedAt")[]; }; header?: never; path: { From 730880098ebeba8b159098d9e828991c4042dcf8 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Thu, 24 Sep 2026 19:54:04 -0700 Subject: [PATCH 14/25] Fixed sortby bug in processing apps --- django/interfaces/api/services/etl/task.py | 3 +- .../api/services/monitoring/task.py | 3 +- .../interfaces/api/services/products/task.py | 3 +- .../api/test_data_product_task_views.py | 30 +++++++++++++++++++ .../interfaces/api/test_etl_task_views.py | 28 +++++++++++++++++ .../api/test_monitoring_task_views.py | 30 +++++++++++++++++++ django/tests/interfaces/api/test_sortby.py | 16 ++++++++++ 7 files changed, 110 insertions(+), 3 deletions(-) diff --git a/django/interfaces/api/services/etl/task.py b/django/interfaces/api/services/etl/task.py index 55826273..1d6ca336 100644 --- a/django/interfaces/api/services/etl/task.py +++ b/django/interfaces/api/services/etl/task.py @@ -4,6 +4,7 @@ from django.db import transaction from django.db.models import Count from django.contrib.auth import get_user_model +from pydantic.alias_generators import to_snake from core.types import Unset from core.iam.models import ServiceAccount @@ -103,7 +104,7 @@ def list( "latest_run_finished_at_min", "latest_run_finished_at_max", ] if any(field in filtering for field in latest_run_fields) or any( - term.lstrip("-") in self.latest_run_filter_fields for term in sortby + to_snake(term.lstrip("-")) in self.latest_run_filter_fields for term in sortby ): queryset = self.annotate_latest_run(queryset, fields=self.latest_run_filter_fields) diff --git a/django/interfaces/api/services/monitoring/task.py b/django/interfaces/api/services/monitoring/task.py index 5eb569c8..0a761db1 100644 --- a/django/interfaces/api/services/monitoring/task.py +++ b/django/interfaces/api/services/monitoring/task.py @@ -4,6 +4,7 @@ from django.db import transaction from django.db.models import Count from django.contrib.auth import get_user_model +from pydantic.alias_generators import to_snake from core.types import Unset from core.iam.models import ServiceAccount @@ -136,7 +137,7 @@ def list( sortby = sortby or [] if "latest_run_status" in filtering or any( - term.lstrip("-") in self.latest_run_filter_fields for term in sortby + to_snake(term.lstrip("-")) in self.latest_run_filter_fields for term in sortby ): queryset = self.annotate_latest_run(queryset, fields=self.latest_run_filter_fields) diff --git a/django/interfaces/api/services/products/task.py b/django/interfaces/api/services/products/task.py index d4e65fe4..dd772cab 100644 --- a/django/interfaces/api/services/products/task.py +++ b/django/interfaces/api/services/products/task.py @@ -4,6 +4,7 @@ from typing import Optional, Literal, get_args from django.db import transaction from django.contrib.auth import get_user_model +from pydantic.alias_generators import to_snake from core.types import Unset from core.iam.models import ServiceAccount @@ -133,7 +134,7 @@ def list( sortby = sortby or [] if "latest_run_status" in filtering or any( - term.lstrip("-") in self.latest_run_filter_fields for term in sortby + to_snake(term.lstrip("-")) in self.latest_run_filter_fields for term in sortby ): queryset = self.annotate_latest_run(queryset, fields=self.latest_run_filter_fields) diff --git a/django/tests/interfaces/api/test_data_product_task_views.py b/django/tests/interfaces/api/test_data_product_task_views.py index 93244349..db0b4b40 100644 --- a/django/tests/interfaces/api/test_data_product_task_views.py +++ b/django/tests/interfaces/api/test_data_product_task_views.py @@ -1,8 +1,10 @@ +from datetime import timedelta from unittest.mock import patch import pytest from django.db import connection from django.test.utils import CaptureQueriesContext +from django.utils import timezone from processing.orchestration.models import TaskRun from tests.core.iam.factories import ( @@ -427,3 +429,31 @@ def test_get_data_product_task_runs_ignores_unsupported_include(client): response = client.get(f"{_detail_url(task.id)}/runs", {"include": "bogus"}) assert response.status_code == 200 + + +@pytest.mark.parametrize( + "sortby, expected", + [ + ("latestRunStartedAt", ["older", "newer"]), + ("-latestRunStartedAt", ["newer", "older"]), + ("latestRunFinishedAt", ["older", "newer"]), + ("latestRunStatus", ["newer", "older"]), + ], +) +def test_get_data_product_tasks_sorts_by_latest_run_fields(client, sortby, expected): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + now = timezone.now() + tasks = {"older": _make_data_product_task(workspace), "newer": _make_data_product_task(workspace)} + TaskRun.objects.create( + task=tasks["older"], status="SUCCESS", started_at=now - timedelta(hours=2), + finished_at=now - timedelta(hours=1), + ) + TaskRun.objects.create(task=tasks["newer"], status="FAILURE", started_at=now, finished_at=now) + client.force_login(owner) + + response = client.get(DATA_PRODUCT_TASKS_URL, {"sortby": sortby}) + + assert response.status_code == 200 + ids = [task["id"] for task in response.json()["data"]] + assert ids == [str(tasks[name].id) for name in expected] diff --git a/django/tests/interfaces/api/test_etl_task_views.py b/django/tests/interfaces/api/test_etl_task_views.py index 45a80429..6012680d 100644 --- a/django/tests/interfaces/api/test_etl_task_views.py +++ b/django/tests/interfaces/api/test_etl_task_views.py @@ -462,3 +462,31 @@ def test_get_etl_task_runs_ignores_unsupported_include(client): response = client.get(f"{_detail_url(task.id)}/runs", {"include": "bogus"}) assert response.status_code == 200 + + +@pytest.mark.parametrize( + "sortby, expected", + [ + ("latestRunStartedAt", ["older", "newer"]), + ("-latestRunStartedAt", ["newer", "older"]), + ("latestRunFinishedAt", ["older", "newer"]), + ("latestRunStatus", ["newer", "older"]), + ], +) +def test_get_etl_tasks_sorts_by_latest_run_fields(client, sortby, expected): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + now = timezone.now() + tasks = {"older": _make_etl_task(workspace), "newer": _make_etl_task(workspace)} + TaskRun.objects.create( + task=tasks["older"], status="SUCCESS", started_at=now - timedelta(hours=2), + finished_at=now - timedelta(hours=1), + ) + TaskRun.objects.create(task=tasks["newer"], status="FAILURE", started_at=now, finished_at=now) + client.force_login(owner) + + response = client.get(ETL_TASKS_URL, {"sortby": sortby}) + + assert response.status_code == 200 + ids = [task["id"] for task in response.json()["data"]] + assert ids == [str(tasks[name].id) for name in expected] diff --git a/django/tests/interfaces/api/test_monitoring_task_views.py b/django/tests/interfaces/api/test_monitoring_task_views.py index 0c39ba5c..6f7c1f3a 100644 --- a/django/tests/interfaces/api/test_monitoring_task_views.py +++ b/django/tests/interfaces/api/test_monitoring_task_views.py @@ -1,8 +1,10 @@ +from datetime import timedelta from unittest.mock import patch import pytest from django.db import connection from django.test.utils import CaptureQueriesContext +from django.utils import timezone from processing.orchestration.models import TaskRun from tests.core.iam.factories import ( @@ -392,3 +394,31 @@ def test_get_monitoring_task_runs_ignores_unsupported_include(client): response = client.get(f"{_detail_url(task.id)}/runs", {"include": "bogus"}) assert response.status_code == 200 + + +@pytest.mark.parametrize( + "sortby, expected", + [ + ("latestRunStartedAt", ["older", "newer"]), + ("-latestRunStartedAt", ["newer", "older"]), + ("latestRunFinishedAt", ["older", "newer"]), + ("latestRunStatus", ["newer", "older"]), + ], +) +def test_get_monitoring_tasks_sorts_by_latest_run_fields(client, sortby, expected): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + now = timezone.now() + tasks = {"older": _make_monitoring_task(workspace), "newer": _make_monitoring_task(workspace)} + TaskRun.objects.create( + task=tasks["older"], status="SUCCESS", started_at=now - timedelta(hours=2), + finished_at=now - timedelta(hours=1), + ) + TaskRun.objects.create(task=tasks["newer"], status="FAILURE", started_at=now, finished_at=now) + client.force_login(owner) + + response = client.get(MONITORING_TASKS_URL, {"sortby": sortby}) + + assert response.status_code == 200 + ids = [task["id"] for task in response.json()["data"]] + assert ids == [str(tasks[name].id) for name in expected] diff --git a/django/tests/interfaces/api/test_sortby.py b/django/tests/interfaces/api/test_sortby.py index 05d2bcd2..a43003d7 100644 --- a/django/tests/interfaces/api/test_sortby.py +++ b/django/tests/interfaces/api/test_sortby.py @@ -81,6 +81,22 @@ def test_get_task_runs_accepts_a_plus_prefix(client): assert [run["id"] for run in response.json()["data"]] == [str(older.id), str(newer.id)] +def test_get_etl_tasks_accepts_a_plus_prefix_on_latest_run_fields(client): + owner = UserFactory() + now = timezone.now() + older = _etl_task(owner) + TaskRun.objects.create(task=older, status="SUCCESS", started_at=now - timedelta(hours=1)) + newer = _etl_task(owner) + TaskRun.objects.create(task=newer, status="SUCCESS", started_at=now) + client.force_login(owner) + + response = client.get("/api/ogc/collections/etl-tasks/items?sortby=%2BlatestRunStartedAt") + + assert response.status_code == 200 + ids = [task["id"] for task in response.json()["data"]] + assert ids.index(str(older.id)) < ids.index(str(newer.id)) + + def test_every_sortby_parameter_is_comma_separated_with_plus_and_minus_prefixes(): paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] sortby_parameters = [ From 030b52ce1f33086359b483a739f655daae77c140 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Fri, 25 Sep 2026 10:51:54 -0700 Subject: [PATCH 15/25] Reject unknown query params in OGC API --- django/interfaces/api/http/query_params.py | 62 +++++++++ django/interfaces/api/urls.py | 2 + .../tests/interfaces/api/test_query_params.py | 127 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 django/interfaces/api/http/query_params.py create mode 100644 django/tests/interfaces/api/test_query_params.py diff --git a/django/interfaces/api/http/query_params.py b/django/interfaces/api/http/query_params.py new file mode 100644 index 00000000..e4d1a413 --- /dev/null +++ b/django/interfaces/api/http/query_params.py @@ -0,0 +1,62 @@ +from functools import wraps +from typing import Callable + +from django.http import HttpRequest, HttpResponseBase +from ninja.operation import Operation + + +def declared_query_params(operation: Operation) -> frozenset[str]: + """ + Returns the query parameter names an operation declares, as they appear in the request URI + and in the OpenAPI document (aliases such as 'format', not the Python field names). + """ + + names: set[str] = set() + + for model in operation.models: + if getattr(model, "__ninja_param_source__", None) != "query": + continue + + flatten_map = getattr(model, "__ninja_flatten_map__", None) + if flatten_map is None: + raise TypeError( + f"Cannot determine the query parameters of '{operation.view_func.__name__}': " + f"{model.__name__} has no flatten map." + ) + + names.update(flatten_map) + + return frozenset(names) + + +def reject_unknown_query_params(run: Callable[..., HttpResponseBase]) -> Callable[..., HttpResponseBase]: + """ + Ninja view-mode decorator that responds 400 to requests with query parameters the operation + doesn't declare (OGC API - Features Core Req 8, /req/core/query-param-unknown). + + Ninja ignores undeclared query parameters, so without this a misspelled filter such as + '?workspaceId=' silently returns unfiltered results. View-mode decorators wrap the bound + Operation.run, which is how the operation's declared parameters are found. + """ + + operation = getattr(run, "__self__", None) + if not isinstance(operation, Operation): + raise TypeError( + "reject_unknown_query_params must be registered with mode='view' before any other " + "view decorator, so it wraps Operation.run directly." + ) + + allowed = declared_query_params(operation) + + @wraps(run) + def wrapper(request: HttpRequest, **kwargs) -> HttpResponseBase: + unknown = sorted(set(request.GET) - allowed) + + if unknown: + message = f"Unknown query parameter(s): {', '.join(unknown)}." + message += f" Allowed: {', '.join(sorted(allowed))}." if allowed else " This operation accepts none." + return operation.api.create_response(request, {"message": message}, status=400) + + return run(request, **kwargs) + + return wrapper diff --git a/django/interfaces/api/urls.py b/django/interfaces/api/urls.py index 5252fff8..1d2fef94 100644 --- a/django/interfaces/api/urls.py +++ b/django/interfaces/api/urls.py @@ -6,6 +6,7 @@ from hydroserver import __version__ from interfaces.api.http import handlers from interfaces.api.http.api import HydroServerNinjaAPI +from interfaces.api.http.query_params import reject_unknown_query_params from interfaces.api.http.renderer import ORJSONRenderer from interfaces.api.views import ( @@ -54,6 +55,7 @@ ) handlers.register(api) +api.add_decorator(reject_unknown_query_params, mode="view") api.add_router("collections/workspaces", workspace_router) api.add_router("collections/roles", role_router) diff --git a/django/tests/interfaces/api/test_query_params.py b/django/tests/interfaces/api/test_query_params.py new file mode 100644 index 00000000..1def47ce --- /dev/null +++ b/django/tests/interfaces/api/test_query_params.py @@ -0,0 +1,127 @@ +import re +import uuid + +import pytest + +from interfaces.api.http.query_params import declared_query_params +from interfaces.api.urls import api +from tests.core.sta.factories import DatastreamFactory, ObservationFactory + +pytestmark = pytest.mark.django_db + +# Query parameters an operation doesn't declare get a 400 (OGC API - Features Core Req 8). + +API_PREFIX = "/api/ogc/" +UNITS_URL = "/api/ogc/collections/units/items" + + +def _operations(): + """Yields (method, OpenAPI-style path, operation) for every operation in the API.""" + + for bound_router in api._get_bound_routers(): + for path, path_view in bound_router.path_operations.items(): + route = "/".join(part.strip("/") for part in (bound_router.prefix, path) if part.strip("/")) + for operation in path_view.operations: + for method in operation.methods: + yield method, f"{API_PREFIX}{route}", operation + + +OPERATIONS = list(_operations()) + + +@pytest.mark.parametrize( + "method, path, operation", + OPERATIONS, + ids=[f"{method} {path.removeprefix(API_PREFIX)}" for method, path, _ in OPERATIONS], +) +def test_every_operation_rejects_an_unknown_query_parameter(client, method, path, operation): + url = re.sub(r"\{[^}]+\}", str(uuid.uuid4()), path) + + response = client.generic(method, f"{url}?notAParameter=1") + + assert response.status_code == 400 + assert "notAParameter" in response.json()["message"] + + +def test_declared_query_parameters_match_the_openapi_document(): + paths = api.get_openapi_schema(path_prefix=API_PREFIX)["paths"] + + for method, path, operation in OPERATIONS: + documented = { + parameter["name"] + for parameter in paths[path][method.lower()].get("parameters", []) + if parameter["in"] == "query" + } + + assert declared_query_params(operation) == documented, f"{method} {path}" + + +def test_unknown_parameter_message_lists_the_allowed_parameters(client): + response = client.get(UNITS_URL, {"sortBy": "name"}) + + assert response.status_code == 400 + assert response.json()["message"].startswith("Unknown query parameter(s): sortBy. Allowed: ") + assert "sortby" in response.json()["message"] + + +def test_parameter_names_are_case_sensitive(client): + response = client.get(UNITS_URL, {"Limit": 5}) + + assert response.status_code == 400 + + +def test_unknown_parameter_without_a_value_is_rejected(client): + response = client.get(f"{UNITS_URL}?foo") + + assert response.status_code == 400 + + +def test_all_unknown_parameters_are_reported(client): + response = client.get(UNITS_URL, {"foo": 1, "bar": 2, "limit": 5}) + + assert response.status_code == 400 + assert "bar, foo." in response.json()["message"] + + +def test_declared_parameters_are_accepted(client): + response = client.get(UNITS_URL, {"limit": 5, "offset": 0, "sortby": "-name,+symbol", "properties": "name"}) + + assert response.status_code == 200 + + +def test_repeated_declared_parameters_are_accepted(client): + response = client.get(f"{UNITS_URL}?properties=name&properties=symbol") + + assert response.status_code == 200 + + +def test_aliased_parameters_are_accepted(client): + datastream = DatastreamFactory() + ObservationFactory(datastream=datastream) + + response = client.get( + "/api/ogc/collections/observations/items", + {"datastream_id": str(datastream.id), "format": "row", "result_qualifier_code": "A"}, + ) + + assert response.status_code == 200 + + +def test_invalid_values_of_declared_parameters_keep_their_validation_error(client): + response = client.get(UNITS_URL, {"limit": "abc"}) + + assert response.status_code == 400 + assert response.json()["message"] == "Input should be a valid integer, unable to parse string as an integer" + + +def test_operation_without_query_parameters_rejects_any(client): + response = client.get("/api/ogc/collections/monitoring-sites/site-type-icons", {"foo": 1}) + + assert response.status_code == 400 + assert response.json()["message"] == "Unknown query parameter(s): foo. This operation accepts none." + + +def test_operation_without_query_parameters_accepts_none(client): + response = client.get("/api/ogc/collections/monitoring-sites/site-type-icons") + + assert response.status_code == 200 From 79dd2edc276d6bbf21fd0646e2ac602b6998b345 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Fri, 25 Sep 2026 11:34:21 -0700 Subject: [PATCH 16/25] Add self and pagination links to API responses --- django/contracts/openapi/ogc.openapi.json | 546 ++++++++++++++++++ django/interfaces/api/http/links.py | 74 +++ django/interfaces/api/schemas/base.py | 24 + .../interfaces/api/schemas/sta/observation.py | 15 + .../interfaces/api/test_collection_extent.py | 12 +- django/tests/interfaces/api/test_links.py | 199 +++++++ .../src/generated/ogc.types.d.ts | 331 +++++++++++ 7 files changed, 1199 insertions(+), 2 deletions(-) create mode 100644 django/interfaces/api/http/links.py create mode 100644 django/tests/interfaces/api/test_links.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 21e47e59..17151f7d 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -3898,6 +3898,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -3925,6 +3933,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -3952,6 +3968,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -3979,6 +4003,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4006,6 +4038,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4033,6 +4073,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4060,6 +4108,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4087,6 +4143,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4114,6 +4178,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4141,6 +4213,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4168,6 +4248,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4195,6 +4283,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4222,6 +4318,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4249,6 +4353,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4276,6 +4388,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4303,6 +4423,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4330,6 +4458,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4357,6 +4493,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4384,6 +4528,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4411,6 +4563,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4438,6 +4598,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4465,6 +4633,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4492,6 +4668,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4519,6 +4703,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4546,6 +4738,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4573,6 +4773,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4600,6 +4808,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4627,6 +4843,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4654,6 +4878,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4681,6 +4913,14 @@ } ], "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" } }, "required": [ @@ -4785,6 +5025,40 @@ "title": "JSONPayloadResponse", "type": "object" }, + "Link": { + "properties": { + "href": { + "title": "Href", + "type": "string" + }, + "rel": { + "title": "Rel", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "href", + "rel", + "type" + ], + "title": "Link", + "type": "object" + }, "LinkedResourceGetResponse": { "properties": { "description": { @@ -7688,6 +7962,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8012,6 +8294,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8511,6 +8801,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8546,6 +8844,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8581,6 +8887,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8616,6 +8930,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8651,6 +8973,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8686,6 +9016,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8721,6 +9059,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8756,6 +9102,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8791,6 +9145,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8826,6 +9188,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8861,6 +9231,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8896,6 +9274,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8931,6 +9317,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -8966,6 +9360,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9001,6 +9403,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9036,6 +9446,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9071,6 +9489,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9106,6 +9532,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9141,6 +9575,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9176,6 +9618,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9211,6 +9661,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9246,6 +9704,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9281,6 +9747,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9316,6 +9790,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9351,6 +9833,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9386,6 +9876,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9421,6 +9919,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9456,6 +9962,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9491,6 +10005,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9526,6 +10048,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9561,6 +10091,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } @@ -9596,6 +10134,14 @@ ], "title": "Included" }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, "meta": { "$ref": "#/components/schemas/PaginationMeta" } diff --git a/django/interfaces/api/http/links.py b/django/interfaces/api/http/links.py new file mode 100644 index 00000000..36ade3db --- /dev/null +++ b/django/interfaces/api/http/links.py @@ -0,0 +1,74 @@ +from typing import Optional + +from django.conf import settings +from django.http import HttpRequest +from ninja import Schema +from pydantic import SerializationInfo + +JSON_MEDIA_TYPE = "application/json" + + +class Link(Schema): + href: str + rel: str + type: str + title: Optional[str] = None + + +def build_url(request: HttpRequest, query_string: str) -> str: + """ + Builds an absolute URL for the request's path from PROXY_BASE_URL, which is the public URL + of Django's root, so links are correct behind a proxy regardless of the request's Host. + """ + + url = settings.PROXY_BASE_URL.rstrip("/") + request.path_info + + return f"{url}?{query_string}" if query_string else url + + +def build_self_link(request: HttpRequest) -> Link: + """A link to this response document (OGC API - Features Core Req 28 and 35).""" + + return Link( + href=build_url(request, request.META.get("QUERY_STRING", "")), + rel="self", + type=JSON_MEDIA_TYPE, + ) + + +def build_page_link(request: HttpRequest, rel: str, offset: int, limit: int) -> Link: + query = request.GET.copy() + query["offset"] = str(offset) + query["limit"] = str(limit) + + return Link(href=build_url(request, query.urlencode()), rel=rel, type=JSON_MEDIA_TYPE) + + +def build_page_links(request: HttpRequest, offset: int, limit: int, returned: int) -> list[Link]: + """ + Returns the self-link plus next and prev links for a page of results (OGC API - Features + Core Req 28, Recs 17-19 and Permission 7). + + A next link is included when the page is full rather than by comparing against the total + count, which can be a Postgres estimate or a stored value count that runs low. At worst the + last next link leads to an empty page, which Core allows. + """ + + links = [build_self_link(request)] + + if limit <= 0: + return links + + if returned >= limit: + links.append(build_page_link(request, "next", offset + limit, limit)) + + if offset > 0: + links.append(build_page_link(request, "prev", max(0, offset - limit), limit)) + + return links + + +def get_request(info: SerializationInfo) -> Optional[HttpRequest]: + """Returns the request Ninja passes in the serialization context, if any.""" + + return (info.context or {}).get("request") if info.context else None diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index 21f7cb4e..f04b2914 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -16,6 +16,7 @@ ) from core.types import Unset +from interfaces.api.http.links import Link, build_page_links, build_self_link, get_request MAX_LIMIT = 100000 @@ -200,6 +201,7 @@ class PaginationMeta(Schema): class ItemResponse(Schema, Generic[T]): included: Optional[dict[str, list[Any]]] = None data: T + links: list[Link] = [] model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) @@ -213,13 +215,31 @@ def _finalize(self, handler, info: SerializationInfo): if requested is not None: data["data"] = filter_requested_properties(data.get("data"), requested) + request = get_request(info) + if request is not None: + data["links"] = [build_self_link(request).model_dump(exclude_none=True)] + return data +def page_links(info: SerializationInfo, meta: PaginationMeta, returned: int) -> Optional[list[dict]]: + """Serialized self, next and prev links for a page of results, or None without a request.""" + + request = get_request(info) + if request is None: + return None + + return [ + link.model_dump(exclude_none=True) + for link in build_page_links(request, meta.offset, meta.limit, returned) + ] + + class PaginatedResponse(Schema, Generic[T]): included: Optional[dict[str, list[Any]]] = None data: list[T] meta: PaginationMeta + links: list[Link] = [] model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) @@ -236,6 +256,10 @@ def _finalize(self, handler, info: SerializationInfo): if requested is not None and isinstance(data.get("data"), list): data["data"] = [filter_requested_properties(item, requested) for item in data["data"]] + links = page_links(info, self.meta, len(self.data)) + if links is not None: + data["links"] = links + return data diff --git a/django/interfaces/api/schemas/sta/observation.py b/django/interfaces/api/schemas/sta/observation.py index 96e6f61d..6e87823a 100644 --- a/django/interfaces/api/schemas/sta/observation.py +++ b/django/interfaces/api/schemas/sta/observation.py @@ -22,6 +22,8 @@ split_sortby, sortby_array_schema, ) +from interfaces.api.schemas.base import page_links +from interfaces.api.http.links import Link from interfaces.api.schemas.sta.result_qualifier import ResultQualifierResponse @@ -130,6 +132,7 @@ class ObservationFormatResponse(Schema, Generic[T]): data: T meta: PaginationMeta included: Optional[dict[str, list[Any]]] = None + links: list[Link] = [] model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) @@ -142,6 +145,10 @@ def _finalize(self, handler, info: SerializationInfo): if not data.get("included"): data.pop("included", None) + links = page_links(info, self.meta, self.data.row_count) + if links is not None: + data["links"] = links + return data @@ -149,12 +156,20 @@ class ObservationRowData(BaseGetResponse): fields: list[Literal["phenomenonTime", "result", "resultQualifierCodes"]] rows: list[list] + @property + def row_count(self) -> int: + return len(self.rows) + class ObservationColumnarData(BaseGetResponse): phenomenon_time: list result: list result_qualifier_codes: list + @property + def row_count(self) -> int: + return len(self.phenomenon_time) + class ObservationRowResponse(ObservationFormatResponse[ObservationRowData]): pass diff --git a/django/tests/interfaces/api/test_collection_extent.py b/django/tests/interfaces/api/test_collection_extent.py index 3b5ca9ac..517e26bb 100644 --- a/django/tests/interfaces/api/test_collection_extent.py +++ b/django/tests/interfaces/api/test_collection_extent.py @@ -72,6 +72,14 @@ def _items_url(collection): return f"/api/ogc/collections/{collection}/items" +def _without_links(response): + """The response body without its links, whose self href echoes the request's query string.""" + + body = response.json() + body.pop("links", None) + return body + + @pytest.fixture def owner_workspace(client): owner = UserFactory() @@ -94,7 +102,7 @@ def test_non_spatial_collection_ignores_a_valid_bbox(client, owner_workspace, co filtered = client.get(_items_url(collection), {"bbox": "170,-20,-170,-10"}) assert filtered.status_code == 200 - assert filtered.json() == unfiltered.json() + assert _without_links(filtered) == _without_links(unfiltered) @pytest.mark.parametrize("collection", ITEM_FACTORIES) @@ -121,7 +129,7 @@ def test_non_temporal_collection_ignores_a_valid_datetime(client, owner_workspac filtered = client.get(_items_url(collection), {"datetime": "1900-01-01T00:00:00Z/1900-01-02T00:00:00Z"}) assert filtered.status_code == 200 - assert filtered.json() == unfiltered.json() + assert _without_links(filtered) == _without_links(unfiltered) @pytest.mark.parametrize("collection", ITEM_FACTORIES) diff --git a/django/tests/interfaces/api/test_links.py b/django/tests/interfaces/api/test_links.py new file mode 100644 index 00000000..6323a000 --- /dev/null +++ b/django/tests/interfaces/api/test_links.py @@ -0,0 +1,199 @@ +import re + +from urllib.parse import parse_qs, urlsplit + +import pytest + +from django.test import override_settings + +from interfaces.api.schemas import base +from interfaces.api.urls import api +from tests.core.iam.factories import UserFactory +from tests.core.sta.factories import DatastreamFactory, ObservationFactory, UnitFactory + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("proxy_base_url")] + +# List and item responses link to themselves (OGC API - Features Core Req 28 and 35) and pages +# link to the next and previous pages (Core Recs 17-19, Permission 7). Links are built from +# PROXY_BASE_URL, not the request's Host, which the test client sends as "testserver". + +BASE_URL = "https://hydroserver.example.org" +UNITS_PATH = "/api/ogc/collections/units/items" +OBSERVATIONS_PATH = "/api/ogc/collections/observations/items" + + +@pytest.fixture +def proxy_base_url(): + with override_settings(PROXY_BASE_URL=f"{BASE_URL}/"): + yield + + +@pytest.fixture +def max_limit_of_two(monkeypatch): + monkeypatch.setattr(base, "MAX_LIMIT", 2) + + +def _links(response): + return {link["rel"]: link for link in response.json()["links"]} + + +def _query(href): + return parse_qs(urlsplit(href).query) + + +def test_list_self_link_is_the_request_url_on_proxy_base_url(client): + response = client.get(f"{UNITS_PATH}?limit=5&sortby=-name") + + assert response.status_code == 200 + assert _links(response)["self"] == { + "href": f"{BASE_URL}{UNITS_PATH}?limit=5&sortby=-name", + "rel": "self", + "type": "application/json", + } + + +def test_list_self_link_without_a_query_string_has_no_question_mark(client): + response = client.get(UNITS_PATH) + + assert _links(response)["self"]["href"] == f"{BASE_URL}{UNITS_PATH}" + + +def test_item_self_link_is_the_item_url(client): + unit = UnitFactory(global_=True) + + response = client.get(f"{UNITS_PATH}/{unit.id}", {"properties": "name"}) + + assert response.status_code == 200 + assert response.json()["links"] == [ + { + "href": f"{BASE_URL}{UNITS_PATH}/{unit.id}?properties=name", + "rel": "self", + "type": "application/json", + } + ] + + +def test_full_first_page_links_next_but_not_prev(client): + UnitFactory.create_batch(3, global_=True) + + links = _links(client.get(UNITS_PATH, {"limit": 2})) + + assert set(links) == {"self", "next"} + assert _query(links["next"]["href"]) == {"limit": ["2"], "offset": ["2"]} + assert links["next"]["type"] == "application/json" + + +def test_middle_page_links_next_and_prev(client): + UnitFactory.create_batch(5, global_=True) + + links = _links(client.get(UNITS_PATH, {"limit": 2, "offset": 2})) + + assert set(links) == {"self", "next", "prev"} + assert _query(links["next"]["href"])["offset"] == ["4"] + assert _query(links["prev"]["href"])["offset"] == ["0"] + + +def test_partial_last_page_links_prev_but_not_next(client): + UnitFactory.create_batch(3, global_=True) + + links = _links(client.get(UNITS_PATH, {"limit": 2, "offset": 2})) + + assert set(links) == {"self", "prev"} + + +def test_prev_link_does_not_go_below_offset_zero(client): + UnitFactory.create_batch(3, global_=True) + + links = _links(client.get(UNITS_PATH, {"limit": 2, "offset": 1})) + + assert _query(links["prev"]["href"]) == {"limit": ["2"], "offset": ["0"]} + + +def test_zero_limit_links_only_self(client): + UnitFactory(global_=True) + + links = _links(client.get(UNITS_PATH, {"limit": 0})) + + assert set(links) == {"self"} + + +def test_page_links_use_the_limit_that_was_applied(client, max_limit_of_two): + UnitFactory.create_batch(3, global_=True) + + links = _links(client.get(UNITS_PATH, {"limit": 50})) + + assert _query(links["self"]["href"])["limit"] == ["50"] + assert _query(links["next"]["href"])["limit"] == ["2"] + + +def test_page_links_keep_the_other_query_parameters(client): + UnitFactory.create_batch(3, global_=True) + + response = client.get( + f"{UNITS_PATH}?limit=2&properties=name&properties=symbol&sortby=-name,%2Bsymbol" + "&datetime=2024-01-01T00%3A00%3A00Z%2F.." + ) + + assert response.status_code == 200 + assert _query(_links(response)["next"]["href"]) == { + "limit": ["2"], + "offset": ["2"], + "properties": ["name", "symbol"], + "sortby": ["-name,+symbol"], + "datetime": ["2024-01-01T00:00:00Z/.."], + } + + +def test_following_next_links_returns_every_item_once(client): + units = UnitFactory.create_batch(5, global_=True) + seen = [] + url = f"{UNITS_PATH}?limit=2" + + while url: + response = client.get(url) + seen.extend(item["id"] for item in response.json()["data"]) + next_link = _links(response).get("next") + url = next_link["href"].removeprefix(BASE_URL) if next_link else None + + assert sorted(seen) == sorted(str(unit.id) for unit in units) + + +@pytest.mark.parametrize("response_format, key", [("row", "rows"), ("column", "phenomenonTime")]) +def test_observation_formats_link_next_from_their_row_count(client, response_format, key): + datastream = DatastreamFactory() + ObservationFactory.create_batch(3, datastream=datastream) + + response = client.get( + OBSERVATIONS_PATH, + {"datastream_id": str(datastream.id), "format": response_format, "limit": 2}, + ) + + assert response.status_code == 200 + assert len(response.json()["data"][key]) == 2 + assert set(_links(response)) == {"self", "next"} + + +def _collection_items_paths(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + pattern = re.compile(r"/api/ogc/collections/[a-z-]+/items") + + return sorted(path for path in paths if pattern.fullmatch(path) and "get" in paths[path]) + + +@pytest.mark.parametrize("path", _collection_items_paths()) +def test_every_collection_links_to_itself(client, path): + client.force_login(UserFactory()) + + response = client.get(path) + + assert response.status_code == 200 + links = response.json()["links"] + assert _links(response)["self"]["href"] == f"{BASE_URL}{path}" + assert all({"href", "rel", "type"} <= set(link) for link in links) + + +def test_links_are_documented_in_the_openapi_document(): + schemas = api.get_openapi_schema(path_prefix="/api/ogc/")["components"]["schemas"] + + assert schemas["Link"]["required"] == ["href", "rel", "type"] + assert "links" in schemas["PaginatedResponse_UnitResponse_"]["properties"] diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 1a085782..f6a6e112 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -3558,6 +3558,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[DataConnectionResponse] */ ItemResponse_DataConnectionResponse_: { @@ -3566,6 +3571,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[DataProductTaskResponse] */ ItemResponse_DataProductTaskResponse_: { @@ -3574,6 +3584,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[DataProductTransformationResponse] */ ItemResponse_DataProductTransformationResponse_: { @@ -3582,6 +3597,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[DatastreamResponse] */ ItemResponse_DatastreamResponse_: { @@ -3590,6 +3610,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[DatastreamStatusResponse] */ ItemResponse_DatastreamStatusResponse_: { @@ -3598,6 +3623,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[EtlMappingResponse] */ ItemResponse_EtlMappingResponse_: { @@ -3606,6 +3636,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[EtlTaskResponse] */ ItemResponse_EtlTaskResponse_: { @@ -3614,6 +3649,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[LinkedResourceTypeResponse] */ ItemResponse_LinkedResourceTypeResponse_: { @@ -3622,6 +3662,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[MethodResponse] */ ItemResponse_MethodResponse_: { @@ -3630,6 +3675,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[MethodTypeResponse] */ ItemResponse_MethodTypeResponse_: { @@ -3638,6 +3688,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[MonitoringRuleResponse] */ ItemResponse_MonitoringRuleResponse_: { @@ -3646,6 +3701,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[MonitoringSiteResponse] */ ItemResponse_MonitoringSiteResponse_: { @@ -3654,6 +3714,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[MonitoringSiteTypeResponse] */ ItemResponse_MonitoringSiteTypeResponse_: { @@ -3662,6 +3727,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[MonitoringTaskResponse] */ ItemResponse_MonitoringTaskResponse_: { @@ -3670,6 +3740,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[ObservationResponse] */ ItemResponse_ObservationResponse_: { @@ -3678,6 +3753,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[ObservedPropertyResponse] */ ItemResponse_ObservedPropertyResponse_: { @@ -3686,6 +3766,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[ObservedPropertyTypeResponse] */ ItemResponse_ObservedPropertyTypeResponse_: { @@ -3694,6 +3779,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[ProcessingLevelResponse] */ ItemResponse_ProcessingLevelResponse_: { @@ -3702,6 +3792,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[QualityControlHistoryResponse] */ ItemResponse_QualityControlHistoryResponse_: { @@ -3710,6 +3805,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[QualityControlOperationResponse] */ ItemResponse_QualityControlOperationResponse_: { @@ -3718,6 +3818,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[QualityControlSessionResponse] */ ItemResponse_QualityControlSessionResponse_: { @@ -3726,6 +3831,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[RatingCurveResponse] */ ItemResponse_RatingCurveResponse_: { @@ -3734,6 +3844,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[ResultQualifierResponse] */ ItemResponse_ResultQualifierResponse_: { @@ -3742,6 +3857,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[RoleResponse] */ ItemResponse_RoleResponse_: { @@ -3750,6 +3870,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[SampledMediumResponse] */ ItemResponse_SampledMediumResponse_: { @@ -3758,6 +3883,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[ServiceAccountResponse] */ ItemResponse_ServiceAccountResponse_: { @@ -3766,6 +3896,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[UnitResponse] */ ItemResponse_UnitResponse_: { @@ -3774,6 +3909,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[UnitTypeResponse] */ ItemResponse_UnitTypeResponse_: { @@ -3782,6 +3922,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** ItemResponse[WorkspaceResponse] */ ItemResponse_WorkspaceResponse_: { @@ -3790,6 +3935,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; }; /** JSONPayloadPostBody */ JSONPayloadPostBody: { @@ -3821,6 +3971,17 @@ export interface components { */ type: "JSON"; }; + /** Link */ + Link: { + /** Href */ + href: string; + /** Rel */ + rel: string; + /** Title */ + title?: string | null; + /** Type */ + type: string; + }; /** LinkedResourceGetResponse */ LinkedResourceGetResponse: { /** Description */ @@ -4857,6 +5018,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** ObservationItemQueryParameters */ @@ -4987,6 +5153,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** ObservedPropertyItemQueryParameters */ @@ -5158,6 +5329,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[CollaboratorResponse] */ @@ -5168,6 +5344,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[DataConnectionResponse] */ @@ -5178,6 +5359,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[DataProductTaskResponse] */ @@ -5188,6 +5374,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[DataProductTransformationResponse] */ @@ -5198,6 +5389,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[DatastreamResponse] */ @@ -5208,6 +5404,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[DatastreamStatusResponse] */ @@ -5218,6 +5419,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[EtlMappingResponse] */ @@ -5228,6 +5434,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[EtlTaskResponse] */ @@ -5238,6 +5449,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[LinkedResourceTypeResponse] */ @@ -5248,6 +5464,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[MethodResponse] */ @@ -5258,6 +5479,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[MethodTypeResponse] */ @@ -5268,6 +5494,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[MonitoringRuleResponse] */ @@ -5278,6 +5509,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[MonitoringSiteResponse] */ @@ -5288,6 +5524,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[MonitoringSiteTypeResponse] */ @@ -5298,6 +5539,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[MonitoringTaskResponse] */ @@ -5308,6 +5554,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[ObservationResponse] */ @@ -5318,6 +5569,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[ObservedPropertyResponse] */ @@ -5328,6 +5584,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[ObservedPropertyTypeResponse] */ @@ -5338,6 +5599,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[ProcessingLevelResponse] */ @@ -5348,6 +5614,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[QualityControlHistoryResponse] */ @@ -5358,6 +5629,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[QualityControlOperationResponse] */ @@ -5368,6 +5644,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[QualityControlSessionResponse] */ @@ -5378,6 +5659,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[RatingCurveResponse] */ @@ -5388,6 +5674,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[ResultQualifierResponse] */ @@ -5398,6 +5689,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[RoleResponse] */ @@ -5408,6 +5704,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[SampledMediumResponse] */ @@ -5418,6 +5719,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[ServiceAccountResponse] */ @@ -5428,6 +5734,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[TaskRunResponse] */ @@ -5438,6 +5749,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[UnitResponse] */ @@ -5448,6 +5764,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[UnitTypeResponse] */ @@ -5458,6 +5779,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginatedResponse[WorkspaceResponse] */ @@ -5468,6 +5794,11 @@ export interface components { included?: { [key: string]: unknown[]; } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; /** PaginationMeta */ From 8236d54d308c7f7f437c5d8a351e2b70cc285add Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Fri, 25 Sep 2026 12:52:35 -0700 Subject: [PATCH 17/25] Create OGC API collection registry --- django/interfaces/api/collections.py | 199 ++++++++++++++++++ django/interfaces/api/urls.py | 66 +----- .../api/test_collection_registry.py | 53 +++++ 3 files changed, 256 insertions(+), 62 deletions(-) create mode 100644 django/interfaces/api/collections.py create mode 100644 django/tests/interfaces/api/test_collection_registry.py diff --git a/django/interfaces/api/collections.py b/django/interfaces/api/collections.py new file mode 100644 index 00000000..59f21f95 --- /dev/null +++ b/django/interfaces/api/collections.py @@ -0,0 +1,199 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class CollectionDefinition: + """ + An OGC API collection, served at /collections/{id} with its items at /collections/{id}/items. + + The router is an import path rather than a Router, so modules the views import (such as the + response schemas) can use the registry without a circular import. + """ + + id: str + title: str + description: str + router: str + item_type: Optional[str] = None + + +VIEWS = "interfaces.api.views" + +COLLECTIONS: tuple[CollectionDefinition, ...] = ( + CollectionDefinition( + id="workspaces", + title="Workspaces", + description="Workspaces that own monitoring sites, datastreams, and other data, and " + "control who can access them.", + router=f"{VIEWS}.iam.workspace.workspace_router", + ), + CollectionDefinition( + id="roles", + title="Roles", + description="Roles that grant collaborators and service accounts permissions within " + "a workspace.", + router=f"{VIEWS}.iam.role.role_router", + ), + CollectionDefinition( + id="monitoring-sites", + title="Monitoring Sites", + description="Locations where observations are collected, with WGS 84 coordinates.", + router=f"{VIEWS}.sta.monitoring_site.monitoring_site_router", + item_type="feature", + ), + CollectionDefinition( + id="monitoring-site-types", + title="Monitoring Site Types", + description="Controlled vocabulary of monitoring site types.", + router=f"{VIEWS}.sta.monitoring_site_type.monitoring_site_type_router", + ), + CollectionDefinition( + id="linked-resource-types", + title="Linked Resource Types", + description="Controlled vocabulary of types for files and links attached to monitoring " + "sites and datastreams.", + router=f"{VIEWS}.sta.linked_resource_type.linked_resource_type_router", + ), + CollectionDefinition( + id="datastreams", + title="Datastreams", + description="Time series of observations of one observed property at a monitoring site.", + router=f"{VIEWS}.sta.datastream.datastream_router", + item_type="feature", + ), + CollectionDefinition( + id="datastream-statuses", + title="Datastream Statuses", + description="Controlled vocabulary of datastream statuses.", + router=f"{VIEWS}.sta.datastream_status.datastream_status_router", + ), + CollectionDefinition( + id="aggregation-statistics", + title="Aggregation Statistics", + description="Controlled vocabulary of statistics used to aggregate datastream results.", + router=f"{VIEWS}.sta.aggregation_statistic.aggregation_statistic_router", + ), + CollectionDefinition( + id="observations", + title="Observations", + description="Individual timestamped results recorded in datastreams.", + router=f"{VIEWS}.sta.observation.observation_router", + item_type="feature", + ), + CollectionDefinition( + id="observed-properties", + title="Observed Properties", + description="Phenomena that datastreams observe, such as discharge or water temperature.", + router=f"{VIEWS}.sta.observed_property.observed_property_router", + ), + CollectionDefinition( + id="observed-property-types", + title="Observed Property Types", + description="Controlled vocabulary of observed property types.", + router=f"{VIEWS}.sta.observed_property_type.observed_property_type_router", + ), + CollectionDefinition( + id="units", + title="Units", + description="Units of measure for datastream results.", + router=f"{VIEWS}.sta.unit.unit_router", + ), + CollectionDefinition( + id="unit-types", + title="Unit Types", + description="Controlled vocabulary of unit types.", + router=f"{VIEWS}.sta.unit_type.unit_type_router", + ), + CollectionDefinition( + id="methods", + title="Methods", + description="Sensors and procedures used to produce datastream results.", + router=f"{VIEWS}.sta.method.method_router", + ), + CollectionDefinition( + id="method-types", + title="Method Types", + description="Controlled vocabulary of method types.", + router=f"{VIEWS}.sta.method_type.method_type_router", + ), + CollectionDefinition( + id="processing-levels", + title="Processing Levels", + description="Levels of quality control and processing applied to datastream results.", + router=f"{VIEWS}.sta.processing_level.processing_level_router", + ), + CollectionDefinition( + id="result-qualifiers", + title="Result Qualifiers", + description="Codes that qualify individual observation results.", + router=f"{VIEWS}.sta.result_qualifier.result_qualifier_router", + ), + CollectionDefinition( + id="sampled-mediums", + title="Sampled Mediums", + description="Controlled vocabulary of the media that datastreams sample, such as surface " + "water or air.", + router=f"{VIEWS}.sta.sampled_medium.sampled_medium_router", + ), + CollectionDefinition( + id="etl-data-connections", + title="ETL Data Connections", + description="Sources that ETL tasks extract data from.", + router=f"{VIEWS}.etl.data_connection.data_connection_router", + ), + CollectionDefinition( + id="etl-tasks", + title="ETL Tasks", + description="Scheduled jobs that load observations from data connections into datastreams.", + router=f"{VIEWS}.etl.task.etl_task_router", + ), + CollectionDefinition( + id="etl-mappings", + title="ETL Mappings", + description="Mappings from source identifiers in extracted data to target datastreams.", + router=f"{VIEWS}.etl.mapping.etl_mapping_router", + ), + CollectionDefinition( + id="data-product-rating-curves", + title="Data Product Rating Curves", + description="Rating curves used by data product transformations.", + router=f"{VIEWS}.products.rating_curve.rating_curve_router", + ), + CollectionDefinition( + id="data-product-tasks", + title="Data Product Tasks", + description="Scheduled jobs that derive datastreams from other datastreams.", + router=f"{VIEWS}.products.task.data_product_task_router", + ), + CollectionDefinition( + id="data-product-transformations", + title="Data Product Transformations", + description="Transformations that data product tasks apply to input datastreams.", + router=f"{VIEWS}.products.transformation.data_product_transformation_router", + ), + CollectionDefinition( + id="monitoring-tasks", + title="Monitoring Tasks", + description="Scheduled jobs that check datastreams against monitoring rules.", + router=f"{VIEWS}.monitoring.task.monitoring_task_router", + ), + CollectionDefinition( + id="monitoring-rules", + title="Monitoring Rules", + description="Conditions that monitoring tasks check datastreams against.", + router=f"{VIEWS}.monitoring.rule.monitoring_rule_router", + ), + CollectionDefinition( + id="quality-control-histories", + title="Quality Control Histories", + description="Records of quality control sessions applied to datastreams.", + router=f"{VIEWS}.quality.history.qc_history_router", + ), +) + +_COLLECTIONS_BY_ID = {collection.id: collection for collection in COLLECTIONS} + + +def get_collection(collection_id: str) -> Optional[CollectionDefinition]: + return _COLLECTIONS_BY_ID.get(collection_id) diff --git a/django/interfaces/api/urls.py b/django/interfaces/api/urls.py index 1d2fef94..fe031181 100644 --- a/django/interfaces/api/urls.py +++ b/django/interfaces/api/urls.py @@ -9,37 +9,8 @@ from interfaces.api.http.query_params import reject_unknown_query_params from interfaces.api.http.renderer import ORJSONRenderer -from interfaces.api.views import ( - workspace_router, - role_router, - monitoring_site_router, - monitoring_site_type_router, - linked_resource_type_router, - observed_property_router, - observed_property_type_router, - processing_level_router, - result_qualifier_router, - sampled_medium_router, - aggregation_statistic_router, - datastream_status_router, - method_router, - method_type_router, - unit_router, - unit_type_router, - datastream_router, - observation_router, - data_connection_router, - etl_task_router, - etl_mapping_router, - rating_curve_router, - data_product_transformation_router, - data_product_task_router, - monitoring_task_router, - monitoring_rule_router, - qc_history_router, - qc_session_router, - qc_operation_router, -) +from interfaces.api.collections import COLLECTIONS +from interfaces.api.views import qc_history_router, qc_session_router, qc_operation_router rate_limits = settings.API_RATE_LIMITS or {} @@ -57,38 +28,9 @@ handlers.register(api) api.add_decorator(reject_unknown_query_params, mode="view") -api.add_router("collections/workspaces", workspace_router) -api.add_router("collections/roles", role_router) - -api.add_router("collections/monitoring-sites", monitoring_site_router) -api.add_router("collections/monitoring-site-types", monitoring_site_type_router) -api.add_router("collections/linked-resource-types", linked_resource_type_router) -api.add_router("collections/datastreams", datastream_router) -api.add_router("collections/datastream-statuses", datastream_status_router) -api.add_router("collections/aggregation-statistics", aggregation_statistic_router) -api.add_router("collections/observations", observation_router) -api.add_router("collections/observed-properties", observed_property_router) -api.add_router("collections/observed-property-types", observed_property_type_router) -api.add_router("collections/units", unit_router) -api.add_router("collections/unit-types", unit_type_router) -api.add_router("collections/methods", method_router) -api.add_router("collections/method-types", method_type_router) -api.add_router("collections/processing-levels", processing_level_router) -api.add_router("collections/result-qualifiers", result_qualifier_router) -api.add_router("collections/sampled-mediums", sampled_medium_router) - -api.add_router("collections/etl-data-connections", data_connection_router) -api.add_router("collections/etl-tasks", etl_task_router) -api.add_router("collections/etl-mappings", etl_mapping_router) - -api.add_router("collections/data-product-rating-curves", rating_curve_router) -api.add_router("collections/data-product-tasks", data_product_task_router) -api.add_router("collections/data-product-transformations", data_product_transformation_router) - -api.add_router("collections/monitoring-tasks", monitoring_task_router) -api.add_router("collections/monitoring-rules", monitoring_rule_router) +for collection in COLLECTIONS: + api.add_router(f"collections/{collection.id}", collection.router) -api.add_router("collections/quality-control-histories", qc_history_router) qc_history_router.add_router( "/items/{history_id}/sessions", qc_session_router, tags=["Quality Control Sessions"] ) diff --git a/django/tests/interfaces/api/test_collection_registry.py b/django/tests/interfaces/api/test_collection_registry.py new file mode 100644 index 00000000..9ea6141e --- /dev/null +++ b/django/tests/interfaces/api/test_collection_registry.py @@ -0,0 +1,53 @@ +import re + +import pytest +from django.utils.module_loading import import_string +from ninja import Router + +from interfaces.api.collections import COLLECTIONS, get_collection +from interfaces.api.urls import api + +COLLECTION_ITEMS_PATH = re.compile(r"/api/ogc/collections/(?P[^/]+)/items") + + +def test_collection_ids_are_unique(): + ids = [collection.id for collection in COLLECTIONS] + + assert len(ids) == len(set(ids)) + + +@pytest.mark.parametrize("collection", COLLECTIONS, ids=lambda collection: collection.id) +def test_collection_id_is_a_url_segment(collection): + assert re.fullmatch(r"[a-z]+(-[a-z]+)*", collection.id) + + +@pytest.mark.parametrize("collection", COLLECTIONS, ids=lambda collection: collection.id) +def test_collection_router_imports_to_a_router(collection): + assert isinstance(import_string(collection.router), Router) + + +@pytest.mark.parametrize("collection", COLLECTIONS, ids=lambda collection: collection.id) +def test_collection_title_matches_its_router_tag(collection): + assert import_string(collection.router).tags == [collection.title] + + +@pytest.mark.parametrize("collection", COLLECTIONS, ids=lambda collection: collection.id) +def test_collection_has_a_description(collection): + assert collection.description.strip() + + +def test_every_collection_items_path_is_registered(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + mounted = { + match["id"] for path in paths if (match := COLLECTION_ITEMS_PATH.fullmatch(path)) + } + + assert mounted == {collection.id for collection in COLLECTIONS} + + +def test_get_collection_returns_the_registered_collection(): + assert get_collection("monitoring-sites").title == "Monitoring Sites" + + +def test_get_collection_returns_none_for_an_unknown_id(): + assert get_collection("not-a-collection") is None From 16006c37e74799534566ccfc28d0c8f57c5ab82f Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Fri, 25 Sep 2026 13:04:23 -0700 Subject: [PATCH 18/25] Added base collections endpoints --- django/contracts/openapi/ogc.openapi.json | 163 ++++++++++++++++++ django/interfaces/api/http/links.py | 24 ++- django/interfaces/api/schemas/ogc.py | 26 +++ django/interfaces/api/urls.py | 3 + django/interfaces/api/views/ogc.py | 67 +++++++ .../interfaces/api/test_collections_views.py | 127 ++++++++++++++ .../src/generated/ogc.types.d.ts | 117 +++++++++++++ 7 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 django/interfaces/api/schemas/ogc.py create mode 100644 django/interfaces/api/views/ogc.py create mode 100644 django/tests/interfaces/api/test_collections_views.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 17151f7d..3cde4a0f 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -378,6 +378,74 @@ "title": "CollaboratorResponse", "type": "object" }, + "CollectionResponse": { + "description": "A collection's metadata (OGC API - Features Core collection.yaml).", + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "itemType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Itemtype" + }, + "links": { + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "title", + "description", + "links" + ], + "title": "CollectionResponse", + "type": "object" + }, + "CollectionsResponse": { + "description": "The collections the API serves (OGC API - Features Core collections.yaml).", + "properties": { + "collections": { + "items": { + "$ref": "#/components/schemas/CollectionResponse" + }, + "title": "Collections", + "type": "array" + }, + "links": { + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + } + }, + "required": [ + "links", + "collections" + ], + "title": "CollectionsResponse", + "type": "object" + }, "CreatedResponse": { "properties": { "id": { @@ -14542,6 +14610,43 @@ }, "openapi": "3.1.0", "paths": { + "/api/ogc/collections": { + "get": { + "description": "Get the collections the API serves.", + "operationId": "interfaces_api_views_ogc_get_collections", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionsResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Collections", + "tags": [ + "Collections" + ] + } + }, "/api/ogc/collections/aggregation-statistics/items": { "get": { "description": "Get Aggregation Statistics.", @@ -36869,6 +36974,64 @@ "Workspaces" ] } + }, + "/api/ogc/collections/{collection_id}": { + "get": { + "description": "Get a collection.", + "operationId": "interfaces_api_views_ogc_get_collection_metadata", + "parameters": [ + { + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "title": "Collection Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionResponse" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Collection Metadata", + "tags": [ + "Collections" + ] + } } }, "servers": [] diff --git a/django/interfaces/api/http/links.py b/django/interfaces/api/http/links.py index 36ade3db..0f172d94 100644 --- a/django/interfaces/api/http/links.py +++ b/django/interfaces/api/http/links.py @@ -2,6 +2,7 @@ from django.conf import settings from django.http import HttpRequest +from django.urls import get_script_prefix, reverse from ninja import Schema from pydantic import SerializationInfo @@ -15,13 +16,28 @@ class Link(Schema): title: Optional[str] = None -def build_url(request: HttpRequest, query_string: str) -> str: +def build_absolute_url(path: str) -> str: """ - Builds an absolute URL for the request's path from PROXY_BASE_URL, which is the public URL - of Django's root, so links are correct behind a proxy regardless of the request's Host. + Builds an absolute URL for a path relative to Django's root from PROXY_BASE_URL, which is the + public URL of Django's root, so links are correct behind a proxy regardless of the request's + Host. """ - url = settings.PROXY_BASE_URL.rstrip("/") + request.path_info + return settings.PROXY_BASE_URL.rstrip("/") + "/" + path.lstrip("/") + + +def build_api_url(path: str = "") -> str: + """Builds an absolute URL for a path relative to the OGC API root, e.g. 'collections/units'.""" + + api_root = reverse("ogc:api-root").removeprefix(get_script_prefix()) + + return build_absolute_url(api_root + path.lstrip("/")) + + +def build_url(request: HttpRequest, query_string: str) -> str: + """Builds an absolute URL for the request's path with the given query string.""" + + url = build_absolute_url(request.path_info) return f"{url}?{query_string}" if query_string else url diff --git a/django/interfaces/api/schemas/ogc.py b/django/interfaces/api/schemas/ogc.py new file mode 100644 index 00000000..ddef3dc6 --- /dev/null +++ b/django/interfaces/api/schemas/ogc.py @@ -0,0 +1,26 @@ +from typing import Optional + +from ninja import Schema +from pydantic import ConfigDict +from pydantic.alias_generators import to_camel + +from interfaces.api.http.links import Link + + +class CollectionResponse(Schema): + """A collection's metadata (OGC API - Features Core collection.yaml).""" + + id: str + title: str + description: str + item_type: Optional[str] = None + links: list[Link] + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class CollectionsResponse(Schema): + """The collections the API serves (OGC API - Features Core collections.yaml).""" + + links: list[Link] + collections: list[CollectionResponse] diff --git a/django/interfaces/api/urls.py b/django/interfaces/api/urls.py index fe031181..1aa8720a 100644 --- a/django/interfaces/api/urls.py +++ b/django/interfaces/api/urls.py @@ -11,6 +11,7 @@ from interfaces.api.collections import COLLECTIONS from interfaces.api.views import qc_history_router, qc_session_router, qc_operation_router +from interfaces.api.views.ogc import ogc_router rate_limits = settings.API_RATE_LIMITS or {} @@ -28,6 +29,8 @@ handlers.register(api) api.add_decorator(reject_unknown_query_params, mode="view") +api.add_router("", ogc_router) + for collection in COLLECTIONS: api.add_router(f"collections/{collection.id}", collection.router) diff --git a/django/interfaces/api/views/ogc.py b/django/interfaces/api/views/ogc.py new file mode 100644 index 00000000..554edab9 --- /dev/null +++ b/django/interfaces/api/views/ogc.py @@ -0,0 +1,67 @@ +from ninja import Router, Path + +from interfaces.auth.security import session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth +from interfaces.api.collections import COLLECTIONS, CollectionDefinition, get_collection +from interfaces.api.http.errors import NotFoundError +from interfaces.api.http.links import JSON_MEDIA_TYPE, Link, build_api_url, build_self_link +from interfaces.api.http.request import HydroServerHttpRequest +from interfaces.api.schemas.ogc import CollectionResponse, CollectionsResponse + +ogc_router = Router(tags=["Collections"]) + + +def build_collection(collection: CollectionDefinition) -> CollectionResponse: + """ + Builds a collection's metadata. Both /collections and /collections/{collectionId} use it, so + the two stay identical (OGC API - Features Core Req 19). + """ + + collection_url = build_api_url(f"collections/{collection.id}") + + return CollectionResponse( + id=collection.id, + title=collection.title, + description=collection.description, + item_type=collection.item_type, + links=[ + Link(href=collection_url, rel="self", type=JSON_MEDIA_TYPE), + Link(href=f"{collection_url}/items", rel="items", type=JSON_MEDIA_TYPE), + ], + ) + + +@ogc_router.get( + "/collections", + auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], + response={200: CollectionsResponse}, + by_alias=True, + exclude_none=True, +) +def get_collections(request: HydroServerHttpRequest): + """ + Get the collections the API serves. + """ + + return 200, CollectionsResponse( + links=[build_self_link(request)], + collections=[build_collection(collection) for collection in COLLECTIONS], + ) + + +@ogc_router.get( + "/collections/{collection_id}", + auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], + response={200: CollectionResponse, 404: str}, + by_alias=True, + exclude_none=True, +) +def get_collection_metadata(request: HydroServerHttpRequest, collection_id: Path[str]): + """ + Get a collection. + """ + + collection = get_collection(collection_id) + if collection is None: + raise NotFoundError(f"Collection '{collection_id}' does not exist") + + return 200, build_collection(collection) diff --git a/django/tests/interfaces/api/test_collections_views.py b/django/tests/interfaces/api/test_collections_views.py new file mode 100644 index 00000000..d1f58872 --- /dev/null +++ b/django/tests/interfaces/api/test_collections_views.py @@ -0,0 +1,127 @@ +import pytest + +from django.test import override_settings + +from interfaces.api.collections import COLLECTIONS +from interfaces.api.urls import api +from tests.core.iam.factories import UserFactory + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("proxy_base_url")] + +# /collections lists every collection and /collections/{collectionId} describes one, each with +# self and items links (OGC API - Features Core Req 11-15, 18-19). Links are built from +# PROXY_BASE_URL, not the request's Host, which the test client sends as "testserver". + +BASE_URL = "https://hydroserver.example.org" +COLLECTIONS_PATH = "/api/ogc/collections" +COLLECTION_IDS = [collection.id for collection in COLLECTIONS] + + +@pytest.fixture +def proxy_base_url(): + with override_settings(PROXY_BASE_URL=BASE_URL): + yield + + +def _links(body): + return {link["rel"]: link for link in body["links"]} + + +def test_get_collections_links_to_itself(client): + response = client.get(COLLECTIONS_PATH) + + assert response.status_code == 200 + assert response.json()["links"] == [ + {"href": f"{BASE_URL}{COLLECTIONS_PATH}", "rel": "self", "type": "application/json"} + ] + + +def test_get_collections_lists_every_registered_collection_in_order(client): + response = client.get(COLLECTIONS_PATH) + + assert [collection["id"] for collection in response.json()["collections"]] == COLLECTION_IDS + + +@pytest.mark.parametrize("collection", COLLECTIONS, ids=lambda collection: collection.id) +def test_collection_describes_the_registered_collection(client, collection): + response = client.get(f"{COLLECTIONS_PATH}/{collection.id}") + + assert response.status_code == 200 + body = response.json() + assert body["id"] == collection.id + assert body["title"] == collection.title + assert body["description"] == collection.description + assert body.get("itemType") == collection.item_type + assert _links(body) == { + "self": { + "href": f"{BASE_URL}{COLLECTIONS_PATH}/{collection.id}", + "rel": "self", + "type": "application/json", + }, + "items": { + "href": f"{BASE_URL}{COLLECTIONS_PATH}/{collection.id}/items", + "rel": "items", + "type": "application/json", + }, + } + + +@pytest.mark.parametrize("collection_id", COLLECTION_IDS) +def test_collection_matches_its_entry_in_collections(client, collection_id): + listed = { + collection["id"]: collection for collection in client.get(COLLECTIONS_PATH).json()["collections"] + } + + response = client.get(f"{COLLECTIONS_PATH}/{collection_id}") + + assert response.json() == listed[collection_id] + + +def test_collection_without_an_item_type_omits_it(client): + response = client.get(f"{COLLECTIONS_PATH}/units") + + assert "itemType" not in response.json() + + +def test_feature_collection_declares_its_item_type(client): + response = client.get(f"{COLLECTIONS_PATH}/monitoring-sites") + + assert response.json()["itemType"] == "feature" + + +@pytest.mark.parametrize("collection_id", COLLECTION_IDS) +def test_collection_items_link_resolves(client, collection_id): + client.force_login(UserFactory()) + items_href = _links(client.get(f"{COLLECTIONS_PATH}/{collection_id}").json())["items"]["href"] + + response = client.get(items_href.removeprefix(BASE_URL)) + + assert response.status_code == 200 + + +def test_get_unknown_collection_returns_404(client): + response = client.get(f"{COLLECTIONS_PATH}/not-a-collection") + + assert response.status_code == 404 + assert response.json() == {"message": "Collection 'not-a-collection' does not exist"} + + +@pytest.mark.parametrize("path", [COLLECTIONS_PATH, f"{COLLECTIONS_PATH}/units"]) +def test_collections_endpoints_reject_unknown_query_parameters(client, path): + response = client.get(path, {"foo": "bar"}) + + assert response.status_code == 400 + + +@pytest.mark.parametrize("path", [COLLECTIONS_PATH, f"{COLLECTIONS_PATH}/units"]) +def test_collections_endpoints_are_public(client, path): + response = client.get(path) + + assert response.status_code == 200 + + +def test_collections_endpoints_are_documented_in_the_openapi_document(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + + assert "get" in paths[COLLECTIONS_PATH] + assert "get" in paths[f"{COLLECTIONS_PATH}/{{collection_id}}"] diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index f6a6e112..17831464 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -4,6 +4,26 @@ */ export interface paths { + "/api/ogc/collections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Collections + * @description Get the collections the API serves. + */ + get: operations["interfaces_api_views_ogc_get_collections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/ogc/collections/aggregation-statistics/items": { parameters: { query?: never; @@ -2122,6 +2142,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ogc/collections/{collection_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Collection Metadata + * @description Get a collection. + */ + get: operations["interfaces_api_views_ogc_get_collection_metadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -2270,6 +2310,32 @@ export interface components { /** Useremail */ userEmail?: string | null; }; + /** + * CollectionResponse + * @description A collection's metadata (OGC API - Features Core collection.yaml). + */ + CollectionResponse: { + /** Description */ + description: string; + /** Id */ + id: string; + /** Itemtype */ + itemType?: string | null; + /** Links */ + links: components["schemas"]["Link"][]; + /** Title */ + title: string; + }; + /** + * CollectionsResponse + * @description The collections the API serves (OGC API - Features Core collections.yaml). + */ + CollectionsResponse: { + /** Collections */ + collections: components["schemas"]["CollectionResponse"][]; + /** Links */ + links: components["schemas"]["Link"][]; + }; /** CreatedResponse */ CreatedResponse: { /** @@ -7352,6 +7418,26 @@ export interface components { } export type $defs = Record; export interface operations { + interfaces_api_views_ogc_get_collections: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionsResponse"]; + }; + }; + }; + }; interfaces_api_views_sta_aggregation_statistic_get_aggregation_statistics: { parameters: { query?: { @@ -16602,4 +16688,35 @@ export interface operations { }; }; }; + interfaces_api_views_ogc_get_collection_metadata: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; } From e0873ec0e981a9003c6c91c4e38083a714e31f75 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Fri, 25 Sep 2026 13:18:19 -0700 Subject: [PATCH 19/25] Update API links on item responses --- django/interfaces/api/http/links.py | 35 ++++++++++++++++-- django/interfaces/api/schemas/base.py | 11 ++++-- django/tests/interfaces/api/test_links.py | 44 +++++++++++++++++++++-- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/django/interfaces/api/http/links.py b/django/interfaces/api/http/links.py index 0f172d94..8411a23c 100644 --- a/django/interfaces/api/http/links.py +++ b/django/interfaces/api/http/links.py @@ -1,3 +1,5 @@ +import re + from typing import Optional from django.conf import settings @@ -6,7 +8,10 @@ from ninja import Schema from pydantic import SerializationInfo +from interfaces.api.collections import get_collection + JSON_MEDIA_TYPE = "application/json" +COLLECTION_ITEM_PATH = re.compile(r"collections/(?P[^/]+)/items/[^/]+") class Link(Schema): @@ -26,12 +31,16 @@ def build_absolute_url(path: str) -> str: return settings.PROXY_BASE_URL.rstrip("/") + "/" + path.lstrip("/") +def api_root_path() -> str: + """The OGC API root relative to Django's root, e.g. 'api/ogc/'.""" + + return reverse("ogc:api-root").removeprefix(get_script_prefix()) + + def build_api_url(path: str = "") -> str: """Builds an absolute URL for a path relative to the OGC API root, e.g. 'collections/units'.""" - api_root = reverse("ogc:api-root").removeprefix(get_script_prefix()) - - return build_absolute_url(api_root + path.lstrip("/")) + return build_absolute_url(api_root_path() + path.lstrip("/")) def build_url(request: HttpRequest, query_string: str) -> str: @@ -52,6 +61,26 @@ def build_self_link(request: HttpRequest) -> Link: ) +def build_collection_link(request: HttpRequest) -> Optional[Link]: + """ + A link to the collection that contains the requested item (OGC API - Features Core Req 35), + or None when the request isn't for /collections/{collectionId}/items/{itemId}, such as a + nested sub-resource whose parent isn't a collection. + """ + + path = request.path_info.lstrip("/").removeprefix(api_root_path()) + match = COLLECTION_ITEM_PATH.fullmatch(path) + + if match is None or get_collection(match["collection_id"]) is None: + return None + + return Link( + href=build_api_url(f"collections/{match['collection_id']}"), + rel="collection", + type=JSON_MEDIA_TYPE, + ) + + def build_page_link(request: HttpRequest, rel: str, offset: int, limit: int) -> Link: query = request.GET.copy() query["offset"] = str(offset) diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index f04b2914..887df85e 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -16,7 +16,13 @@ ) from core.types import Unset -from interfaces.api.http.links import Link, build_page_links, build_self_link, get_request +from interfaces.api.http.links import ( + Link, + build_collection_link, + build_page_links, + build_self_link, + get_request, +) MAX_LIMIT = 100000 @@ -217,7 +223,8 @@ def _finalize(self, handler, info: SerializationInfo): request = get_request(info) if request is not None: - data["links"] = [build_self_link(request).model_dump(exclude_none=True)] + links = [build_self_link(request), build_collection_link(request)] + data["links"] = [link.model_dump(exclude_none=True) for link in links if link is not None] return data diff --git a/django/tests/interfaces/api/test_links.py b/django/tests/interfaces/api/test_links.py index 6323a000..1f14a010 100644 --- a/django/tests/interfaces/api/test_links.py +++ b/django/tests/interfaces/api/test_links.py @@ -1,4 +1,5 @@ import re +import uuid from urllib.parse import parse_qs, urlsplit @@ -6,10 +7,12 @@ from django.test import override_settings +from interfaces.api.collections import COLLECTIONS +from interfaces.api.http.links import build_collection_link from interfaces.api.schemas import base from interfaces.api.urls import api from tests.core.iam.factories import UserFactory -from tests.core.sta.factories import DatastreamFactory, ObservationFactory, UnitFactory +from tests.core.sta.factories import DatastreamFactory, MonitoringSiteFactory, ObservationFactory, UnitFactory pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("proxy_base_url")] @@ -58,7 +61,7 @@ def test_list_self_link_without_a_query_string_has_no_question_mark(client): assert _links(response)["self"]["href"] == f"{BASE_URL}{UNITS_PATH}" -def test_item_self_link_is_the_item_url(client): +def test_item_links_to_itself_and_its_collection(client): unit = UnitFactory(global_=True) response = client.get(f"{UNITS_PATH}/{unit.id}", {"properties": "name"}) @@ -69,10 +72,45 @@ def test_item_self_link_is_the_item_url(client): "href": f"{BASE_URL}{UNITS_PATH}/{unit.id}?properties=name", "rel": "self", "type": "application/json", - } + }, + { + "href": f"{BASE_URL}/api/ogc/collections/units", + "rel": "collection", + "type": "application/json", + }, ] +def test_item_collection_link_resolves_to_its_collection(client): + site = MonitoringSiteFactory() + + response = client.get(f"/api/ogc/collections/monitoring-sites/items/{site.id}") + collection_href = _links(response)["collection"]["href"] + + assert client.get(collection_href.removeprefix(BASE_URL)).json()["id"] == "monitoring-sites" + + +@pytest.mark.parametrize("collection", COLLECTIONS, ids=lambda collection: collection.id) +def test_collection_link_for_an_item_of_every_collection(rf, collection): + request = rf.get(f"/api/ogc/collections/{collection.id}/items/{uuid.uuid4()}") + + assert build_collection_link(request).href == f"{BASE_URL}/api/ogc/collections/{collection.id}" + + +@pytest.mark.parametrize( + "path", + [ + f"/api/ogc/collections/quality-control-histories/items/{uuid.uuid4()}/sessions/{uuid.uuid4()}", + f"/api/ogc/collections/etl-tasks/items/{uuid.uuid4()}/runs/{uuid.uuid4()}", + f"/api/ogc/collections/workspaces/items/{uuid.uuid4()}/service-accounts/{uuid.uuid4()}", + f"/api/ogc/collections/not-a-collection/items/{uuid.uuid4()}", + "/api/ogc/collections/units/items", + ], +) +def test_no_collection_link_outside_a_collection_item(rf, path): + assert build_collection_link(rf.get(path)) is None + + def test_full_first_page_links_next_but_not_prev(client): UnitFactory.create_batch(3, global_=True) From f164c2a4a788a0b466230e80466d392d7ba8f33c Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Mon, 28 Sep 2026 10:31:13 -0700 Subject: [PATCH 20/25] Added landing page and conformance API endpoints --- django/contracts/openapi/ogc.openapi.json | 120 ++++++++++++++- django/interfaces/api/http/links.py | 8 +- django/interfaces/api/schemas/extent.py | 3 + django/interfaces/api/schemas/ogc.py | 16 ++ django/interfaces/api/urls.py | 5 +- django/interfaces/api/views/ogc.py | 96 +++++++++++- .../interfaces/api/test_collections_views.py | 19 ++- .../tests/interfaces/api/test_common_core.py | 143 ++++++++++++++++++ django/tests/interfaces/api/test_extent.py | 3 +- .../interfaces/api/test_landing_views.py | 125 +++++++++++++++ .../src/generated/ogc.types.d.ts | 101 +++++++++++++ 11 files changed, 628 insertions(+), 11 deletions(-) create mode 100644 django/tests/interfaces/api/test_common_core.py create mode 100644 django/tests/interfaces/api/test_landing_views.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 3cde4a0f..d9ef848e 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -446,6 +446,23 @@ "title": "CollectionsResponse", "type": "object" }, + "ConformanceResponse": { + "description": "The conformance classes the API implements (OGC API - Features Core confClasses.yaml).", + "properties": { + "conformsTo": { + "items": { + "type": "string" + }, + "title": "Conformsto", + "type": "array" + } + }, + "required": [ + "conformsTo" + ], + "title": "ConformanceResponse", + "type": "object" + }, "CreatedResponse": { "properties": { "id": { @@ -5093,6 +5110,33 @@ "title": "JSONPayloadResponse", "type": "object" }, + "LandingPageResponse": { + "description": "The API's landing page (OGC API - Features Core landingPage.yaml).", + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "links": { + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "description", + "links" + ], + "title": "LandingPageResponse", + "type": "object" + }, "Link": { "properties": { "href": { @@ -14604,12 +14648,49 @@ } }, "info": { - "description": "", + "description": "Provides access to hydrologic monitoring sites, datastreams, and observations managed in HydroServer.", "title": "HydroServer Data Management API", "version": "2.0.0b3" }, "openapi": "3.1.0", "paths": { + "/api/ogc/": { + "get": { + "description": "Get the API's landing page, which links to the API definition, conformance declaration, and\ncollections.", + "operationId": "interfaces_api_views_ogc_get_landing_page", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LandingPageResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Landing Page", + "tags": [ + "Capabilities" + ] + } + }, "/api/ogc/collections": { "get": { "description": "Get the collections the API serves.", @@ -37032,6 +37113,43 @@ "Collections" ] } + }, + "/api/ogc/conformance": { + "get": { + "description": "Get the OGC API conformance classes the API implements.", + "operationId": "interfaces_api_views_ogc_get_conformance", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConformanceResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Conformance", + "tags": [ + "Capabilities" + ] + } } }, "servers": [] diff --git a/django/interfaces/api/http/links.py b/django/interfaces/api/http/links.py index 8411a23c..1e4a9c8e 100644 --- a/django/interfaces/api/http/links.py +++ b/django/interfaces/api/http/links.py @@ -31,10 +31,16 @@ def build_absolute_url(path: str) -> str: return settings.PROXY_BASE_URL.rstrip("/") + "/" + path.lstrip("/") +def route_path(route_name: str) -> str: + """A named route's path relative to Django's root, e.g. 'api/ogc/openapi.json'.""" + + return reverse(route_name).removeprefix(get_script_prefix()) + + def api_root_path() -> str: """The OGC API root relative to Django's root, e.g. 'api/ogc/'.""" - return reverse("ogc:api-root").removeprefix(get_script_prefix()) + return route_path("ogc:api-root") def build_api_url(path: str = "") -> str: diff --git a/django/interfaces/api/schemas/extent.py b/django/interfaces/api/schemas/extent.py index 5f434250..cf8c672d 100644 --- a/django/interfaces/api/schemas/extent.py +++ b/django/interfaces/api/schemas/extent.py @@ -55,6 +55,9 @@ def parse_bbox(value: Any) -> Optional[BoundingBox]: parts = value.split(",") if isinstance(value, str) else list(value) + if len(parts) == 1 and not str(parts[0]).strip(): + return None + try: numbers = [float(part) for part in parts] except (TypeError, ValueError): diff --git a/django/interfaces/api/schemas/ogc.py b/django/interfaces/api/schemas/ogc.py index ddef3dc6..9317d652 100644 --- a/django/interfaces/api/schemas/ogc.py +++ b/django/interfaces/api/schemas/ogc.py @@ -24,3 +24,19 @@ class CollectionsResponse(Schema): links: list[Link] collections: list[CollectionResponse] + + +class LandingPageResponse(Schema): + """The API's landing page (OGC API - Features Core landingPage.yaml).""" + + title: str + description: str + links: list[Link] + + +class ConformanceResponse(Schema): + """The conformance classes the API implements (OGC API - Features Core confClasses.yaml).""" + + conforms_to: list[str] + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) diff --git a/django/interfaces/api/urls.py b/django/interfaces/api/urls.py index 1aa8720a..66f00d7f 100644 --- a/django/interfaces/api/urls.py +++ b/django/interfaces/api/urls.py @@ -11,14 +11,15 @@ from interfaces.api.collections import COLLECTIONS from interfaces.api.views import qc_history_router, qc_session_router, qc_operation_router -from interfaces.api.views.ogc import ogc_router +from interfaces.api.views.ogc import API_DESCRIPTION, API_TITLE, ogc_router rate_limits = settings.API_RATE_LIMITS or {} throttle_classes = {"anonymous": AnonRateThrottle, "authenticated": AuthRateThrottle} api = HydroServerNinjaAPI( - title="HydroServer Data Management API", + title=API_TITLE, + description=API_DESCRIPTION, version=__version__, urls_namespace="ogc", docs_decorator=ensure_csrf_cookie, diff --git a/django/interfaces/api/views/ogc.py b/django/interfaces/api/views/ogc.py index 554edab9..71d7d392 100644 --- a/django/interfaces/api/views/ogc.py +++ b/django/interfaces/api/views/ogc.py @@ -3,13 +3,105 @@ from interfaces.auth.security import session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth from interfaces.api.collections import COLLECTIONS, CollectionDefinition, get_collection from interfaces.api.http.errors import NotFoundError -from interfaces.api.http.links import JSON_MEDIA_TYPE, Link, build_api_url, build_self_link +from interfaces.api.http.links import ( + JSON_MEDIA_TYPE, + Link, + build_absolute_url, + build_api_url, + build_self_link, + route_path, +) from interfaces.api.http.request import HydroServerHttpRequest -from interfaces.api.schemas.ogc import CollectionResponse, CollectionsResponse +from interfaces.api.schemas.ogc import ( + CollectionResponse, + CollectionsResponse, + ConformanceResponse, + LandingPageResponse, +) + +API_TITLE = "HydroServer Data Management API" +API_DESCRIPTION = ( + "Provides access to hydrologic monitoring sites, datastreams, and observations managed in HydroServer." +) + +CONFORMANCE_CLASSES = ( + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", +) ogc_router = Router(tags=["Collections"]) +@ogc_router.get( + "", + auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], + response={200: LandingPageResponse}, + by_alias=True, + exclude_none=True, + tags=["Capabilities"], +) +def get_landing_page(request: HydroServerHttpRequest): + """ + Get the API's landing page, which links to the API definition, conformance declaration, and + collections. + """ + + return 200, LandingPageResponse( + title=API_TITLE, + description=API_DESCRIPTION, + links=[ + build_self_link(request), + Link( + href=build_absolute_url(route_path("ogc:openapi-json")), + rel="service-desc", + type=JSON_MEDIA_TYPE, + title="The API definition", + ), + Link( + href=build_absolute_url(route_path("ogc:openapi-view")), + rel="service-doc", + type="text/html", + title="The API documentation", + ), + Link( + href=build_api_url("conformance"), + rel="conformance", + type=JSON_MEDIA_TYPE, + title="OGC API conformance classes implemented by this server", + ), + Link( + href=build_api_url("conformance"), + rel="http://www.opengis.net/def/rel/ogc/1.0/conformance", + type=JSON_MEDIA_TYPE, + title="OGC API conformance classes implemented by this server", + ), + Link( + href=build_api_url("collections"), + rel="data", + type=JSON_MEDIA_TYPE, + title="Information about the collections", + ), + ], + ) + + +@ogc_router.get( + "/conformance", + auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], + response={200: ConformanceResponse}, + by_alias=True, + tags=["Capabilities"], +) +def get_conformance(request: HydroServerHttpRequest): + """ + Get the OGC API conformance classes the API implements. + """ + + return 200, ConformanceResponse(conforms_to=list(CONFORMANCE_CLASSES)) + + def build_collection(collection: CollectionDefinition) -> CollectionResponse: """ Builds a collection's metadata. Both /collections and /collections/{collectionId} use it, so diff --git a/django/tests/interfaces/api/test_collections_views.py b/django/tests/interfaces/api/test_collections_views.py index d1f58872..8a18b735 100644 --- a/django/tests/interfaces/api/test_collections_views.py +++ b/django/tests/interfaces/api/test_collections_views.py @@ -120,8 +120,21 @@ def test_collections_endpoints_are_public(client, path): assert response.status_code == 200 -def test_collections_endpoints_are_documented_in_the_openapi_document(): +@pytest.mark.parametrize("path", [COLLECTIONS_PATH, f"{COLLECTIONS_PATH}/{{collection_id}}"]) +def test_collections_endpoints_are_documented_under_collections(path): paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] - assert "get" in paths[COLLECTIONS_PATH] - assert "get" in paths[f"{COLLECTIONS_PATH}/{{collection_id}}"] + assert paths[path]["get"]["tags"] == ["Collections"] + + +def test_collection_metadata_is_documented_once_for_all_collections(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + + assert not [path for path in paths if path.removeprefix(f"{COLLECTIONS_PATH}/") in COLLECTION_IDS] + + +def test_openapi_operation_ids_are_unique(): + paths = api.get_openapi_schema(path_prefix="/api/ogc/")["paths"] + operation_ids = [operation["operationId"] for path in paths.values() for operation in path.values()] + + assert len(operation_ids) == len(set(operation_ids)) diff --git a/django/tests/interfaces/api/test_common_core.py b/django/tests/interfaces/api/test_common_core.py new file mode 100644 index 00000000..ae3254b4 --- /dev/null +++ b/django/tests/interfaces/api/test_common_core.py @@ -0,0 +1,143 @@ +import pytest + +from tests.core.iam.factories import UserFactory +from tests.core.sta.factories import MonitoringSiteFactory, UnitFactory + +pytestmark = pytest.mark.django_db + +# Query parameter encoding rules from OGC API - Common - Part 1: Core (conformance class +# http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core). The comma-delimited list parameters +# are properties, include, sortby and bbox; parameters repeated as name=value pairs are exempt. + +UNITS_PATH = "/api/ogc/collections/units/items" +SITES_PATH = "/api/ogc/collections/monitoring-sites/items" + + +@pytest.fixture +def units(): + return [UnitFactory(global_=True, name=name, symbol=symbol) for name, symbol in [("B", "b"), ("A", "a")]] + + +@pytest.fixture +def site(): + return MonitoringSiteFactory(latitude=40.5, longitude=-111.5) + + +# --- Req 6 tolerance: an escaped comma separates list items ------------------------------------- +# +# Req 6 reads %2C as part of an item, but Python requests and browser URLSearchParams always +# percent-encode commas, so standard clients send lists as a%2Cb. No valid value of these +# parameters contains a comma, so the API treats %2C as a separator, as allowed by +# /per/core/query-param-value-tolerance. + + +def test_escaped_comma_separates_properties(client, units): + response = client.get(f"{UNITS_PATH}?properties=name%2Csymbol") + + assert response.status_code == 200 + assert all(set(unit) == {"name", "symbol"} for unit in response.json()["data"]) + + +def test_escaped_comma_separates_sortby_fields(client, units): + response = client.get(f"{UNITS_PATH}?sortby=name%2C-symbol") + + assert response.status_code == 200 + assert [unit["name"] for unit in response.json()["data"]] == ["A", "B"] + + +def test_escaped_comma_separates_include_relations(client, units): + response = client.get(f"{UNITS_PATH}?include=workspace%2Ctype") + + assert response.status_code == 200 + + +def test_escaped_comma_separates_bbox_values(client, site): + response = client.get(f"{SITES_PATH}?bbox=-112%2C40%2C-111%2C41") + + assert response.status_code == 200 + assert [s["id"] for s in response.json()["data"]] == [str(site.id)] + + +def test_unescaped_comma_separates_list_items(client, units): + response = client.get(f"{UNITS_PATH}?properties=name,symbol") + + assert response.status_code == 200 + assert all(set(unit) == {"name", "symbol"} for unit in response.json()["data"]) + + +def test_repeated_list_parameters_combine_their_items(client, units): + response = client.get(f"{UNITS_PATH}?properties=name&properties=symbol") + + assert response.status_code == 200 + assert all(set(unit) == {"name", "symbol"} for unit in response.json()["data"]) + + +def test_unescaped_comma_separates_bbox_values(client, site): + response = client.get(f"{SITES_PATH}?bbox=-112,40,-111,41") + + assert response.status_code == 200 + assert [s["id"] for s in response.json()["data"]] == [str(site.id)] + + +# --- Req 7: an empty value is an empty list ------------------------------------------------ + + +@pytest.mark.parametrize( + "path, parameter", + [(UNITS_PATH, "properties"), (UNITS_PATH, "include"), (UNITS_PATH, "sortby"), (SITES_PATH, "bbox")], +) +def test_empty_list_parameter_behaves_as_if_omitted(client, units, site, path, parameter): + omitted = client.get(path) + + response = client.get(f"{path}?{parameter}=") + + assert response.status_code == 200 + assert response.json()["data"] == omitted.json()["data"] + + +# --- Req 8: booleans --------------------------------------------------------------------- + + +@pytest.mark.parametrize("value, expected", [("true", True), ("false", False)]) +def test_boolean_values(client, value, expected): + owner = UserFactory() + MonitoringSiteFactory(workspace__owner=owner, workspace__is_private=True, is_private=True) + MonitoringSiteFactory(workspace__owner=owner, is_private=False) + client.force_login(owner) + + response = client.get(SITES_PATH, {"is_private": value}) + + assert response.status_code == 200 + assert [s["isPrivate"] for s in response.json()["data"]] == [expected] + + +# --- Reqs 9-11: integers, decimals and doubles ------------------------------------------------ + + +def test_integer_with_leading_zeros(client, units): + response = client.get(UNITS_PATH, {"limit": "001"}) + + assert response.status_code == 200 + assert response.json()["meta"]["limit"] == 1 + + +@pytest.mark.parametrize( + "bbox", + ["-112.500,040.00,-111.0,41", "-1.12e2,4.0e1,-1.11e2,41", "-112,40,-111,41.250"], +) +def test_bbox_accepts_decimal_and_exponent_forms(client, site, bbox): + response = client.get(SITES_PATH, {"bbox": bbox}) + + assert response.status_code == 200 + assert [s["id"] for s in response.json()["data"]] == [str(site.id)] + + +# --- Tolerance kept for sortby: an unencoded '+' arrives as a space -------------------------------- + + +def test_sortby_unencoded_plus_still_sorts_ascending(client, units): + response = client.get(f"{UNITS_PATH}?sortby=+name") + + assert response.status_code == 200 + names = [unit["name"] for unit in response.json()["data"]] + assert names == sorted(names) diff --git a/django/tests/interfaces/api/test_extent.py b/django/tests/interfaces/api/test_extent.py index 09473719..1e9a63f3 100644 --- a/django/tests/interfaces/api/test_extent.py +++ b/django/tests/interfaces/api/test_extent.py @@ -77,7 +77,7 @@ def test_parse_bbox_accepts_a_box_that_crosses_the_antimeridian(): assert bbox.crosses_antimeridian -@pytest.mark.parametrize("value", [None, "null", "NULL"]) +@pytest.mark.parametrize("value", [None, "null", "NULL", "", " "]) def test_parse_bbox_returns_none_for_missing_values(value): assert parse_bbox(value) is None @@ -92,7 +92,6 @@ def test_parse_bbox_passes_through_an_existing_bounding_box(): "value, message", [ ("-112,40,-111,north", "only numeric values"), - ("", "only numeric values"), ("-112,40,-111,nan", "finite"), ("-112,40,-111,inf", "finite"), ("-112,40,-111", "4 or 6"), diff --git a/django/tests/interfaces/api/test_landing_views.py b/django/tests/interfaces/api/test_landing_views.py new file mode 100644 index 00000000..09b243ee --- /dev/null +++ b/django/tests/interfaces/api/test_landing_views.py @@ -0,0 +1,125 @@ +import pytest + +from django.test import override_settings +from django.urls import reverse + +from interfaces.api.urls import api + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("proxy_base_url")] + +# The landing page links to the API definition, documentation, conformance declaration and +# collections, and /conformance lists the classes the API implements (OGC API - Features Core +# Req 1-6; OGC API - Common Core landing-page and json classes). Links are built from +# PROXY_BASE_URL, not the request's Host, which the test client sends as "testserver". + +BASE_URL = "https://hydroserver.example.org" +LANDING_PATH = "/api/ogc/" +CONFORMANCE_PATH = "/api/ogc/conformance" +COMMON_CORE = "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" +COMMON_JSON = "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json" +COMMON_LANDING_PAGE = "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page" +OGC_CONFORMANCE_REL = "http://www.opengis.net/def/rel/ogc/1.0/conformance" +FEATURES_CORE = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core" + + +@pytest.fixture +def proxy_base_url(): + with override_settings(PROXY_BASE_URL=BASE_URL): + yield + + +def _links(response): + return {link["rel"]: link for link in response.json()["links"]} + + +def test_landing_page_describes_the_api(client): + response = client.get(LANDING_PATH) + + assert response.status_code == 200 + assert response.json()["title"] == api.title + assert response.json()["description"] == api.description + + +def test_landing_page_links(client): + links = _links(client.get(LANDING_PATH)) + + assert {rel: (link["href"], link["type"]) for rel, link in links.items()} == { + "self": (f"{BASE_URL}/api/ogc/", "application/json"), + "service-desc": (f"{BASE_URL}/api/ogc/openapi.json", "application/json"), + "service-doc": (f"{BASE_URL}/api/ogc/docs", "text/html"), + "conformance": (f"{BASE_URL}/api/ogc/conformance", "application/json"), + OGC_CONFORMANCE_REL: (f"{BASE_URL}/api/ogc/conformance", "application/json"), + "data": (f"{BASE_URL}/api/ogc/collections", "application/json"), + } + + +@pytest.mark.parametrize( + "rel", ["self", "service-desc", "service-doc", "conformance", OGC_CONFORMANCE_REL, "data"] +) +def test_landing_page_link_returns_its_declared_media_type(client, rel): + link = _links(client.get(LANDING_PATH))[rel] + + response = client.get(link["href"].removeprefix(BASE_URL), HTTP_ACCEPT=link["type"]) + + assert response.status_code == 200 + assert response["Content-Type"].split(";")[0] == link["type"] + + +def test_conformance_declares_the_implemented_classes(client): + response = client.get(CONFORMANCE_PATH) + + assert response.status_code == 200 + assert response.json() == { + "conformsTo": [COMMON_CORE, COMMON_JSON, COMMON_LANDING_PAGE, FEATURES_CORE] + } + + +@pytest.mark.parametrize("path", [LANDING_PATH, CONFORMANCE_PATH]) +def test_capabilities_endpoints_return_json(client, path): + response = client.get(path, HTTP_ACCEPT="application/json") + + assert response.status_code == 200 + assert response["Content-Type"].split(";")[0] == "application/json" + + +def test_landing_page_matches_the_common_landing_page_schema(client): + body = client.get(LANDING_PATH).json() + + assert isinstance(body["links"], list) + assert all(isinstance(link["href"], str) and isinstance(link["rel"], str) for link in body["links"]) + + +def test_conformance_matches_the_common_conformance_schema(client): + body = client.get(CONFORMANCE_PATH).json() + + assert isinstance(body["conformsTo"], list) + assert all(isinstance(uri, str) for uri in body["conformsTo"]) + + +@pytest.mark.parametrize("path", [LANDING_PATH, CONFORMANCE_PATH]) +def test_capabilities_endpoints_are_public(client, path): + assert client.get(path).status_code == 200 + + +@pytest.mark.parametrize("path", [LANDING_PATH, CONFORMANCE_PATH]) +def test_capabilities_endpoints_reject_unknown_query_parameters(client, path): + assert client.get(path, {"foo": "bar"}).status_code == 400 + + +def test_api_root_route_name_still_resolves_to_the_landing_page(): + assert reverse("ogc:api-root") == LANDING_PATH + + +def test_openapi_document_keeps_the_api_path_prefix(): + paths = api.get_openapi_schema(path_prefix=LANDING_PATH)["paths"] + + assert "get" in paths[LANDING_PATH] + assert "get" in paths[CONFORMANCE_PATH] + assert "/api/ogc/collections/units/items" in paths + + +@pytest.mark.parametrize("path", [LANDING_PATH, CONFORMANCE_PATH]) +def test_capabilities_endpoints_are_documented_under_capabilities(path): + paths = api.get_openapi_schema(path_prefix=LANDING_PATH)["paths"] + + assert paths[path]["get"]["tags"] == ["Capabilities"] diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 17831464..0fbc14df 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -4,6 +4,27 @@ */ export interface paths { + "/api/ogc/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Landing Page + * @description Get the API's landing page, which links to the API definition, conformance declaration, and + * collections. + */ + get: operations["interfaces_api_views_ogc_get_landing_page"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/ogc/collections": { parameters: { query?: never; @@ -2162,6 +2183,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ogc/conformance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Conformance + * @description Get the OGC API conformance classes the API implements. + */ + get: operations["interfaces_api_views_ogc_get_conformance"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -2336,6 +2377,14 @@ export interface components { /** Links */ links: components["schemas"]["Link"][]; }; + /** + * ConformanceResponse + * @description The conformance classes the API implements (OGC API - Features Core confClasses.yaml). + */ + ConformanceResponse: { + /** Conformsto */ + conformsTo: string[]; + }; /** CreatedResponse */ CreatedResponse: { /** @@ -4037,6 +4086,18 @@ export interface components { */ type: "JSON"; }; + /** + * LandingPageResponse + * @description The API's landing page (OGC API - Features Core landingPage.yaml). + */ + LandingPageResponse: { + /** Description */ + description: string; + /** Links */ + links: components["schemas"]["Link"][]; + /** Title */ + title: string; + }; /** Link */ Link: { /** Href */ @@ -7418,6 +7479,26 @@ export interface components { } export type $defs = Record; export interface operations { + interfaces_api_views_ogc_get_landing_page: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LandingPageResponse"]; + }; + }; + }; + }; interfaces_api_views_ogc_get_collections: { parameters: { query?: never; @@ -16719,4 +16800,24 @@ export interface operations { }; }; }; + interfaces_api_views_ogc_get_conformance: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConformanceResponse"]; + }; + }; + }; + }; } From 8ac2d067af370bec2a37ad0c63ae4bd27dd40a60 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Mon, 28 Sep 2026 13:50:09 -0700 Subject: [PATCH 21/25] Fixed lax validations in OGC API --- .../e2e/specs/metadata.spec.ts | 6 +- .../__tests__/useOrchestrationData.spec.ts | 1 - .../orchestration/useOrchestrationData.ts | 3 +- django/contracts/openapi/ogc.openapi.json | 174 +++++++++++-- django/interfaces/api/schemas/__init__.py | 2 +- django/interfaces/api/schemas/base.py | 56 +++- django/interfaces/api/schemas/extent.py | 6 - .../interfaces/api/schemas/iam/workspace.py | 5 +- .../interfaces/api/schemas/products/task.py | 2 +- .../interfaces/api/schemas/quality/history.py | 2 +- .../interfaces/api/schemas/quality/session.py | 3 +- .../interfaces/api/schemas/sta/datastream.py | 5 +- django/interfaces/api/schemas/sta/method.py | 4 +- .../api/schemas/sta/monitoring_site.py | 9 +- django/tests/interfaces/api/test_extent.py | 19 +- .../tests/interfaces/api/test_query_null.py | 245 ++++++++++++++++++ .../interfaces/api/test_workspace_views.py | 4 +- .../api/__tests__/workspace.service.spec.ts | 20 ++ .../src/generated/ogc.types.d.ts | 36 +-- .../src/hydroserverpy/api/client.py | 19 ++ .../hydroserverpy/tests/test_api_client.py | 39 +++ 21 files changed, 581 insertions(+), 79 deletions(-) create mode 100644 django/tests/interfaces/api/test_query_null.py diff --git a/apps/data-management/e2e/specs/metadata.spec.ts b/apps/data-management/e2e/specs/metadata.spec.ts index a54d699c..41bc237e 100644 --- a/apps/data-management/e2e/specs/metadata.spec.ts +++ b/apps/data-management/e2e/specs/metadata.spec.ts @@ -136,7 +136,7 @@ test.describe('metadata management', () => { await expect(deleteButton).toBeVisible() await editButton.click() - await page.getByLabel('Name').fill(renamedCode) + await page.getByLabel('Name').fill(renamedName) await page.getByRole('button', { name: 'Update', exact: true }).click() const renamedRow = allTable.locator('tr').filter({ hasText: renamedName }) @@ -421,7 +421,7 @@ test.describe('metadata management', () => { .getByRole('button', { name: /Add new result qualifier/i }) .click() - await page.getByLabel('Name').fill(qualifierCode) + await page.getByLabel('Name').fill(qualifierName) await page .getByLabel('Description') .fill( @@ -438,7 +438,7 @@ test.describe('metadata management', () => { await qualifierRow .getByRole('button', { name: 'Edit metadata item', exact: true }) .click() - await page.getByLabel('Name').fill(renamedCode) + await page.getByLabel('Name').fill(renamedName) await page.getByRole('button', { name: 'Update' }).click() const renamedRow = page diff --git a/apps/data-management/src/composables/__tests__/useOrchestrationData.spec.ts b/apps/data-management/src/composables/__tests__/useOrchestrationData.spec.ts index 5be21263..a470f98b 100644 --- a/apps/data-management/src/composables/__tests__/useOrchestrationData.spec.ts +++ b/apps/data-management/src/composables/__tests__/useOrchestrationData.spec.ts @@ -100,7 +100,6 @@ describe('useOrchestrationData', () => { workspace_id: ['workspace-1'], data_connection_id: ['dc-1'], sortby: ['name'], - expand_related: true, }) await data.fetchTasksForGroup('aggregation', 'monitoringSite-1', 'workspace-1') diff --git a/apps/data-management/src/composables/orchestration/useOrchestrationData.ts b/apps/data-management/src/composables/orchestration/useOrchestrationData.ts index d8c5252b..c9b74fec 100644 --- a/apps/data-management/src/composables/orchestration/useOrchestrationData.ts +++ b/apps/data-management/src/composables/orchestration/useOrchestrationData.ts @@ -136,8 +136,7 @@ export function useOrchestrationData() { workspace_id: [requestedWorkspaceId], data_connection_id: [groupId], sortby: ['name'], - expand_related: true, - } as any) + }) if (requestId !== taskRequestId) return workspaceTasks.value = items as any } else if (tab === 'aggregation') { diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index d9ef848e..c7d00ef0 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -1437,8 +1437,16 @@ "default": [], "description": "Filter data product tasks by rating curve ID.", "items": { - "format": "uuid", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Rating Curve Id", "type": "array" @@ -2859,9 +2867,17 @@ }, "status": { "default": [], - "description": "Filter monitoring_sites by status.", + "description": "Filter datastreams by status.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Status", "type": "array" @@ -5699,7 +5715,15 @@ "default": [], "description": "Filter methods by sensor model", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Sensor Model", "type": "array" @@ -5708,7 +5732,15 @@ "default": [], "description": "Filter methods by sensor model manufacturer", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Sensor Model Manufacturer", "type": "array" @@ -6912,7 +6944,15 @@ "default": [], "description": "Filter monitoring sites by admin area 1.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Admin Area 1", "type": "array" @@ -6921,7 +6961,15 @@ "default": [], "description": "Filter monitoring sites by admin area 2.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Admin Area 2", "type": "array" @@ -6947,7 +6995,15 @@ "default": [], "description": "Filter monitoring sites by country.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Country", "type": "array" @@ -11081,8 +11137,16 @@ "default": [], "description": "Filter histories by source datastream ID.", "items": { - "format": "uuid", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Source Datastream Id", "type": "array" @@ -16166,8 +16230,16 @@ "default": [], "description": "Filter data product tasks by rating curve ID.", "items": { - "format": "uuid", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Rating Curve Id", "type": "array" @@ -18633,15 +18705,23 @@ } }, { - "description": "Filter monitoring_sites by status.", + "description": "Filter datastreams by status.", "in": "query", "name": "status", "required": false, "schema": { "default": [], - "description": "Filter monitoring_sites by status.", + "description": "Filter datastreams by status.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Status", "type": "array" @@ -23749,7 +23829,15 @@ "default": [], "description": "Filter methods by sensor model", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Sensor Model", "type": "array" @@ -23764,7 +23852,15 @@ "default": [], "description": "Filter methods by sensor model manufacturer", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Sensor Model Manufacturer", "type": "array" @@ -25584,7 +25680,15 @@ "default": [], "description": "Filter monitoring sites by admin area 1.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Admin Area 1", "type": "array" @@ -25599,7 +25703,15 @@ "default": [], "description": "Filter monitoring sites by admin area 2.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Admin Area 2", "type": "array" @@ -25614,7 +25726,15 @@ "default": [], "description": "Filter monitoring sites by country.", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Country", "type": "array" @@ -30885,8 +31005,16 @@ "default": [], "description": "Filter histories by source datastream ID.", "items": { - "format": "uuid", - "type": "string" + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "const": "null", + "type": "string" + } + ] }, "title": "Source Datastream Id", "type": "array" diff --git a/django/interfaces/api/schemas/__init__.py b/django/interfaces/api/schemas/__init__.py index 696a76be..d7ed7859 100644 --- a/django/interfaces/api/schemas/__init__.py +++ b/django/interfaces/api/schemas/__init__.py @@ -1,7 +1,7 @@ from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, CollectionQueryParameters, PaginationMeta, PaginatedResponse, CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema, - split_sortby, sortby_array_schema) + split_sortby, sortby_array_schema, QueryBool) from .extent import (BoundingBox, BoundingBoxQuery, DatetimeQuery, ExtentQueryParameters, TimeInterval, parse_bbox, parse_datetime) from interfaces.api.schemas.iam.user import UserContactResponse diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index 887df85e..8461f482 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -1,14 +1,16 @@ import copy import uuid -from typing import Optional, Any, Union, Annotated, Generic, TypeVar, get_args +from typing import Optional, Any, Union, Annotated, Generic, Literal, TypeVar, get_args, get_origin from ninja import Schema, Query from pydantic.alias_generators import to_camel from pydantic import ( AfterValidator, AliasGenerator, AliasChoices, + BeforeValidator, ConfigDict, + ValidationInfo, field_validator, model_serializer, SerializationInfo, @@ -139,17 +141,59 @@ def filter_requested_properties(item: Any, requested: set[str]) -> Any: return {k: v for k, v in item.items() if k in requested} +NULL_QUERY_VALUE = "null" + + +def accepts_null(annotation: Any) -> bool: + """Returns whether a query parameter's type admits the value 'null'.""" + + if get_origin(annotation) is Literal: + return NULL_QUERY_VALUE in get_args(annotation) + + return any(accepts_null(arg) for arg in get_args(annotation)) + + +def parse_query_bool(value: Any) -> Any: + """Accepts only the lowercase strings 'true' and 'false' for a boolean query parameter.""" + + if isinstance(value, bool): + return value + + if value == "true": + return True + if value == "false": + return False + + raise ValueError("Boolean query parameters must be 'true' or 'false'") + + +QueryBool = Annotated[ + bool, + BeforeValidator(parse_query_bool), + WithJsonSchema({"type": "boolean"}), +] + + class BaseQueryParameters(Schema): model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True) @field_validator("*", mode="after") - def convert_null_strings(value: Any) -> Any: # noqa - if isinstance(value, str) and value.lower() == "null": + @classmethod + def convert_null_values(cls, value: Any, info: ValidationInfo) -> Any: + """ + Converts 'null' to None for parameters whose type declares Literal["null"], where it + selects items whose value is null. Matching is case-sensitive, and parameters that + don't declare it treat 'null' as an ordinary value. + """ + + if not accepts_null(cls.model_fields[info.field_name].annotation): + return value + + if value == NULL_QUERY_VALUE: return None if isinstance(value, list): - return [ - None if isinstance(v, str) and v.lower() == "null" else v for v in value - ] + return [None if v == NULL_QUERY_VALUE else v for v in value] + return value diff --git a/django/interfaces/api/schemas/extent.py b/django/interfaces/api/schemas/extent.py index cf8c672d..dfea1990 100644 --- a/django/interfaces/api/schemas/extent.py +++ b/django/interfaces/api/schemas/extent.py @@ -50,9 +50,6 @@ def parse_bbox(value: Any) -> Optional[BoundingBox]: if value is None or isinstance(value, BoundingBox): return value - if isinstance(value, str) and value.strip().lower() == "null": - return None - parts = value.split(",") if isinstance(value, str) else list(value) if len(parts) == 1 and not str(parts[0]).strip(): @@ -155,9 +152,6 @@ def parse_datetime(value: Any) -> Optional[TimeInterval]: value = value.strip() - if value.lower() == "null": - return None - if "/" not in value: instant = parse_rfc3339(value) return TimeInterval(start=instant, end=instant) diff --git a/django/interfaces/api/schemas/iam/workspace.py b/django/interfaces/api/schemas/iam/workspace.py index 7744dba8..bbc4271c 100644 --- a/django/interfaces/api/schemas/iam/workspace.py +++ b/django/interfaces/api/schemas/iam/workspace.py @@ -18,6 +18,7 @@ comma_array_schema, split_sortby, sortby_array_schema, + QueryBool, ) @@ -98,11 +99,11 @@ class WorkspaceQueryParameters(WorkspaceFilterFields, CollectionQueryParameters, description="Full-text search query. Comma-separated terms are combined with OR; " "whitespace-separated words within a term are combined with AND.", ) - is_associated: Optional[bool] = Query( + is_associated: Optional[QueryBool] = Query( None, description="Whether the workspace is associated with the authenticated user", ) - is_private: Optional[bool] = Query( + is_private: Optional[QueryBool] = Query( None, description="Whether the returned workspaces should be private or public." ) diff --git a/django/interfaces/api/schemas/products/task.py b/django/interfaces/api/schemas/products/task.py index bb0ca289..9caf6c7b 100644 --- a/django/interfaces/api/schemas/products/task.py +++ b/django/interfaces/api/schemas/products/task.py @@ -122,7 +122,7 @@ class DataProductTaskQueryParameters(DataProductTaskFilterFields, CollectionQuer input_datastream: list[uuid.UUID] = Query( [], description="Filter data product tasks by input datastream ID.", alias="input_datastream_id" ) - rating_curve: list[uuid.UUID] = Query( + rating_curve: list[uuid.UUID | Literal["null"]] = Query( [], description="Filter data product tasks by rating curve ID.", alias="rating_curve_id" ) diff --git a/django/interfaces/api/schemas/quality/history.py b/django/interfaces/api/schemas/quality/history.py index 04307953..364fd9da 100644 --- a/django/interfaces/api/schemas/quality/history.py +++ b/django/interfaces/api/schemas/quality/history.py @@ -90,7 +90,7 @@ class QualityControlHistoryQueryParameters( managed_datastream_id: list[uuid.UUID] = Query( [], description="Filter histories by managed datastream ID." ) - source_datastream_id: list[uuid.UUID] = Query( + source_datastream_id: list[uuid.UUID | Literal["null"]] = Query( [], description="Filter histories by source datastream ID." ) diff --git a/django/interfaces/api/schemas/quality/session.py b/django/interfaces/api/schemas/quality/session.py index 79c8b630..e0ebb2ba 100644 --- a/django/interfaces/api/schemas/quality/session.py +++ b/django/interfaces/api/schemas/quality/session.py @@ -16,6 +16,7 @@ comma_array_schema, split_sortby, sortby_array_schema, + QueryBool, ) from interfaces.api.schemas.iam.collaborator import DELETED_USER_CONTACT @@ -76,7 +77,7 @@ class QualityControlSessionQueryParameters(QualityControlSessionFilterFields, Co range_start: Optional[ISODatetime] = Query(None, description="Return sessions overlapping with this range start.") range_end: Optional[ISODatetime] = Query(None, description="Return sessions overlapping with this range end.") ancestor_of: Optional[uuid.UUID] = Query(None, description="Return all transitive ancestors of the given session ID.") - include_ancestors: bool = Query(False, description="Also return transitive ancestors of all sessions matched by other filters.") + include_ancestors: QueryBool = Query(False, description="Also return transitive ancestors of all sessions matched by other filters.") class QualityControlSessionResponse(BaseGetResponse): diff --git a/django/interfaces/api/schemas/sta/datastream.py b/django/interfaces/api/schemas/sta/datastream.py index 0e64b070..4e2c2492 100644 --- a/django/interfaces/api/schemas/sta/datastream.py +++ b/django/interfaces/api/schemas/sta/datastream.py @@ -23,6 +23,7 @@ comma_array_schema, split_sortby, sortby_array_schema, + QueryBool, ) from interfaces.api.schemas.sta.linked_resource import LinkedResourceGetResponse from interfaces.api.schemas.sta.tags import reject_empty_tag_keys_and_values @@ -210,12 +211,12 @@ class DatastreamQueryParameters(DatastreamFilterFields, CollectionQueryParameter sampled_medium: list[str] = Query( [], description="Filter monitoring_sites by sampled medium." ) - status: list[str] = Query([], description="Filter monitoring_sites by status.") + status: list[str | Literal["null"]] = Query([], description="Filter datastreams by status.") result_type: list[str] = Query([], description="Filter monitoring_sites by result type.") tag: list[str] = Query( [], description="Filter datastreams by tag. Format tag filters as {key}:{value}" ) - is_private: Optional[bool] = Query( + is_private: Optional[QueryBool] = Query( None, description="Controls whether the datastreams should be private or public.", ) diff --git a/django/interfaces/api/schemas/sta/method.py b/django/interfaces/api/schemas/sta/method.py index 616c48ef..fc9da94d 100644 --- a/django/interfaces/api/schemas/sta/method.py +++ b/django/interfaces/api/schemas/sta/method.py @@ -108,8 +108,8 @@ class MethodQueryParameters(MethodFilterFields, CollectionQueryParameters, Exten [], description="Filter methods by datastream ID.", alias="datastream_id" ) type: list[str] = Query([], description="Filter methods by type") - sensor_model: list[str] = Query([], description="Filter methods by sensor model") - sensor_model_manufacturer: list[str] = Query( + sensor_model: list[str | Literal["null"]] = Query([], description="Filter methods by sensor model") + sensor_model_manufacturer: list[str | Literal["null"]] = Query( [], description="Filter methods by sensor model manufacturer" ) diff --git a/django/interfaces/api/schemas/sta/monitoring_site.py b/django/interfaces/api/schemas/sta/monitoring_site.py index 3f33dcdd..6caa53dc 100644 --- a/django/interfaces/api/schemas/sta/monitoring_site.py +++ b/django/interfaces/api/schemas/sta/monitoring_site.py @@ -21,6 +21,7 @@ comma_array_schema, split_sortby, sortby_array_schema, + QueryBool, ) from interfaces.api.schemas.sta.linked_resource import LinkedResourceGetResponse from interfaces.api.schemas.sta.tags import reject_empty_tag_keys_and_values @@ -140,18 +141,18 @@ class MonitoringSiteQueryParameters(MonitoringSiteFilterFields, CollectionQueryP workspace_id: list[uuid.UUID] = Query( [], description="Filter monitoring sites by workspace ID." ) - admin_area_1: list[str] = Query( + admin_area_1: list[str | Literal["null"]] = Query( [], description="Filter monitoring sites by admin area 1." ) - admin_area_2: list[str] = Query( + admin_area_2: list[str | Literal["null"]] = Query( [], description="Filter monitoring sites by admin area 2." ) - country: list[str] = Query([], description="Filter monitoring sites by country.") + country: list[str | Literal["null"]] = Query([], description="Filter monitoring sites by country.") type: list[str] = Query([], description="Filter monitoring sites by type.") tag: list[str] = Query( [], description="Filter monitoring sites by tag. Format tag filters as {key}:{value}" ) - is_private: Optional[bool] = Query( + is_private: Optional[QueryBool] = Query( None, description="Controls whether the returned monitoring sites should be private or public.", ) diff --git a/django/tests/interfaces/api/test_extent.py b/django/tests/interfaces/api/test_extent.py index 1e9a63f3..1b2efb2d 100644 --- a/django/tests/interfaces/api/test_extent.py +++ b/django/tests/interfaces/api/test_extent.py @@ -77,11 +77,17 @@ def test_parse_bbox_accepts_a_box_that_crosses_the_antimeridian(): assert bbox.crosses_antimeridian -@pytest.mark.parametrize("value", [None, "null", "NULL", "", " "]) +@pytest.mark.parametrize("value", [None, "", " "]) def test_parse_bbox_returns_none_for_missing_values(value): assert parse_bbox(value) is None +@pytest.mark.parametrize("value", ["null", "NULL"]) +def test_parse_bbox_rejects_null(value): + with pytest.raises(ValueError, match="only numeric values"): + parse_bbox(value) + + def test_parse_bbox_passes_through_an_existing_bounding_box(): bbox = BoundingBox(west=-112, south=40, east=-111, north=41) @@ -286,9 +292,14 @@ def test_parse_datetime_parses_an_open_end(value): assert parse_datetime(value) == TimeInterval(start=JAN_1, end=None) -@pytest.mark.parametrize("value", [None, "null"]) -def test_parse_datetime_returns_none_for_missing_values(value): - assert parse_datetime(value) is None +def test_parse_datetime_returns_none_for_missing_values(): + assert parse_datetime(None) is None + + +@pytest.mark.parametrize("value", ["null", "NULL"]) +def test_parse_datetime_rejects_null(value): + with pytest.raises(ValueError, match="RFC 3339"): + parse_datetime(value) @pytest.mark.parametrize( diff --git a/django/tests/interfaces/api/test_query_null.py b/django/tests/interfaces/api/test_query_null.py new file mode 100644 index 00000000..ae37b905 --- /dev/null +++ b/django/tests/interfaces/api/test_query_null.py @@ -0,0 +1,245 @@ +import uuid + +import pytest + +from interfaces.api.schemas.base import accepts_null +from interfaces.api.urls import api +from tests.core.iam.factories import RoleFactory, UserFactory, WorkspaceFactory +from tests.core.sta.factories import ( + DatastreamFactory, + MethodFactory, + MonitoringSiteFactory, + UnitFactory, +) + +pytestmark = pytest.mark.django_db + +# 'null' is only a valid query value where the parameter's type declares Literal["null"], and +# there it selects items whose value is null. Everywhere else it's an invalid value, or an +# ordinary string for free-text parameters. Booleans accept only 'true' and 'false' (OGC API - +# Common - Part 1 /req/core/query-param-value-boolean and /req/core/query-param-capitalization). + +COLLECTIONS_URL = "/api/ogc/collections" + +NULLABLE_PARAMETERS = { + ("data-product-tasks", "latest_run_status"), + ("data-product-tasks", "rating_curve_id"), + ("datastreams", "status"), + ("etl-tasks", "latest_run_status"), + ("methods", "datastream_id"), + ("methods", "monitoring_site_id"), + ("methods", "sensor_model"), + ("methods", "sensor_model_manufacturer"), + ("methods", "workspace_id"), + ("monitoring-sites", "admin_area_1"), + ("monitoring-sites", "admin_area_2"), + ("monitoring-sites", "country"), + ("monitoring-tasks", "latest_run_status"), + ("observed-properties", "datastream_id"), + ("observed-properties", "monitoring_site_id"), + ("observed-properties", "workspace_id"), + ("processing-levels", "datastream_id"), + ("processing-levels", "monitoring_site_id"), + ("processing-levels", "workspace_id"), + ("quality-control-histories", "source_datastream_id"), + ("result-qualifiers", "workspace_id"), + ("roles", "workspace_id"), + ("units", "datastream_id"), + ("units", "monitoring_site_id"), + ("units", "workspace_id"), +} + + +def _nullable_parameters(): + """Yields (collection id, parameter name) for every query parameter that accepts 'null'.""" + + for bound_router in api._get_bound_routers(): + prefix = bound_router.prefix.strip("/") + if not prefix.startswith("collections/"): + continue + + for path_view in bound_router.path_operations.values(): + for operation in path_view.operations: + for model in operation.models: + if getattr(model, "__ninja_param_source__", None) != "query": + continue + + for alias, field_path in model.__ninja_flatten_map__.items(): + schema = model + for name in field_path[:-1]: + schema = schema.model_fields[name].annotation + # The flatten map ends with the parameter's alias, not its field name. + field = next( + field + for name, field in schema.model_fields.items() + if (field.alias or name) == field_path[-1] + ) + if accepts_null(field.annotation): + yield prefix.removeprefix("collections/"), alias + + +def test_only_nullable_columns_accept_null(): + assert set(_nullable_parameters()) == NULLABLE_PARAMETERS + + +def test_null_selects_items_without_a_value(client): + global_role = RoleFactory(global_role=True) + RoleFactory() + + response = client.get(f"{COLLECTIONS_URL}/roles/items", {"workspace_id": "null"}) + + assert response.status_code == 200 + ids = {item["id"] for item in response.json()["data"]} + assert str(global_role.id) in ids + assert all(item["workspaceId"] is None for item in response.json()["data"]) + + +def test_null_combines_with_other_values(client): + user = UserFactory() + workspace = WorkspaceFactory(owner=user) + workspace_unit = UnitFactory(workspace=workspace) + global_unit = UnitFactory(workspace=None) + other_unit = UnitFactory() + client.force_login(user) + + response = client.get( + f"{COLLECTIONS_URL}/units/items?workspace_id={workspace.id}&workspace_id=null&limit=1000" + ) + + assert response.status_code == 200 + ids = {item["id"] for item in response.json()["data"]} + assert {str(workspace_unit.id), str(global_unit.id)} <= ids + assert str(other_unit.id) not in ids + + +@pytest.mark.parametrize( + "collection, parameter, field", + [ + ("monitoring-sites", "country", "country"), + ("monitoring-sites", "admin_area_1", "admin_area_1"), + ("methods", "sensor_model", "sensor_model"), + ], +) +def test_newly_nullable_string_filters_select_null_values(client, collection, parameter, field): + factory = MonitoringSiteFactory if collection == "monitoring-sites" else MethodFactory + null_item = factory(**{field: None}) + valued_item = factory(**{field: "US"}) + client.force_login(UserFactory(superuser=True)) + + response = client.get(f"{COLLECTIONS_URL}/{collection}/items", {parameter: "null", "limit": 1000}) + + assert response.status_code == 200 + ids = {item["id"] for item in response.json()["data"]} + assert str(null_item.id) in ids + assert str(valued_item.id) not in ids + + +def test_datastream_status_null_selects_datastreams_without_a_status(client): + null_datastream = DatastreamFactory(status=None) + valued_datastream = DatastreamFactory(status="Ongoing") + + response = client.get(f"{COLLECTIONS_URL}/datastreams/items", {"status": "null", "limit": 1000}) + + assert response.status_code == 200 + ids = {item["id"] for item in response.json()["data"]} + assert str(null_datastream.id) in ids + assert str(valued_datastream.id) not in ids + + +def test_null_matching_is_case_sensitive_for_string_filters(client): + null_site = MonitoringSiteFactory(admin_area_1=None) + literal_site = MonitoringSiteFactory(admin_area_1="NULL") + + response = client.get( + f"{COLLECTIONS_URL}/monitoring-sites/items", {"admin_area_1": "NULL", "limit": 1000} + ) + + assert response.status_code == 200 + ids = {item["id"] for item in response.json()["data"]} + assert str(literal_site.id) in ids + assert str(null_site.id) not in ids + + +@pytest.mark.parametrize("value", ["NULL", "Null"]) +def test_null_matching_is_case_sensitive_for_typed_filters(client, value): + response = client.get(f"{COLLECTIONS_URL}/roles/items", {"workspace_id": value}) + + assert response.status_code == 400 + + +@pytest.mark.parametrize( + "collection, parameter", + [ + ("units", "limit"), + ("units", "offset"), + ("units", "bbox"), + ("units", "datetime"), + ("units", "sortby"), + ("units", "properties"), + ("datastreams", "monitoring_site_id"), + ("datastreams", "is_private"), + ("workspaces", "is_associated"), + ("etl-tasks", "workspace_id"), + ], +) +def test_non_nullable_parameters_reject_null(client, collection, parameter): + client.force_login(UserFactory()) + + response = client.get(f"{COLLECTIONS_URL}/{collection}/items", {parameter: "null"}) + + assert response.status_code == 400 + + +def test_free_text_parameters_treat_null_as_a_string(client): + site = MonitoringSiteFactory(type="null") + MonitoringSiteFactory(type="Stream") + + response = client.get(f"{COLLECTIONS_URL}/monitoring-sites/items", {"type": "null", "limit": 1000}) + + assert response.status_code == 200 + assert [item["id"] for item in response.json()["data"]] == [str(site.id)] + + +@pytest.mark.parametrize("value, expected", [("true", True), ("false", False)]) +def test_boolean_parameters_accept_lowercase_true_and_false(client, value, expected): + # A site is only private-filtered as private when its workspace is private too. + private_site = MonitoringSiteFactory(private=True, workspace=WorkspaceFactory(private=True)) + public_site = MonitoringSiteFactory() + client.force_login(UserFactory(superuser=True)) + + response = client.get(f"{COLLECTIONS_URL}/monitoring-sites/items", {"is_private": value, "limit": 1000}) + + assert response.status_code == 200 + ids = {item["id"] for item in response.json()["data"]} + assert (str(private_site.id) in ids) is expected + assert (str(public_site.id) in ids) is not expected + + +@pytest.mark.parametrize("value", ["True", "FALSE", "1", "0", "yes", "no", "t", "f", "on", ""]) +@pytest.mark.parametrize( + "path, parameter", + [ + ("workspaces/items", "is_private"), + ("workspaces/items", "is_associated"), + ("datastreams/items", "is_private"), + ("monitoring-sites/items", "is_private"), + ], +) +def test_boolean_parameters_reject_other_spellings(client, path, parameter, value): + response = client.get(f"{COLLECTIONS_URL}/{path}", {parameter: value}) + + assert response.status_code == 400 + assert response.json()["message"] == "Value error, Boolean query parameters must be 'true' or 'false'" + + +@pytest.mark.parametrize("value", ["True", "1", "null"]) +def test_include_ancestors_rejects_other_spellings(client, value): + client.force_login(UserFactory()) + history_id = uuid.uuid4() + + response = client.get( + f"{COLLECTIONS_URL}/quality-control-histories/items/{history_id}/sessions", + {"include_ancestors": value}, + ) + + assert response.status_code == 400 diff --git a/django/tests/interfaces/api/test_workspace_views.py b/django/tests/interfaces/api/test_workspace_views.py index e131d3e9..cf8cbfad 100644 --- a/django/tests/interfaces/api/test_workspace_views.py +++ b/django/tests/interfaces/api/test_workspace_views.py @@ -406,7 +406,7 @@ def test_get_workspaces_include_pending_transfer_to_sideloads_for_recipient(clie response = client.get( WORKSPACES_URL, - {"is_associated": True, "include": "pendingTransferTo"}, + {"is_associated": "true", "include": "pendingTransferTo"}, ) assert response.status_code == 200 @@ -425,7 +425,7 @@ def test_get_workspaces_include_collaborator_role_sideloads_for_collaborator(cli response = client.get( WORKSPACES_URL, - {"is_associated": True, "include": "collaboratorRole"}, + {"is_associated": "true", "include": "collaboratorRole"}, ) assert response.status_code == 200 diff --git a/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts index 7ce776f9..97183c5e 100644 --- a/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/workspace.service.spec.ts @@ -7,6 +7,26 @@ describe('WorkspaceService', () => { vi.restoreAllMocks() }) + it('serializes boolean and null query values as lowercase true, false and null', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [], meta: { offset: 0, limit: 100, totalCount: 0 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const client = new HydroServer({ host: 'https://hydro.example.com' }) + await client.workspaces.list({ is_associated: true, is_private: false }) + await client.units.list({ workspace_id: ['workspace-1', 'null'] } as any) + + const workspaceParams = new URL(String(fetchMock.mock.calls[0][0])).searchParams + expect(workspaceParams.get('is_associated')).toBe('true') + expect(workspaceParams.get('is_private')).toBe('false') + const unitParams = new URL(String(fetchMock.mock.calls[1][0])).searchParams + expect(unitParams.getAll('workspace_id')).toEqual(['workspace-1', 'null']) + }) + it('uses the service-account endpoint for the workspace management table', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ data: [], meta: { offset: 0, limit: 200, totalCount: 0 } }), { diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 0fbc14df..6c149afc 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -2689,7 +2689,7 @@ export interface components { * @description Filter data product tasks by rating curve ID. * @default [] */ - rating_curve_id: string[]; + rating_curve_id: (string | "null")[]; /** * Sortby * @description Select one or more fields to sort the response by. @@ -3195,10 +3195,10 @@ export interface components { sortby: ("name" | "observationType" | "sampledMedium" | "status" | "resultType" | "isPrivate" | "isVisible" | "aggregationStatistic" | "valueCount" | "phenomenonBeginTime" | "phenomenonEndTime" | "resultBeginTime" | "resultEndTime" | "-name" | "-observationType" | "-sampledMedium" | "-status" | "-resultType" | "-isPrivate" | "-isVisible" | "-aggregationStatistic" | "-valueCount" | "-phenomenonBeginTime" | "-phenomenonEndTime" | "-resultBeginTime" | "-resultEndTime" | "+name" | "+observationType" | "+sampledMedium" | "+status" | "+resultType" | "+isPrivate" | "+isVisible" | "+aggregationStatistic" | "+valueCount" | "+phenomenonBeginTime" | "+phenomenonEndTime" | "+resultBeginTime" | "+resultEndTime")[]; /** * Status - * @description Filter monitoring_sites by status. + * @description Filter datastreams by status. * @default [] */ - status: string[]; + status: (string | "null")[]; /** * Tag * @description Filter datastreams by tag. Format tag filters as {key}:{value} @@ -4295,13 +4295,13 @@ export interface components { * @description Filter methods by sensor model * @default [] */ - sensor_model: string[]; + sensor_model: (string | "null")[]; /** * Sensor Model Manufacturer * @description Filter methods by sensor model manufacturer * @default [] */ - sensor_model_manufacturer: string[]; + sensor_model_manufacturer: (string | "null")[]; /** * Sortby * @description Select one or more fields to sort the response by. @@ -4705,13 +4705,13 @@ export interface components { * @description Filter monitoring sites by admin area 1. * @default [] */ - admin_area_1: string[]; + admin_area_1: (string | "null")[]; /** * Admin Area 2 * @description Filter monitoring sites by admin area 2. * @default [] */ - admin_area_2: string[]; + admin_area_2: (string | "null")[]; /** @description Bounding box in WGS 84 longitude/latitude: min_lon,min_lat,max_lon,max_lat, or six values including min and max heights (heights are not used for filtering). A min_lon greater than max_lon crosses the antimeridian. Items without a location match any bounding box. */ bbox?: number[] & (unknown | unknown); /** @@ -4719,7 +4719,7 @@ export interface components { * @description Filter monitoring sites by country. * @default [] */ - country: string[]; + country: (string | "null")[]; /** @description Date-time or interval in RFC 3339 with a UTC offset: an instant (2024-01-01T00:00:00Z), a bounded interval (start/end), or a half-bounded interval with '..' or an empty value for the open end (../end, start/..). Items without a time match any datetime. */ datetime?: string; /** @@ -6191,7 +6191,7 @@ export interface components { * @description Filter histories by source datastream ID. * @default [] */ - source_datastream_id: string[]; + source_datastream_id: (string | "null")[]; }; /** QualityControlHistoryResponse */ QualityControlHistoryResponse: { @@ -8051,7 +8051,7 @@ export interface operations { /** @description Filter data product tasks by input datastream ID. */ input_datastream_id?: string[]; /** @description Filter data product tasks by rating curve ID. */ - rating_curve_id?: string[]; + rating_curve_id?: (string | "null")[]; }; header?: never; path?: never; @@ -9028,8 +9028,8 @@ export interface operations { observation_type?: string[]; /** @description Filter monitoring_sites by sampled medium. */ sampled_medium?: string[]; - /** @description Filter monitoring_sites by status. */ - status?: string[]; + /** @description Filter datastreams by status. */ + status?: (string | "null")[]; /** @description Filter monitoring_sites by result type. */ result_type?: string[]; /** @description Filter datastreams by tag. Format tag filters as {key}:{value} */ @@ -11100,9 +11100,9 @@ export interface operations { /** @description Filter methods by type */ type?: string[]; /** @description Filter methods by sensor model */ - sensor_model?: string[]; + sensor_model?: (string | "null")[]; /** @description Filter methods by sensor model manufacturer */ - sensor_model_manufacturer?: string[]; + sensor_model_manufacturer?: (string | "null")[]; }; header?: never; path?: never; @@ -11884,11 +11884,11 @@ export interface operations { /** @description Filter monitoring sites by workspace ID. */ workspace_id?: string[]; /** @description Filter monitoring sites by admin area 1. */ - admin_area_1?: string[]; + admin_area_1?: (string | "null")[]; /** @description Filter monitoring sites by admin area 2. */ - admin_area_2?: string[]; + admin_area_2?: (string | "null")[]; /** @description Filter monitoring sites by country. */ - country?: string[]; + country?: (string | "null")[]; /** @description Filter monitoring sites by type. */ type?: string[]; /** @description Filter monitoring sites by tag. Format tag filters as {key}:{value} */ @@ -14037,7 +14037,7 @@ export interface operations { /** @description Filter histories by managed datastream ID. */ managed_datastream_id?: string[]; /** @description Filter histories by source datastream ID. */ - source_datastream_id?: string[]; + source_datastream_id?: (string | "null")[]; }; header?: never; path?: never; diff --git a/packages/hydroserverpy/src/hydroserverpy/api/client.py b/packages/hydroserverpy/src/hydroserverpy/api/client.py index de6acce6..16ceff2c 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/client.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/client.py @@ -46,9 +46,28 @@ def __init__( self._init_session() + @staticmethod + def _encode_query_value(value): + """ + Encodes booleans as the lowercase 'true' and 'false' the API requires; requests would + otherwise send str(value), i.e. 'True' and 'False'. + """ + + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + return [HydroServer._encode_query_value(item) for item in value] + + return value + def request(self, method, path, *args, **kwargs) -> requests.Response: """Sends a request to HydroServer's API.""" + if isinstance(kwargs.get("params"), dict): + kwargs["params"] = { + key: self._encode_query_value(value) for key, value in kwargs["params"].items() + } + for attempt in range(2): try: response = getattr(self._session, method)( diff --git a/packages/hydroserverpy/tests/test_api_client.py b/packages/hydroserverpy/tests/test_api_client.py index 58af95d7..4de125b8 100644 --- a/packages/hydroserverpy/tests/test_api_client.py +++ b/packages/hydroserverpy/tests/test_api_client.py @@ -1,3 +1,5 @@ +import json + import pytest import requests @@ -9,6 +11,9 @@ def __init__(self, status_code=200, content=b"{}"): self.status_code = status_code self.content = content + def json(self): + return json.loads(self.content) + def raise_for_status(self): if self.status_code >= 400: raise requests.HTTPError(response=self) @@ -28,6 +33,7 @@ def close(self): self.closed = True def _request(self, method, *args, **kwargs): + self.last_kwargs = kwargs response = self._responses[method].pop(0) if isinstance(response, Exception): raise response @@ -121,3 +127,36 @@ def next_session(): assert fake_session_factory[0].closed is True assert hs._session is replacement_session assert hs._session.auth == ("user@example.com", "secret") + + +def test_hydroserver_encodes_boolean_query_params_in_lowercase(fake_session_factory): + hs = client_module.HydroServer(host="https://example.com", apikey="hs_test_api_key") + fake_session_factory[0].queue("get", FakeResponse()) + + hs.request( + "get", + "/api/ogc/collections/workspaces/items", + params={"is_private": True, "is_associated": False, "workspace_id": ["a", "null"], "limit": 5}, + ) + + assert fake_session_factory[0].last_kwargs["params"] == { + "is_private": "true", + "is_associated": "false", + "workspace_id": ["a", "null"], + "limit": 5, + } + + +def test_workspace_list_sends_lowercase_booleans(fake_session_factory): + hs = client_module.HydroServer(host="https://example.com", apikey="hs_test_api_key") + fake_session_factory[0].queue( + "get", FakeResponse(content=b'{"data": [], "meta": {"limit": 100, "offset": 0, "totalCount": 0}}') + ) + + hs.workspaces.list(is_private=True, is_associated=False) + + url = requests.Request( + "GET", "https://example.com", params=fake_session_factory[0].last_kwargs["params"] + ).prepare().url + assert "is_private=true" in url + assert "is_associated=false" in url From 0ad8ee978713e611ee6a24045dce18c7e271ff81 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Mon, 28 Sep 2026 15:11:34 -0700 Subject: [PATCH 22/25] Updated linked resource and task run responses for consistency with other endpoints --- django/contracts/openapi/ogc.openapi.json | 150 +++++--- django/interfaces/api/http/links.py | 63 ++- django/interfaces/api/schemas/__init__.py | 7 +- django/interfaces/api/schemas/base.py | 363 ++++++++++-------- .../api/schemas/sta/linked_resource.py | 4 +- .../interfaces/api/services/sta/datastream.py | 7 +- .../api/services/sta/monitoring_site.py | 7 +- django/interfaces/api/views/etl/task.py | 14 +- .../interfaces/api/views/monitoring/task.py | 14 +- django/interfaces/api/views/products/task.py | 14 +- django/interfaces/api/views/sta/datastream.py | 4 +- .../api/views/sta/monitoring_site.py | 4 +- .../api/test_data_product_task_views.py | 40 +- .../interfaces/api/test_datastream_views.py | 46 ++- .../interfaces/api/test_etl_task_views.py | 40 +- .../api/test_monitoring_site_views.py | 47 ++- .../api/test_monitoring_task_views.py | 40 +- ...monitoring-site-datastream.service.spec.ts | 28 ++ .../src/api/__tests__/task.service.spec.ts | 22 ++ .../src/generated/contracts/runs.contract.ts | 2 +- .../src/generated/ogc.types.d.ts | 53 ++- .../hydroserverpy/api/services/etl/task.py | 4 +- .../api/services/monitoring/task.py | 4 +- .../api/services/products/task.py | 4 +- .../hydroserverpy/tests/test_api_client.py | 20 + 25 files changed, 672 insertions(+), 329 deletions(-) diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index c7d00ef0..4764c53b 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -4925,6 +4925,41 @@ "title": "ItemResponse[ServiceAccountResponse]", "type": "object" }, + "ItemResponse_TaskRunResponse_": { + "properties": { + "data": { + "$ref": "#/components/schemas/TaskRunResponse" + }, + "included": { + "anyOf": [ + { + "additionalProperties": { + "items": {}, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "ItemResponse[TaskRunResponse]", + "type": "object" + }, "ItemResponse_UnitResponse_": { "properties": { "data": { @@ -5229,18 +5264,6 @@ }, "LinkedResourceQueryParameters": { "properties": { - "include": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Comma-separated list of related resources to include in the response.", - "title": "Include" - }, "limit": { "default": 100, "description": "The maximum number of items to return. (default: 100, maximum: 100000)", @@ -9332,6 +9355,49 @@ "title": "PaginatedResponse[EtlTaskResponse]", "type": "object" }, + "PaginatedResponse_LinkedResourceGetResponse_": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/LinkedResourceGetResponse" + }, + "title": "Data", + "type": "array" + }, + "included": { + "anyOf": [ + { + "additionalProperties": { + "items": {}, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/PaginationMeta" + } + }, + "required": [ + "data", + "meta" + ], + "title": "PaginatedResponse[LinkedResourceGetResponse]", + "type": "object" + }, "PaginatedResponse_LinkedResourceTypeResponse_": { "properties": { "data": { @@ -16997,7 +17063,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskRunResponse" + "$ref": "#/components/schemas/ItemResponse_TaskRunResponse_" } } }, @@ -17078,7 +17144,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskRunResponse" + "$ref": "#/components/schemas/ItemResponse_TaskRunResponse_" } } }, @@ -19315,24 +19381,6 @@ "title": "Properties" } }, - { - "description": "Comma-separated list of related resources to include in the response.", - "in": "query", - "name": "include", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Comma-separated list of related resources to include in the response.", - "title": "Include" - } - }, { "description": "Number of items to skip.", "in": "query", @@ -19388,11 +19436,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/LinkedResourceGetResponse" - }, - "title": "Response", - "type": "array" + "$ref": "#/components/schemas/PaginatedResponse_LinkedResourceGetResponse_" } } }, @@ -22388,7 +22432,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskRunResponse" + "$ref": "#/components/schemas/ItemResponse_TaskRunResponse_" } } }, @@ -22469,7 +22513,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskRunResponse" + "$ref": "#/components/schemas/ItemResponse_TaskRunResponse_" } } }, @@ -26200,24 +26244,6 @@ "title": "Properties" } }, - { - "description": "Comma-separated list of related resources to include in the response.", - "in": "query", - "name": "include", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Comma-separated list of related resources to include in the response.", - "title": "Include" - } - }, { "description": "Number of items to skip.", "in": "query", @@ -26273,11 +26299,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/LinkedResourceGetResponse" - }, - "title": "Response", - "type": "array" + "$ref": "#/components/schemas/PaginatedResponse_LinkedResourceGetResponse_" } } }, @@ -28153,7 +28175,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskRunResponse" + "$ref": "#/components/schemas/ItemResponse_TaskRunResponse_" } } }, @@ -28234,7 +28256,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TaskRunResponse" + "$ref": "#/components/schemas/ItemResponse_TaskRunResponse_" } } }, diff --git a/django/interfaces/api/http/links.py b/django/interfaces/api/http/links.py index 1e4a9c8e..14d8b7bb 100644 --- a/django/interfaces/api/http/links.py +++ b/django/interfaces/api/http/links.py @@ -21,26 +21,24 @@ class Link(Schema): title: Optional[str] = None -def build_absolute_url(path: str) -> str: - """ - Builds an absolute URL for a path relative to Django's root from PROXY_BASE_URL, which is the - public URL of Django's root, so links are correct behind a proxy regardless of the request's - Host. - """ +def api_root_path() -> str: + """The OGC API root relative to Django's root, e.g., 'api/ogc/'.""" - return settings.PROXY_BASE_URL.rstrip("/") + "/" + path.lstrip("/") + return route_path("ogc:api-root") def route_path(route_name: str) -> str: - """A named route's path relative to Django's root, e.g. 'api/ogc/openapi.json'.""" + """A named route's path relative to Django's root, e.g., 'api/ogc/openapi.json'.""" return reverse(route_name).removeprefix(get_script_prefix()) -def api_root_path() -> str: - """The OGC API root relative to Django's root, e.g. 'api/ogc/'.""" +def build_absolute_url(path: str, query_string: str = "") -> str: + """Builds an absolute URL for a path relative to Django's root from PROXY_BASE_URL""" - return route_path("ogc:api-root") + url = settings.PROXY_BASE_URL.rstrip("/") + "/" + path.lstrip("/") + + return f"{url}?{query_string}" if query_string else url def build_api_url(path: str = "") -> str: @@ -49,30 +47,20 @@ def build_api_url(path: str = "") -> str: return build_absolute_url(api_root_path() + path.lstrip("/")) -def build_url(request: HttpRequest, query_string: str) -> str: - """Builds an absolute URL for the request's path with the given query string.""" - - url = build_absolute_url(request.path_info) - - return f"{url}?{query_string}" if query_string else url +def build_self_link(request: HttpRequest, path: Optional[str] = None) -> Link: + """Builds a link to this response document.""" - -def build_self_link(request: HttpRequest) -> Link: - """A link to this response document (OGC API - Features Core Req 28 and 35).""" - - return Link( - href=build_url(request, request.META.get("QUERY_STRING", "")), - rel="self", - type=JSON_MEDIA_TYPE, + href = ( + build_absolute_url(path) + if path + else build_absolute_url(request.path_info, request.META.get("QUERY_STRING", "")) ) + return Link(href=href, rel="self", type=JSON_MEDIA_TYPE) + def build_collection_link(request: HttpRequest) -> Optional[Link]: - """ - A link to the collection that contains the requested item (OGC API - Features Core Req 35), - or None when the request isn't for /collections/{collectionId}/items/{itemId}, such as a - nested sub-resource whose parent isn't a collection. - """ + """Builds a link to the collection that contains the requested item.""" path = request.path_info.lstrip("/").removeprefix(api_root_path()) match = COLLECTION_ITEM_PATH.fullmatch(path) @@ -88,22 +76,19 @@ def build_collection_link(request: HttpRequest) -> Optional[Link]: def build_page_link(request: HttpRequest, rel: str, offset: int, limit: int) -> Link: + """Builds a pagination link with the specified parameters.""" + query = request.GET.copy() query["offset"] = str(offset) query["limit"] = str(limit) - return Link(href=build_url(request, query.urlencode()), rel=rel, type=JSON_MEDIA_TYPE) + return Link( + href=build_absolute_url(request.path_info, query.urlencode()), rel=rel, type=JSON_MEDIA_TYPE + ) def build_page_links(request: HttpRequest, offset: int, limit: int, returned: int) -> list[Link]: - """ - Returns the self-link plus next and prev links for a page of results (OGC API - Features - Core Req 28, Recs 17-19 and Permission 7). - - A next link is included when the page is full rather than by comparing against the total - count, which can be a Postgres estimate or a stored value count that runs low. At worst the - last next link leads to an empty page, which Core allows. - """ + """Returns the self-link plus next and prev links for a page of results.""" links = [build_self_link(request)] diff --git a/django/interfaces/api/schemas/__init__.py b/django/interfaces/api/schemas/__init__.py index d7ed7859..bab38398 100644 --- a/django/interfaces/api/schemas/__init__.py +++ b/django/interfaces/api/schemas/__init__.py @@ -1,7 +1,6 @@ -from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, CollectionQueryParameters, - PaginationMeta, PaginatedResponse, - CreatedResponse, ItemResponse, split_comma_separated, comma_array_schema, - split_sortby, sortby_array_schema, QueryBool) +from .base import (BaseGetResponse, BasePostBody, BasePatchBody, BaseQueryParameters, PaginatedQueryParameters, + CollectionQueryParameters, PaginationMeta, PaginatedResponse, CreatedResponse, ItemResponse, + split_comma_separated, comma_array_schema, split_sortby, sortby_array_schema, QueryBool) from .extent import (BoundingBox, BoundingBoxQuery, DatetimeQuery, ExtentQueryParameters, TimeInterval, parse_bbox, parse_datetime) from interfaces.api.schemas.iam.user import UserContactResponse diff --git a/django/interfaces/api/schemas/base.py b/django/interfaces/api/schemas/base.py index 8461f482..de526705 100644 --- a/django/interfaces/api/schemas/base.py +++ b/django/interfaces/api/schemas/base.py @@ -27,6 +27,9 @@ ) MAX_LIMIT = 100000 +NULL_QUERY_VALUE = "null" + +T = TypeVar("T") base_alias_generator = AliasGenerator( serialization_alias=lambda field_name: to_camel(field_name), @@ -55,23 +58,6 @@ def split_comma_separated(value: Any) -> Optional[list[str]]: return result -def comma_array_schema(literal_type: Any) -> dict: - """ - Generates a JSON schema for an array of strings based on a given Python Literal type. - - This function is used to create a schema that defines an array of strings, where each string - in the array is restricted to a specific predefined set of values, as determined by the provided - Literal type. - """ - - return { - "type": "array", - "items": {"type": "string", "enum": list(get_args(literal_type))}, - "style": "form", - "explode": False, - } - - def split_sortby(value: Any) -> Optional[list[str]]: """ Splits sortby values like split_comma_separated and drops a leading '+', which means @@ -90,58 +76,24 @@ def split_sortby(value: Any) -> Optional[list[str]]: ] -def sortby_array_schema(literal_type: Any) -> dict: - """ - Generates the JSON schema for a comma-separated sortby parameter. The enum lists each field - in the given Literal type, which holds 'field' and '-field', plus '+field' for ascending. - """ - - schema = comma_array_schema(literal_type) - fields = schema["items"]["enum"] - schema["items"]["enum"] = [ - *fields, - *[f"+{field}" for field in fields if not field.startswith("-")], - ] - - return schema - - -def parse_requested_properties(info: SerializationInfo) -> Optional[set[str]]: - """ - Parses and retrieves the requested properties from the serialization context. - - Extracts the "properties" parameter from the serialization context's request and - converts it into a set of property names. If no properties are specified in the - request, the function returns None. - """ - - request = (info.context or {}).get("request") if info.context else None - query_dict = getattr(request, "GET", None) if request is not None else None - raw_values = query_dict.getlist("properties") if query_dict is not None else [] - parsed = split_comma_separated(raw_values) - - if not parsed: - return None - - return set(parsed) - +def parse_query_bool(value: Any) -> Any: + """Accepts only the lowercase strings 'true' and 'false' for a boolean query parameter.""" -def filter_requested_properties(item: Any, requested: set[str]) -> Any: - """ - Filters properties of a dictionary based on a specified set of keys. + if isinstance(value, bool): + return value - This function takes a dictionary and a set of requested keys and returns a new dictionary - containing only the key-value pairs where the key exists in the requested set. If the input - item is not a dictionary, it is returned as is. - """ + if value == "true": + return True + if value == "false": + return False - if not isinstance(item, dict): - return item + raise ValueError("Boolean query parameters must be 'true' or 'false'") - return {k: v for k, v in item.items() if k in requested} +def clamp_limit(value: Optional[int]) -> Optional[int]: + """Caps a page size at MAX_LIMIT.""" -NULL_QUERY_VALUE = "null" + return value if value is None else min(value, MAX_LIMIT) def accepts_null(annotation: Any) -> bool: @@ -153,18 +105,37 @@ def accepts_null(annotation: Any) -> bool: return any(accepts_null(arg) for arg in get_args(annotation)) -def parse_query_bool(value: Any) -> Any: - """Accepts only the lowercase strings 'true' and 'false' for a boolean query parameter.""" +def comma_array_schema(literal_type: Any) -> dict: + """ + Generates a JSON schema for an array of strings based on a given Python Literal type. - if isinstance(value, bool): - return value + This function is used to create a schema that defines an array of strings, where each string + in the array is restricted to a specific predefined set of values, as determined by the provided + Literal type. + """ - if value == "true": - return True - if value == "false": - return False + return { + "type": "array", + "items": {"type": "string", "enum": list(get_args(literal_type))}, + "style": "form", + "explode": False, + } - raise ValueError("Boolean query parameters must be 'true' or 'false'") + +def sortby_array_schema(literal_type: Any) -> dict: + """ + Generates the JSON schema for a comma-separated sortby parameter. The enum lists each field + in the given Literal type, which holds 'field' and '-field', plus '+field' for ascending. + """ + + schema = comma_array_schema(literal_type) + fields = schema["items"]["enum"] + schema["items"]["enum"] = [ + *fields, + *[f"+{field}" for field in fields if not field.startswith("-")], + ] + + return schema QueryBool = Annotated[ @@ -173,6 +144,32 @@ def parse_query_bool(value: Any) -> Any: WithJsonSchema({"type": "boolean"}), ] +LimitQuery = Annotated[ + Optional[int], + AfterValidator(clamp_limit), + WithJsonSchema({"type": "integer", "minimum": 0, "maximum": MAX_LIMIT}), +] + + +def properties_query(): + return Query( + None, + description="Comma-separated list of properties to include in the response. " + "All properties are returned if omitted.", + ) + + +def offset_query(): + return Query(0, ge=0, description="Number of items to skip.") + + +def limit_query(): + return Query( + 100, + ge=0, + description=f"The maximum number of items to return. (default: 100, maximum: {MAX_LIMIT})", + ) + class BaseQueryParameters(Schema): model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True) @@ -197,34 +194,134 @@ def convert_null_values(cls, value: Any, info: ValidationInfo) -> Any: return value -def clamp_limit(value: Optional[int]) -> Optional[int]: - """Caps a page size at MAX_LIMIT.""" - - return value if value is None else min(value, MAX_LIMIT) - - class CollectionQueryParameters(BaseQueryParameters): - properties: Optional[str] = Query( - None, - description="Comma-separated list of properties to include in the response. " - "All properties are returned if omitted.", - ) + properties: Optional[str] = properties_query() include: Optional[str] = Query( None, description="Comma-separated list of related resources to include in the response.", ) - offset: Optional[int] = Query(0, ge=0, description="Number of items to skip.") - limit: Annotated[ - Optional[int], - AfterValidator(clamp_limit), - WithJsonSchema({"type": "integer", "minimum": 0, "maximum": MAX_LIMIT}), - ] = Query( - 100, - ge=0, - description=f"The maximum number of items to return. (default: 100, maximum: {MAX_LIMIT})", + offset: Optional[int] = offset_query() + limit: LimitQuery = limit_query() + + +class PaginatedQueryParameters(BaseQueryParameters): + """CollectionQueryParameters without include, for collections whose items have no related resources.""" + + properties: Optional[str] = properties_query() + offset: Optional[int] = offset_query() + limit: LimitQuery = limit_query() + + +class BasePostBody(Schema): + @field_validator("*", mode="before") + def empty_str_to_none(cls, value): + if isinstance(value, str) and value == "": + return None + return value + + model_config = ConfigDict( + populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel + ) + + +class PartialMetaclass(type(Schema)): + """ + Metaclass for creating "partial" schemas. + + Marks all fields as `Unset` by default, necessary for PATCH operations where only provided fields should + be included, or responses where a subset of fields is selected by the client. Copies all inherited model fields and + annotations to avoid mutating base classes. + """ + + def __new__( + cls, name: str, bases: tuple[type, ...], attrs: dict, **kwargs + ) -> "PartialMetaclass": + new_cls = super().__new__(cls, name, bases, attrs, **kwargs) + fields = {k: copy.deepcopy(v) for k, v in new_cls.model_fields.items()} + + for field in fields.values(): + field.default = Unset + + metadata = field.metadata + original_annotation = field.annotation + + if metadata: + constrained_arm = Annotated[original_annotation, *metadata] + field.metadata = [] + else: + constrained_arm = original_annotation + + field.annotation = Union[constrained_arm, type(Unset)] + + # `model_fields` is a read-only view of `__pydantic_fields__` as of newer + # pydantic versions, so the canonical field mapping must be updated directly + # for schema regeneration to pick up the mutated defaults/annotations. + new_cls.__pydantic_fields__ = fields + new_cls.model_rebuild(force=True) # noqa + + return new_cls + + +class BasePatchBody(Schema, metaclass=PartialMetaclass): + @field_validator("*", mode="before") + def empty_str_to_none(cls, value): + if isinstance(value, str) and value == "": + return None + return value + + model_config = ConfigDict( + populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel ) +def parse_requested_properties(info: SerializationInfo) -> Optional[set[str]]: + """ + Parses and retrieves the requested properties from the serialization context. + + Extracts the "properties" parameter from the serialization context's request and + converts it into a set of property names. If no properties are specified in the + request, the function returns None. + """ + + request = (info.context or {}).get("request") if info.context else None + query_dict = getattr(request, "GET", None) if request is not None else None + raw_values = query_dict.getlist("properties") if query_dict is not None else [] + parsed = split_comma_separated(raw_values) + + if not parsed: + return None + + return set(parsed) + + +def filter_requested_properties(item: Any, requested: set[str]) -> Any: + """ + Filters properties of a dictionary based on a specified set of keys. + + This function takes a dictionary and a set of requested keys and returns a new dictionary + containing only the key-value pairs where the key exists in the requested set. If the input + item is not a dictionary, it is returned as is. + """ + + if not isinstance(item, dict): + return item + + return {k: v for k, v in item.items() if k in requested} + + +def page_links(info: SerializationInfo, meta: "PaginationMeta", returned: int) -> Optional[list[dict]]: + """Serialized self, next and prev links for a page of results, or None without a request.""" + + request = get_request(info) + if request is None: + return None + + return [ + link.model_dump(exclude_none=True) + for link in build_page_links(request, meta.offset, meta.limit, returned) + ] + + class BaseGetResponse(Schema): model_config = ConfigDict( populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel @@ -245,9 +342,6 @@ class PaginationMeta(Schema): model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) -T = TypeVar("T") - - class ItemResponse(Schema, Generic[T]): included: Optional[dict[str, list[Any]]] = None data: T @@ -265,6 +359,10 @@ def _finalize(self, handler, info: SerializationInfo): if requested is not None: data["data"] = filter_requested_properties(data.get("data"), requested) + if self.links: + data["links"] = [link.model_dump(exclude_none=True) for link in self.links] + return data + request = get_request(info) if request is not None: links = [build_self_link(request), build_collection_link(request)] @@ -273,19 +371,6 @@ def _finalize(self, handler, info: SerializationInfo): return data -def page_links(info: SerializationInfo, meta: PaginationMeta, returned: int) -> Optional[list[dict]]: - """Serialized self, next and prev links for a page of results, or None without a request.""" - - request = get_request(info) - if request is None: - return None - - return [ - link.model_dump(exclude_none=True) - for link in build_page_links(request, meta.offset, meta.limit, returned) - ] - - class PaginatedResponse(Schema, Generic[T]): included: Optional[dict[str, list[Any]]] = None data: list[T] @@ -312,65 +397,3 @@ def _finalize(self, handler, info: SerializationInfo): data["links"] = links return data - - -class BasePostBody(Schema): - @field_validator("*", mode="before") - def empty_str_to_none(cls, value): - if isinstance(value, str) and value == "": - return None - return value - - model_config = ConfigDict( - populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel - ) - - -class PartialMetaclass(type(Schema)): - """ - Metaclass for creating "partial" schemas. - - Marks all fields as `Unset` by default, necessary for PATCH operations where only provided fields should - be included, or responses where a subset of fields is selected by the client. Copies all inherited model fields and - annotations to avoid mutating base classes. - """ - - def __new__( - cls, name: str, bases: tuple[type, ...], attrs: dict, **kwargs - ) -> "PartialMetaclass": - new_cls = super().__new__(cls, name, bases, attrs, **kwargs) - fields = {k: copy.deepcopy(v) for k, v in new_cls.model_fields.items()} - - for field in fields.values(): - field.default = Unset - - metadata = field.metadata - original_annotation = field.annotation - - if metadata: - constrained_arm = Annotated[original_annotation, *metadata] - field.metadata = [] - else: - constrained_arm = original_annotation - - field.annotation = Union[constrained_arm, type(Unset)] - - # `model_fields` is a read-only view of `__pydantic_fields__` as of newer - # pydantic versions, so the canonical field mapping must be updated directly - # for schema regeneration to pick up the mutated defaults/annotations. - new_cls.__pydantic_fields__ = fields - new_cls.model_rebuild(force=True) # noqa - - return new_cls - - -class BasePatchBody(Schema, metaclass=PartialMetaclass): - @field_validator("*", mode="before") - def empty_str_to_none(cls, value): - if isinstance(value, str) and value == "": - return None - return value - - model_config = ConfigDict( - populate_by_name=True, str_strip_whitespace=True, alias_generator=to_camel - ) diff --git a/django/interfaces/api/schemas/sta/linked_resource.py b/django/interfaces/api/schemas/sta/linked_resource.py index 2b8199a9..4145fd5f 100644 --- a/django/interfaces/api/schemas/sta/linked_resource.py +++ b/django/interfaces/api/schemas/sta/linked_resource.py @@ -4,11 +4,11 @@ from interfaces.api.schemas import ( BaseGetResponse, BasePostBody, - CollectionQueryParameters, + PaginatedQueryParameters, ) -class LinkedResourceQueryParameters(CollectionQueryParameters): +class LinkedResourceQueryParameters(PaginatedQueryParameters): type: list[str] = Query([], description="Filter by linked resource type.") diff --git a/django/interfaces/api/services/sta/datastream.py b/django/interfaces/api/services/sta/datastream.py index 3dc91b56..731b3276 100644 --- a/django/interfaces/api/services/sta/datastream.py +++ b/django/interfaces/api/services/sta/datastream.py @@ -436,6 +436,8 @@ def get_linked_resources( self, principal: User | ServiceAccount | AnonymousPrincipal, uid: uuid.UUID, + offset: Optional[int] = None, + limit: Optional[int] = None, filtering: Optional[dict] = None, ): datastream = self.get_datastream_for_action( @@ -447,7 +449,10 @@ def get_linked_resources( if filtering.get("type"): queryset = self.apply_filters(queryset, "type", filtering["type"]) - return queryset.all() + queryset = self.apply_sorting(queryset, [], [], default_sortby=("name",)) + queryset, meta = self.apply_pagination(queryset, offset, limit) + + return {"data": list(queryset), "meta": meta} def add_linked_resource( self, diff --git a/django/interfaces/api/services/sta/monitoring_site.py b/django/interfaces/api/services/sta/monitoring_site.py index 412813ba..1ceec400 100644 --- a/django/interfaces/api/services/sta/monitoring_site.py +++ b/django/interfaces/api/services/sta/monitoring_site.py @@ -521,6 +521,8 @@ def get_linked_resources( self, principal: User | ServiceAccount | AnonymousPrincipal, uid: uuid.UUID, + offset: Optional[int] = None, + limit: Optional[int] = None, filtering: Optional[dict] = None, ): monitoring_site = self.get_monitoring_site_for_action( @@ -532,7 +534,10 @@ def get_linked_resources( if filtering.get("type"): queryset = self.apply_filters(queryset, "type", filtering["type"]) - return queryset.all() + queryset = self.apply_sorting(queryset, [], [], default_sortby=("name",)) + queryset, meta = self.apply_pagination(queryset, offset, limit) + + return {"data": list(queryset), "meta": meta} def add_linked_resource( self, diff --git a/django/interfaces/api/views/etl/task.py b/django/interfaces/api/views/etl/task.py index 91cffa4f..3bdabef0 100644 --- a/django/interfaces/api/views/etl/task.py +++ b/django/interfaces/api/views/etl/task.py @@ -1,7 +1,9 @@ import uuid +from django.http import HttpResponse from ninja import Router, Path, Query +from interfaces.api.http.links import build_self_link from interfaces.api.http.request import HydroServerHttpRequest from interfaces.auth.security import session_auth, oidc_auth, apikey_auth, basic_auth from processing.orchestration.models import TaskRun @@ -154,7 +156,7 @@ def delete_etl_task( "/items/{task_id}/trigger", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ - 202: TaskRunResponse, + 202: ItemResponse[TaskRunResponse], 401: str, 403: str, 404: str, @@ -163,6 +165,7 @@ def delete_etl_task( ) def trigger_etl_task( request: HydroServerHttpRequest, + response: HttpResponse, task_id: Path[uuid.UUID], ): """ @@ -176,7 +179,10 @@ def trigger_etl_task( run = TaskRun.objects.create(task=etl_task, status="PENDING") run_etl_task.apply_async(kwargs={"task_id": str(etl_task.id), "run_id": str(run.id)}) - return 202, run + run_link = build_self_link(request, request.path_info.removesuffix("/trigger") + f"/runs/{run.id}") + response["Location"] = run_link.href + + return 202, {"data": run, "links": [run_link]} @etl_task_router.get( @@ -225,7 +231,7 @@ def get_etl_task_runs( "/items/{task_id}/runs/{run_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ - 200: TaskRunResponse, + 200: ItemResponse[TaskRunResponse], 401: str, 403: str, 404: str, @@ -247,4 +253,4 @@ def get_etl_task_run( principal=request.principal, ) - return 200, run + return 200, {"data": run} diff --git a/django/interfaces/api/views/monitoring/task.py b/django/interfaces/api/views/monitoring/task.py index 618471a1..a831497a 100644 --- a/django/interfaces/api/views/monitoring/task.py +++ b/django/interfaces/api/views/monitoring/task.py @@ -1,7 +1,9 @@ import uuid +from django.http import HttpResponse from ninja import Router, Path, Query +from interfaces.api.http.links import build_self_link from interfaces.api.http.request import HydroServerHttpRequest from interfaces.auth.security import session_auth, oidc_auth, apikey_auth, basic_auth from processing.orchestration.models import TaskRun @@ -154,7 +156,7 @@ def delete_monitoring_task( "/items/{task_id}/trigger", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ - 202: TaskRunResponse, + 202: ItemResponse[TaskRunResponse], 401: str, 403: str, 404: str, @@ -163,6 +165,7 @@ def delete_monitoring_task( ) def trigger_monitoring_task( request: HydroServerHttpRequest, + response: HttpResponse, task_id: Path[uuid.UUID], ): """ @@ -176,7 +179,10 @@ def trigger_monitoring_task( run = TaskRun.objects.create(task=task, status="PENDING") run_monitoring_task.apply_async(kwargs={"task_id": str(task.id), "run_id": str(run.id)}) - return 202, run + run_link = build_self_link(request, request.path_info.removesuffix("/trigger") + f"/runs/{run.id}") + response["Location"] = run_link.href + + return 202, {"data": run, "links": [run_link]} @monitoring_task_router.get( @@ -225,7 +231,7 @@ def get_monitoring_task_runs( "/items/{task_id}/runs/{run_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ - 200: TaskRunResponse, + 200: ItemResponse[TaskRunResponse], 401: str, 403: str, 404: str, @@ -247,4 +253,4 @@ def get_monitoring_task_run( principal=request.principal, ) - return 200, run + return 200, {"data": run} diff --git a/django/interfaces/api/views/products/task.py b/django/interfaces/api/views/products/task.py index f128c822..5c6e2dbe 100644 --- a/django/interfaces/api/views/products/task.py +++ b/django/interfaces/api/views/products/task.py @@ -1,7 +1,9 @@ import uuid +from django.http import HttpResponse from ninja import Router, Path, Query +from interfaces.api.http.links import build_self_link from interfaces.api.http.request import HydroServerHttpRequest from interfaces.auth.security import session_auth, oidc_auth, apikey_auth, basic_auth from processing.orchestration.models import TaskRun @@ -156,7 +158,7 @@ def delete_data_product_task( "/items/{task_id}/trigger", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ - 202: TaskRunResponse, + 202: ItemResponse[TaskRunResponse], 401: str, 403: str, 404: str, @@ -165,6 +167,7 @@ def delete_data_product_task( ) def trigger_data_product_task( request: HydroServerHttpRequest, + response: HttpResponse, task_id: Path[uuid.UUID], ): """ @@ -178,7 +181,10 @@ def trigger_data_product_task( run = TaskRun.objects.create(task=task, status="PENDING") run_data_product_task.apply_async(kwargs={"task_id": str(task.id), "run_id": str(run.id)}) - return 202, run + run_link = build_self_link(request, request.path_info.removesuffix("/trigger") + f"/runs/{run.id}") + response["Location"] = run_link.href + + return 202, {"data": run, "links": [run_link]} @data_product_task_router.get( @@ -227,7 +233,7 @@ def get_data_product_task_runs( "/items/{task_id}/runs/{run_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], response={ - 200: TaskRunResponse, + 200: ItemResponse[TaskRunResponse], 401: str, 403: str, 404: str, @@ -249,4 +255,4 @@ def get_data_product_task_run( principal=request.principal, ) - return 200, run + return 200, {"data": run} diff --git a/django/interfaces/api/views/sta/datastream.py b/django/interfaces/api/views/sta/datastream.py index e9ca9e8c..fd0cb7ee 100644 --- a/django/interfaces/api/views/sta/datastream.py +++ b/django/interfaces/api/views/sta/datastream.py @@ -206,7 +206,7 @@ def delete_datastream(request: HydroServerHttpRequest, datastream_id: Path[uuid. "/items/{datastream_id}/linked-resources", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ - 200: list[LinkedResourceGetResponse], + 200: PaginatedResponse[LinkedResourceGetResponse], 401: str, 403: str, }, @@ -224,6 +224,8 @@ def get_datastream_linked_resources( return 200, datastream_service.get_linked_resources( principal=request.principal, uid=datastream_id, + offset=query.offset, + limit=query.limit, filtering=query.dict(exclude_unset=True), ) diff --git a/django/interfaces/api/views/sta/monitoring_site.py b/django/interfaces/api/views/sta/monitoring_site.py index d0603966..e4170a2b 100644 --- a/django/interfaces/api/views/sta/monitoring_site.py +++ b/django/interfaces/api/views/sta/monitoring_site.py @@ -270,7 +270,7 @@ def delete_monitoring_site(request: HydroServerHttpRequest, monitoring_site_id: "/items/{monitoring_site_id}/linked-resources", auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], response={ - 200: list[LinkedResourceGetResponse], + 200: PaginatedResponse[LinkedResourceGetResponse], 401: str, 403: str, }, @@ -288,6 +288,8 @@ def get_monitoring_site_linked_resources( return 200, monitoring_site_service.get_linked_resources( principal=request.principal, uid=monitoring_site_id, + offset=query.offset, + limit=query.limit, filtering=query.dict(exclude_unset=True), ) diff --git a/django/tests/interfaces/api/test_data_product_task_views.py b/django/tests/interfaces/api/test_data_product_task_views.py index db0b4b40..b505f6a0 100644 --- a/django/tests/interfaces/api/test_data_product_task_views.py +++ b/django/tests/interfaces/api/test_data_product_task_views.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from django.conf import settings from django.db import connection from django.test.utils import CaptureQueriesContext from django.utils import timezone @@ -346,7 +347,7 @@ def test_trigger_data_product_task_creates_pending_run_for_workspace_owner(clien response = client.post(f"{_detail_url(task.id)}/trigger") assert response.status_code == 202 - assert response.json()["status"] == "PENDING" + assert response.json()["data"]["status"] == "PENDING" mock_apply_async.assert_called_once() assert TaskRun.objects.filter(task=task, status="PENDING").exists() @@ -380,6 +381,41 @@ def test_get_data_product_task_runs_returns_runs_for_workspace_owner(client): assert str(run.id) in [r["id"] for r in response.json()["data"]] +def test_trigger_data_product_task_returns_the_run_with_links_to_it(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + task = _make_data_product_task(workspace) + client.force_login(owner) + + with patch("processing.products.tasks.run_data_product_task.apply_async"): + response = client.post(f"{_detail_url(task.id)}/trigger") + + run = TaskRun.objects.get(task=task) + run_url = f"{settings.PROXY_BASE_URL.rstrip('/')}/api/ogc/collections/data-product-tasks/items/{task.id}/runs/{run.id}" + assert response.status_code == 202 + assert response["Location"] == run_url + assert response.json()["data"]["id"] == str(run.id) + assert response.json()["links"] == [{"href": run_url, "rel": "self", "type": "application/json"}] + + +def test_get_data_product_task_run_returns_a_self_link_and_no_collection_link(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + task = _make_data_product_task(workspace) + run = TaskRun.objects.create(task=task, status="SUCCESS") + client.force_login(owner) + + response = client.get(f"{_detail_url(task.id)}/runs/{run.id}") + + assert response.json()["links"] == [ + { + "href": f"{settings.PROXY_BASE_URL.rstrip('/')}/api/ogc/collections/data-product-tasks/items/{task.id}/runs/{run.id}", + "rel": "self", + "type": "application/json", + } + ] + + def test_get_data_product_task_run_returns_200_for_workspace_owner(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) @@ -390,7 +426,7 @@ def test_get_data_product_task_run_returns_200_for_workspace_owner(client): response = client.get(f"{_detail_url(task.id)}/runs/{run.id}") assert response.status_code == 200 - assert response.json()["id"] == str(run.id) + assert response.json()["data"]["id"] == str(run.id) def test_get_data_product_task_runs_accepts_properties_filter(client): diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index 7907cef0..0af6d4d8 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -6,7 +6,7 @@ from django.db import connection from django.test.utils import CaptureQueriesContext -from core.sta.models import Datastream +from core.sta.models import Datastream, DatastreamLinkedResource from tests.core.iam.factories import ( CollaboratorFactory, PermissionFactory, @@ -749,6 +749,48 @@ def _linked_resources_url(datastream_id): return f"{_detail_url(datastream_id)}/linked-resources" +def _make_datastream_linked_resources(owner, names): + workspace = WorkspaceFactory(owner=owner) + parent = _make_datastream(workspace) + for name in names: + DatastreamLinkedResource.objects.create(datastream=parent, name=name, type="Report", url=f"https://example.com/{name}") + return parent + + +def test_get_datastream_linked_resources_pages_by_name(client): + owner = UserFactory() + parent = _make_datastream_linked_resources(owner, ["C", "A", "B"]) + client.force_login(owner) + + first = client.get(_linked_resources_url(parent.id), {"limit": 2}).json() + second = client.get(_linked_resources_url(parent.id), {"limit": 2, "offset": 2}).json() + + assert [item["name"] for item in first["data"]] == ["A", "B"] + assert first["meta"] == {"limit": 2, "offset": 0, "totalCount": 3} + assert "next" in [link["rel"] for link in first["links"]] + assert [item["name"] for item in second["data"]] == ["C"] + + +def test_get_datastream_linked_resources_selects_properties(client): + owner = UserFactory() + parent = _make_datastream_linked_resources(owner, ["A"]) + client.force_login(owner) + + response = client.get(_linked_resources_url(parent.id), {"properties": "name"}) + + assert response.json()["data"] == [{"name": "A"}] + + +def test_get_datastream_linked_resources_rejects_include(client): + owner = UserFactory() + parent = _make_datastream_linked_resources(owner, ["A"]) + client.force_login(owner) + + response = client.get(_linked_resources_url(parent.id), {"include": "datastream"}) + + assert response.status_code == 400 + + def test_add_datastream_linked_resource_succeeds_with_link(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) @@ -766,7 +808,7 @@ def test_add_datastream_linked_resource_succeeds_with_link(client): assert response.status_code == 201 assert set(response.json().keys()) == {"id"} - linked_resources = client.get(_linked_resources_url(datastream.id)).json() + linked_resources = client.get(_linked_resources_url(datastream.id)).json()["data"] assert linked_resources[0]["name"] == "Datastream Report" diff --git a/django/tests/interfaces/api/test_etl_task_views.py b/django/tests/interfaces/api/test_etl_task_views.py index 6012680d..13ccf17a 100644 --- a/django/tests/interfaces/api/test_etl_task_views.py +++ b/django/tests/interfaces/api/test_etl_task_views.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from django.conf import settings from django.db import connection from django.test.utils import CaptureQueriesContext from django.utils import timezone @@ -352,7 +353,7 @@ def test_trigger_etl_task_creates_pending_run_for_workspace_owner(client): response = client.post(f"{_detail_url(task.id)}/trigger") assert response.status_code == 202 - assert response.json()["status"] == "PENDING" + assert response.json()["data"]["status"] == "PENDING" mock_apply_async.assert_called_once() assert TaskRun.objects.filter(task=task, status="PENDING").exists() @@ -386,6 +387,41 @@ def test_get_etl_task_runs_returns_runs_for_workspace_owner(client): assert str(run.id) in [r["id"] for r in response.json()["data"]] +def test_trigger_etl_task_returns_the_run_with_links_to_it(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + task = _make_etl_task(workspace) + client.force_login(owner) + + with patch("processing.etl.tasks.run_etl_task.apply_async"): + response = client.post(f"{_detail_url(task.id)}/trigger") + + run = TaskRun.objects.get(task=task) + run_url = f"{settings.PROXY_BASE_URL.rstrip('/')}/api/ogc/collections/etl-tasks/items/{task.id}/runs/{run.id}" + assert response.status_code == 202 + assert response["Location"] == run_url + assert response.json()["data"]["id"] == str(run.id) + assert response.json()["links"] == [{"href": run_url, "rel": "self", "type": "application/json"}] + + +def test_get_etl_task_run_returns_a_self_link_and_no_collection_link(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + task = _make_etl_task(workspace) + run = TaskRun.objects.create(task=task, status="SUCCESS") + client.force_login(owner) + + response = client.get(f"{_detail_url(task.id)}/runs/{run.id}") + + assert response.json()["links"] == [ + { + "href": f"{settings.PROXY_BASE_URL.rstrip('/')}/api/ogc/collections/etl-tasks/items/{task.id}/runs/{run.id}", + "rel": "self", + "type": "application/json", + } + ] + + def test_get_etl_task_run_returns_200_for_workspace_owner(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) @@ -396,7 +432,7 @@ def test_get_etl_task_run_returns_200_for_workspace_owner(client): response = client.get(f"{_detail_url(task.id)}/runs/{run.id}") assert response.status_code == 200 - assert response.json()["id"] == str(run.id) + assert response.json()["data"]["id"] == str(run.id) def test_get_etl_task_runs_sortby_started_at_ascending(client): diff --git a/django/tests/interfaces/api/test_monitoring_site_views.py b/django/tests/interfaces/api/test_monitoring_site_views.py index 4f852204..fb3d464b 100644 --- a/django/tests/interfaces/api/test_monitoring_site_views.py +++ b/django/tests/interfaces/api/test_monitoring_site_views.py @@ -4,6 +4,7 @@ from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart from django.test.utils import CaptureQueriesContext +from core.sta.models import MonitoringSiteLinkedResource from tests.core.iam.factories import ( CollaboratorFactory, PermissionFactory, @@ -941,6 +942,48 @@ def _linked_resources_url(monitoring_site_id): return f"{_detail_url(monitoring_site_id)}/linked-resources" +def _make_monitoring_site_linked_resources(owner, names): + workspace = WorkspaceFactory(owner=owner) + parent = MonitoringSiteFactory(workspace=workspace) + for name in names: + MonitoringSiteLinkedResource.objects.create(monitoring_site=parent, name=name, type="Report", url=f"https://example.com/{name}") + return parent + + +def test_get_monitoring_site_linked_resources_pages_by_name(client): + owner = UserFactory() + parent = _make_monitoring_site_linked_resources(owner, ["C", "A", "B"]) + client.force_login(owner) + + first = client.get(_linked_resources_url(parent.id), {"limit": 2}).json() + second = client.get(_linked_resources_url(parent.id), {"limit": 2, "offset": 2}).json() + + assert [item["name"] for item in first["data"]] == ["A", "B"] + assert first["meta"] == {"limit": 2, "offset": 0, "totalCount": 3} + assert "next" in [link["rel"] for link in first["links"]] + assert [item["name"] for item in second["data"]] == ["C"] + + +def test_get_monitoring_site_linked_resources_selects_properties(client): + owner = UserFactory() + parent = _make_monitoring_site_linked_resources(owner, ["A"]) + client.force_login(owner) + + response = client.get(_linked_resources_url(parent.id), {"properties": "name"}) + + assert response.json()["data"] == [{"name": "A"}] + + +def test_get_monitoring_site_linked_resources_rejects_include(client): + owner = UserFactory() + parent = _make_monitoring_site_linked_resources(owner, ["A"]) + client.force_login(owner) + + response = client.get(_linked_resources_url(parent.id), {"include": "monitoringSite"}) + + assert response.status_code == 400 + + def test_add_monitoring_site_linked_resource_succeeds_with_link(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) @@ -958,7 +1001,7 @@ def test_add_monitoring_site_linked_resource_succeeds_with_link(client): assert response.status_code == 201 assert set(response.json().keys()) == {"id"} - linked_resources = client.get(_linked_resources_url(monitoring_site.id)).json() + linked_resources = client.get(_linked_resources_url(monitoring_site.id)).json()["data"] assert linked_resources[0]["name"] == "Site Report" @@ -1013,7 +1056,7 @@ def test_update_monitoring_site_linked_resource_succeeds_for_name(client): assert response.status_code == 204 assert not response.content - linked_resources = client.get(_linked_resources_url(monitoring_site.id)).json() + linked_resources = client.get(_linked_resources_url(monitoring_site.id)).json()["data"] assert linked_resources[0]["name"] == "Updated Report" diff --git a/django/tests/interfaces/api/test_monitoring_task_views.py b/django/tests/interfaces/api/test_monitoring_task_views.py index 6f7c1f3a..f15d4d8e 100644 --- a/django/tests/interfaces/api/test_monitoring_task_views.py +++ b/django/tests/interfaces/api/test_monitoring_task_views.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from django.conf import settings from django.db import connection from django.test.utils import CaptureQueriesContext from django.utils import timezone @@ -311,7 +312,7 @@ def test_trigger_monitoring_task_creates_pending_run_for_workspace_owner(client) response = client.post(f"{_detail_url(task.id)}/trigger") assert response.status_code == 202 - assert response.json()["status"] == "PENDING" + assert response.json()["data"]["status"] == "PENDING" mock_apply_async.assert_called_once() assert TaskRun.objects.filter(task=task, status="PENDING").exists() @@ -345,6 +346,41 @@ def test_get_monitoring_task_runs_returns_runs_for_workspace_owner(client): assert str(run.id) in [r["id"] for r in response.json()["data"]] +def test_trigger_monitoring_task_returns_the_run_with_links_to_it(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + task = _make_monitoring_task(workspace) + client.force_login(owner) + + with patch("processing.monitoring.tasks.run_monitoring_task.apply_async"): + response = client.post(f"{_detail_url(task.id)}/trigger") + + run = TaskRun.objects.get(task=task) + run_url = f"{settings.PROXY_BASE_URL.rstrip('/')}/api/ogc/collections/monitoring-tasks/items/{task.id}/runs/{run.id}" + assert response.status_code == 202 + assert response["Location"] == run_url + assert response.json()["data"]["id"] == str(run.id) + assert response.json()["links"] == [{"href": run_url, "rel": "self", "type": "application/json"}] + + +def test_get_monitoring_task_run_returns_a_self_link_and_no_collection_link(client): + owner = UserFactory() + workspace = WorkspaceFactory(owner=owner) + task = _make_monitoring_task(workspace) + run = TaskRun.objects.create(task=task, status="SUCCESS") + client.force_login(owner) + + response = client.get(f"{_detail_url(task.id)}/runs/{run.id}") + + assert response.json()["links"] == [ + { + "href": f"{settings.PROXY_BASE_URL.rstrip('/')}/api/ogc/collections/monitoring-tasks/items/{task.id}/runs/{run.id}", + "rel": "self", + "type": "application/json", + } + ] + + def test_get_monitoring_task_run_returns_200_for_workspace_owner(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) @@ -355,7 +391,7 @@ def test_get_monitoring_task_run_returns_200_for_workspace_owner(client): response = client.get(f"{_detail_url(task.id)}/runs/{run.id}") assert response.status_code == 200 - assert response.json()["id"] == str(run.id) + assert response.json()["data"]["id"] == str(run.id) def test_get_monitoring_task_runs_accepts_properties_filter(client): diff --git a/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts index 51b29ee6..d23d8791 100644 --- a/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts @@ -15,6 +15,34 @@ describe('MonitoringSiteService', () => { const client = new HydroServer({ host: 'https://hydro.example.com' }) + describe('getLinkedResources', () => { + it('fetches every page of a site\'s linked resources', async () => { + const items = Array.from({ length: 250 }, (_, i) => ({ + id: `lr-${i}`, + name: `Resource ${i}`, + type: 'Report', + link: `https://example.com/${i}`, + })) + const fetchMock = vi.fn(async (input: any) => { + const params = new URL(String(input)).searchParams + const offset = Number(params.get('offset') ?? 0) + const limit = Number(params.get('limit') ?? 100) + return jsonResponse({ + data: items.slice(offset, offset + limit), + meta: { offset, limit, totalCount: items.length }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + const res = await client.monitoringSites.getLinkedResources('site-1') + + expect(fetchMock.mock.calls.length).toBeGreaterThan(1) + expect(res.ok && res.data.map((item: { id: string }) => item.id)).toEqual( + items.map((item) => item.id) + ) + }) + }) + describe('listMarkers', () => { it('fetches monitoringSite markers and returns them in data', async () => { const payload = [ diff --git a/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts index 01c0eef3..e64b3c1c 100644 --- a/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/task.service.spec.ts @@ -15,6 +15,28 @@ describe('TaskService', () => { vi.restoreAllMocks() }) + it('unwraps the item envelope from triggered and fetched runs', async () => { + const envelope = { + data: { id: 'run-1', status: 'PENDING' }, + included: null, + links: [ + { + href: 'https://hydro.example.com/api/ogc/collections/etl-tasks/items/task-1/runs/run-1', + rel: 'self', + type: 'application/json', + }, + ], + } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse(envelope))) + + const client = new HydroServer({ host: 'https://hydro.example.com' }) + const triggered = await client.tasks.runTask('task-1') + const fetched = await client.tasks.getTaskRun('task-1', 'run-1') + + expect(triggered.ok && triggered.data).toEqual({ id: 'run-1', status: 'PENDING' }) + expect(fetched.ok && fetched.data).toEqual({ id: 'run-1', status: 'PENDING' }) + }) + it('forwards task run payloads without normalization', async () => { const rawResult = { message: 'Loaded 12 total observation(s) into 1 datastream(s).', diff --git a/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts b/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts index 27452272..3fdd2cbf 100644 --- a/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts +++ b/packages/hydroserver-ts/src/generated/contracts/runs.contract.ts @@ -6,7 +6,7 @@ export namespace RunContract { export const route = 'runs' as const export type QueryParameters = ([Data.operations['interfaces_api_views_products_task_get_data_product_task_runs']['parameters']['query']] extends [never] ? {} : NonNullable) export type SummaryResponse = Data.components['schemas']['TaskRunResponse'] - export type DetailResponse = Data.components['schemas']['TaskRunResponse'] + export type DetailResponse = Data.components['schemas']['ItemResponse_TaskRunResponse_'] export type PostBody = never export type PatchBody = never export type DeleteBody = never diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 6c149afc..7ce568f4 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -4017,6 +4017,19 @@ export interface components { */ links: components["schemas"]["Link"][]; }; + /** ItemResponse[TaskRunResponse] */ + ItemResponse_TaskRunResponse_: { + data: components["schemas"]["TaskRunResponse"]; + /** Included */ + included?: { + [key: string]: unknown[]; + } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; + }; /** ItemResponse[UnitResponse] */ ItemResponse_UnitResponse_: { data: components["schemas"]["UnitResponse"]; @@ -4127,11 +4140,6 @@ export interface components { }; /** LinkedResourceQueryParameters */ LinkedResourceQueryParameters: { - /** - * Include - * @description Comma-separated list of related resources to include in the response. - */ - include?: string | null; /** * Limit * @description The maximum number of items to return. (default: 100, maximum: 100000) @@ -5583,6 +5591,21 @@ export interface components { links: components["schemas"]["Link"][]; meta: components["schemas"]["PaginationMeta"]; }; + /** PaginatedResponse[LinkedResourceGetResponse] */ + PaginatedResponse_LinkedResourceGetResponse_: { + /** Data */ + data: components["schemas"]["LinkedResourceGetResponse"][]; + /** Included */ + included?: { + [key: string]: unknown[]; + } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; + meta: components["schemas"]["PaginationMeta"]; + }; /** PaginatedResponse[LinkedResourceTypeResponse] */ PaginatedResponse_LinkedResourceTypeResponse_: { /** Data */ @@ -8388,7 +8411,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TaskRunResponse"]; + "application/json": components["schemas"]["ItemResponse_TaskRunResponse_"]; }; }; /** @description Unauthorized */ @@ -8437,7 +8460,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TaskRunResponse"]; + "application/json": components["schemas"]["ItemResponse_TaskRunResponse_"]; }; }; /** @description Unauthorized */ @@ -9295,8 +9318,6 @@ export interface operations { query?: { /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: string | null; - /** @description Comma-separated list of related resources to include in the response. */ - include?: string | null; /** @description Number of items to skip. */ offset?: number | null; /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ @@ -9318,7 +9339,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LinkedResourceGetResponse"][]; + "application/json": components["schemas"]["PaginatedResponse_LinkedResourceGetResponse_"]; }; }; /** @description Unauthorized */ @@ -10533,7 +10554,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TaskRunResponse"]; + "application/json": components["schemas"]["ItemResponse_TaskRunResponse_"]; }; }; /** @description Unauthorized */ @@ -10582,7 +10603,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TaskRunResponse"]; + "application/json": components["schemas"]["ItemResponse_TaskRunResponse_"]; }; }; /** @description Unauthorized */ @@ -12103,8 +12124,6 @@ export interface operations { query?: { /** @description Comma-separated list of properties to include in the response. All properties are returned if omitted. */ properties?: string | null; - /** @description Comma-separated list of related resources to include in the response. */ - include?: string | null; /** @description Number of items to skip. */ offset?: number | null; /** @description The maximum number of items to return. (default: 100, maximum: 100000) */ @@ -12126,7 +12145,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LinkedResourceGetResponse"][]; + "application/json": components["schemas"]["PaginatedResponse_LinkedResourceGetResponse_"]; }; }; /** @description Unauthorized */ @@ -12874,7 +12893,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TaskRunResponse"]; + "application/json": components["schemas"]["ItemResponse_TaskRunResponse_"]; }; }; /** @description Unauthorized */ @@ -12923,7 +12942,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TaskRunResponse"]; + "application/json": components["schemas"]["ItemResponse_TaskRunResponse_"]; }; }; /** @description Unauthorized */ diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/etl/task.py b/packages/hydroserverpy/src/hydroserverpy/api/services/etl/task.py index 5d9c7697..474c4b46 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/etl/task.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/etl/task.py @@ -118,7 +118,7 @@ def trigger(self, uid: Union[UUID, str]) -> TaskRun: """Trigger an immediate run of an ETL task.""" path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/trigger" - response = self.client.request("post", path).json() + response = self.client.request("post", path).json()["data"] return TaskRun(**response) @@ -157,4 +157,4 @@ def get_run(self, uid: Union[UUID, str], run_id: Union[UUID, str]) -> TaskRun: path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/runs/{str(run_id)}" - return TaskRun(**self.client.request("get", path).json()) + return TaskRun(**self.client.request("get", path).json()["data"]) diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/monitoring/task.py b/packages/hydroserverpy/src/hydroserverpy/api/services/monitoring/task.py index 67b872e2..691f3d70 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/monitoring/task.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/monitoring/task.py @@ -114,7 +114,7 @@ def trigger(self, uid: Union[UUID, str]) -> TaskRun: """Trigger an immediate run of a monitoring task.""" path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/trigger" - response = self.client.request("post", path).json() + response = self.client.request("post", path).json()["data"] return TaskRun(**response) @@ -153,6 +153,6 @@ def get_run(self, uid: Union[UUID, str], run_id: Union[UUID, str]) -> TaskRun: """Fetch a single task run for a monitoring task.""" path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/runs/{str(run_id)}" - response = self.client.request("get", path).json() + response = self.client.request("get", path).json()["data"] return TaskRun(**response) diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/products/task.py b/packages/hydroserverpy/src/hydroserverpy/api/services/products/task.py index 65096f5a..7ab45398 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/products/task.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/products/task.py @@ -114,7 +114,7 @@ def trigger(self, uid: Union[UUID, str]) -> TaskRun: """Trigger an immediate run of a data product task.""" path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/trigger" - response = self.client.request("post", path).json() + response = self.client.request("post", path).json()["data"] return TaskRun(**response) @@ -153,4 +153,4 @@ def get_run(self, uid: Union[UUID, str], run_id: Union[UUID, str]) -> TaskRun: path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/runs/{str(run_id)}" - return TaskRun(**self.client.request("get", path).json()) \ No newline at end of file + return TaskRun(**self.client.request("get", path).json()["data"]) \ No newline at end of file diff --git a/packages/hydroserverpy/tests/test_api_client.py b/packages/hydroserverpy/tests/test_api_client.py index 4de125b8..1cdaeabc 100644 --- a/packages/hydroserverpy/tests/test_api_client.py +++ b/packages/hydroserverpy/tests/test_api_client.py @@ -160,3 +160,23 @@ def test_workspace_list_sends_lowercase_booleans(fake_session_factory): ).prepare().url assert "is_private=true" in url assert "is_associated=false" in url + + +RUN_ID = "01a0e94d-307f-7347-8f40-66fb0327d09e" +RUN_RESPONSE = json.dumps( + {"data": {"id": RUN_ID, "status": "PENDING"}, "included": None, "links": []} +).encode() + + +@pytest.mark.parametrize("service", ["etltasks", "monitoringtasks", "dataproducttasks"]) +def test_task_trigger_and_get_run_unwrap_the_item_envelope(fake_session_factory, service): + hs = client_module.HydroServer(host="https://example.com", apikey="hs_test_api_key") + fake_session_factory[0].queue("post", FakeResponse(status_code=202, content=RUN_RESPONSE)) + fake_session_factory[0].queue("get", FakeResponse(content=RUN_RESPONSE)) + + triggered = getattr(hs, service).trigger("task-1") + fetched = getattr(hs, service).get_run("task-1", RUN_ID) + + assert str(triggered.id) == RUN_ID + assert triggered.status == "PENDING" + assert str(fetched.id) == RUN_ID From 1dbccab1ad14a090378e6ec8383fa1cdf9495a12 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Wed, 30 Sep 2026 13:16:25 -0700 Subject: [PATCH 23/25] Removed unused observation signal option --- apps/data-management/src/store/observations.ts | 9 +++------ .../src/utils/__tests__/observationsUtils.spec.ts | 12 ++++++------ apps/data-management/src/utils/observationsUtils.ts | 4 +--- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/data-management/src/store/observations.ts b/apps/data-management/src/store/observations.ts index 4b75f1a6..29b053fa 100644 --- a/apps/data-management/src/store/observations.ts +++ b/apps/data-management/src/store/observations.ts @@ -129,8 +129,7 @@ export const useObservationStore = defineStore('observations', () => { const fetchedData = (await fetchObservations( datastream, beginTime, - endTime, - signal + endTime )) as ObservationArray ensureLatest() observationsRaw.value[id] = parseObservationRows(fetchedData) @@ -153,8 +152,7 @@ export const useObservationStore = defineStore('observations', () => { beginDataPromise = fetchObservations( datastream, beginTime, - new Date(storedStart).toISOString(), - signal + new Date(storedStart).toISOString() ) as Promise } @@ -164,8 +162,7 @@ export const useObservationStore = defineStore('observations', () => { endDataPromise = fetchObservations( datastream, new Date(storedEnd).toISOString(), - endTime, - signal + endTime ) as Promise } diff --git a/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts b/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts index 76d695a0..1b282b64 100644 --- a/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts +++ b/apps/data-management/src/utils/__tests__/observationsUtils.spec.ts @@ -134,12 +134,12 @@ describe('fetchObservations', () => { const data = await fetchObservations(createDatastream(), start, end) expect(data).toEqual([['2023-01-01T00:00:00Z', 5]]) - expect(getObservationsMock).toHaveBeenCalledWith( - '123', - expect.objectContaining({ - datetime: `${start}/${end}`, - }) - ) + expect(getObservationsMock).toHaveBeenCalledWith('123', { + sortby: ['phenomenonTime'], + limit: 50_000, + format: 'column', + datetime: `${start}/${end}`, + }) }) it('returns empty for non-ok or invalid payloads', async () => { diff --git a/apps/data-management/src/utils/observationsUtils.ts b/apps/data-management/src/utils/observationsUtils.ts index 51a348ae..e3aa695d 100644 --- a/apps/data-management/src/utils/observationsUtils.ts +++ b/apps/data-management/src/utils/observationsUtils.ts @@ -30,8 +30,7 @@ const toObservationRows = (columnar: Record): DataArray => { export const fetchObservations = async ( datastream: Datastream, startTime: string | null = null, - endTime: string | null = null, - signal?: AbortSignal + endTime: string | null = null ) => { const { id, phenomenonBeginTime, phenomenonEndTime } = datastream if (!phenomenonBeginTime || !phenomenonEndTime) return [] @@ -42,7 +41,6 @@ export const fetchObservations = async ( format: 'column', datetime: `${startTime ?? phenomenonBeginTime}/${endTime ?? phenomenonEndTime}`, } - if (signal) options.signal = signal const res = await hs.datastreams.getObservations(id, options) From a030cb77ac245c6cc9affcdd3b90d252da29a014 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Wed, 30 Sep 2026 13:46:44 -0700 Subject: [PATCH 24/25] Added endpoints and client package methods to get linked resources by ID --- django/contracts/openapi/ogc.openapi.json | 213 ++++++++++++++++++ django/interfaces/api/service.py | 14 ++ .../interfaces/api/services/sta/datastream.py | 17 ++ .../api/services/sta/monitoring_site.py | 17 ++ django/interfaces/api/views/sta/datastream.py | 29 +++ .../api/views/sta/monitoring_site.py | 29 +++ .../interfaces/api/test_datastream_views.py | 50 ++++ .../api/test_monitoring_site_views.py | 52 +++++ ...monitoring-site-datastream.service.spec.ts | 48 ++-- .../src/api/services/datastream.service.ts | 24 +- .../api/services/monitoring-site.service.ts | 24 +- .../src/generated/ogc.types.d.ts | 125 +++++++++- .../api/services/sta/datastream.py | 36 ++- .../api/services/sta/monitoring_site.py | 36 ++- .../tests/api/test_linked_resources.py | 118 ++++++++++ 15 files changed, 766 insertions(+), 66 deletions(-) create mode 100644 packages/hydroserverpy/tests/api/test_linked_resources.py diff --git a/django/contracts/openapi/ogc.openapi.json b/django/contracts/openapi/ogc.openapi.json index 4bc990ab..07d4cf63 100644 --- a/django/contracts/openapi/ogc.openapi.json +++ b/django/contracts/openapi/ogc.openapi.json @@ -4298,6 +4298,41 @@ "title": "ItemResponse[EtlTaskResponse]", "type": "object" }, + "ItemResponse_LinkedResourceGetResponse_": { + "properties": { + "data": { + "$ref": "#/components/schemas/LinkedResourceGetResponse" + }, + "included": { + "anyOf": [ + { + "additionalProperties": { + "items": {}, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Included" + }, + "links": { + "default": [], + "items": { + "$ref": "#/components/schemas/Link" + }, + "title": "Links", + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "ItemResponse[LinkedResourceGetResponse]", + "type": "object" + }, "ItemResponse_LinkedResourceTypeResponse_": { "properties": { "data": { @@ -19890,6 +19925,95 @@ "Datastreams" ] }, + "get": { + "description": "Get a linked resource associated with a Datastream.", + "operationId": "interfaces_api_views_sta_datastream_get_datastream_linked_resource", + "parameters": [ + { + "in": "path", + "name": "datastream_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Datastream Id", + "type": "string" + } + }, + { + "in": "path", + "name": "linked_resource_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Linked Resource Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemResponse_LinkedResourceGetResponse_" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Datastream Linked Resource", + "tags": [ + "Datastreams" + ] + }, "patch": { "description": "Update a linked resource for a datastream. A linked resource's mode (hosted file vs.\nexternal URL) cannot be changed in place \u2014 delete it and create a new one instead.", "operationId": "interfaces_api_views_sta_datastream_update_datastream_linked_resource", @@ -26753,6 +26877,95 @@ "Monitoring Sites" ] }, + "get": { + "description": "Get a linked resource associated with a MonitoringSite.", + "operationId": "interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resource", + "parameters": [ + { + "in": "path", + "name": "monitoring_site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Monitoring Site Id", + "type": "string" + } + }, + { + "in": "path", + "name": "linked_resource_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Linked Resource Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemResponse_LinkedResourceGetResponse_" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "title": "Response", + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "SessionAuth": [] + }, + { + "OIDCAuth": [] + }, + { + "APIKeyAuth": [] + }, + { + "BasicAuth": [] + } + ], + "summary": "Get Monitoring Site Linked Resource", + "tags": [ + "Monitoring Sites" + ] + }, "patch": { "description": "Update a linked resource for a monitoring_site. A linked resource's mode (hosted file vs.\nexternal URL) cannot be changed in place \u2014 delete it and create a new one instead.", "operationId": "interfaces_api_views_sta_monitoring_site_update_monitoring_site_linked_resource", diff --git a/django/interfaces/api/service.py b/django/interfaces/api/service.py index b8fe52f7..37068e5e 100644 --- a/django/interfaces/api/service.py +++ b/django/interfaces/api/service.py @@ -471,6 +471,20 @@ def resolve_count(cls, queryset: QuerySet, threshold: int = 10_000) -> int: return estimated + @staticmethod + def get_linked_resource_by_id( + linked_resource_model: Type[Model], + parent_field: str, + parent: Model, + linked_resource_id: uuid.UUID, + ): + try: + return linked_resource_model.objects.get( + **{parent_field: parent}, id=linked_resource_id + ) + except linked_resource_model.DoesNotExist: + raise NotFoundError("Linked resource does not exist") + @staticmethod def create_linked_resource( linked_resource_model: Type[Model], diff --git a/django/interfaces/api/services/sta/datastream.py b/django/interfaces/api/services/sta/datastream.py index 731b3276..dacaec28 100644 --- a/django/interfaces/api/services/sta/datastream.py +++ b/django/interfaces/api/services/sta/datastream.py @@ -454,6 +454,23 @@ def get_linked_resources( return {"data": list(queryset), "meta": meta} + def get_linked_resource( + self, + principal: User | ServiceAccount | AnonymousPrincipal, + uid: uuid.UUID, + linked_resource_id: uuid.UUID, + ): + datastream = self.get_datastream_for_action( + principal=principal, uid=uid, action="view" + ) + + return self.get_linked_resource_by_id( + linked_resource_model=DatastreamLinkedResource, + parent_field="datastream", + parent=datastream, + linked_resource_id=linked_resource_id, + ) + def add_linked_resource( self, principal: User | ServiceAccount | AnonymousPrincipal, diff --git a/django/interfaces/api/services/sta/monitoring_site.py b/django/interfaces/api/services/sta/monitoring_site.py index 1ceec400..2847a3ae 100644 --- a/django/interfaces/api/services/sta/monitoring_site.py +++ b/django/interfaces/api/services/sta/monitoring_site.py @@ -539,6 +539,23 @@ def get_linked_resources( return {"data": list(queryset), "meta": meta} + def get_linked_resource( + self, + principal: User | ServiceAccount | AnonymousPrincipal, + uid: uuid.UUID, + linked_resource_id: uuid.UUID, + ): + monitoring_site = self.get_monitoring_site_for_action( + principal=principal, uid=uid, action="view" + ) + + return self.get_linked_resource_by_id( + linked_resource_model=MonitoringSiteLinkedResource, + parent_field="monitoring_site", + parent=monitoring_site, + linked_resource_id=linked_resource_id, + ) + def add_linked_resource( self, principal: User | ServiceAccount | AnonymousPrincipal, diff --git a/django/interfaces/api/views/sta/datastream.py b/django/interfaces/api/views/sta/datastream.py index fd0cb7ee..df54ae9d 100644 --- a/django/interfaces/api/views/sta/datastream.py +++ b/django/interfaces/api/views/sta/datastream.py @@ -268,6 +268,35 @@ def add_datastream_linked_resource( ) +@datastream_router.get( + "/items/{datastream_id}/linked-resources/{linked_resource_id}", + auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], + response={ + 200: ItemResponse[LinkedResourceGetResponse], + 401: str, + 403: str, + 404: str, + }, + by_alias=True, +) +def get_datastream_linked_resource( + request: HydroServerHttpRequest, + datastream_id: Path[uuid.UUID], + linked_resource_id: Path[uuid.UUID], +): + """ + Get a linked resource associated with a Datastream. + """ + + return 200, { + "data": datastream_service.get_linked_resource( + principal=request.principal, + uid=datastream_id, + linked_resource_id=linked_resource_id, + ) + } + + @datastream_router.patch( "/items/{datastream_id}/linked-resources/{linked_resource_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], diff --git a/django/interfaces/api/views/sta/monitoring_site.py b/django/interfaces/api/views/sta/monitoring_site.py index e4170a2b..8d17decc 100644 --- a/django/interfaces/api/views/sta/monitoring_site.py +++ b/django/interfaces/api/views/sta/monitoring_site.py @@ -332,6 +332,35 @@ def add_monitoring_site_linked_resource( ) +@monitoring_site_router.get( + "/items/{monitoring_site_id}/linked-resources/{linked_resource_id}", + auth=[session_auth, oidc_auth, apikey_auth, basic_auth, anonymous_auth], + response={ + 200: ItemResponse[LinkedResourceGetResponse], + 401: str, + 403: str, + 404: str, + }, + by_alias=True, +) +def get_monitoring_site_linked_resource( + request: HydroServerHttpRequest, + monitoring_site_id: Path[uuid.UUID], + linked_resource_id: Path[uuid.UUID], +): + """ + Get a linked resource associated with a MonitoringSite. + """ + + return 200, { + "data": monitoring_site_service.get_linked_resource( + principal=request.principal, + uid=monitoring_site_id, + linked_resource_id=linked_resource_id, + ) + } + + @monitoring_site_router.patch( "/items/{monitoring_site_id}/linked-resources/{linked_resource_id}", auth=[session_auth, oidc_auth, apikey_auth, basic_auth], diff --git a/django/tests/interfaces/api/test_datastream_views.py b/django/tests/interfaces/api/test_datastream_views.py index 0af6d4d8..b738843a 100644 --- a/django/tests/interfaces/api/test_datastream_views.py +++ b/django/tests/interfaces/api/test_datastream_views.py @@ -791,6 +791,56 @@ def test_get_datastream_linked_resources_rejects_include(client): assert response.status_code == 400 +def test_get_datastream_linked_resource_returns_item(client): + owner = UserFactory() + parent = _make_datastream_linked_resources(owner, ["A", "B"]) + linked_resource = DatastreamLinkedResource.objects.get(datastream=parent, name="B") + client.force_login(owner) + + response = client.get(f"{_linked_resources_url(parent.id)}/{linked_resource.id}") + + assert response.status_code == 200 + data = response.json()["data"] + assert data["id"] == str(linked_resource.id) + assert data["name"] == "B" + assert data["type"] == "Report" + assert data["link"] == "https://example.com/B" + + +def test_get_datastream_linked_resource_returns_404_for_unknown_id(client): + owner = UserFactory() + parent = _make_datastream_linked_resources(owner, ["A"]) + client.force_login(owner) + + response = client.get(f"{_linked_resources_url(parent.id)}/{uuid.uuid4()}") + + assert response.status_code == 404 + + +def test_get_datastream_linked_resource_returns_404_for_other_parents_resource(client): + owner = UserFactory() + parent = _make_datastream_linked_resources(owner, ["A"]) + other_parent = _make_datastream_linked_resources(owner, ["B"]) + other_resource = DatastreamLinkedResource.objects.get(datastream=other_parent) + client.force_login(owner) + + response = client.get(f"{_linked_resources_url(parent.id)}/{other_resource.id}") + + assert response.status_code == 404 + + +def test_get_datastream_linked_resource_returns_404_for_private_parent_when_outsider(client): + parent = _make_datastream(WorkspaceFactory(), private=True) + linked_resource = DatastreamLinkedResource.objects.create( + datastream=parent, name="A", type="Report", url="https://example.com/A" + ) + client.force_login(UserFactory()) + + response = client.get(f"{_linked_resources_url(parent.id)}/{linked_resource.id}") + + assert response.status_code == 404 + + def test_add_datastream_linked_resource_succeeds_with_link(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) diff --git a/django/tests/interfaces/api/test_monitoring_site_views.py b/django/tests/interfaces/api/test_monitoring_site_views.py index fb3d464b..cc413a2c 100644 --- a/django/tests/interfaces/api/test_monitoring_site_views.py +++ b/django/tests/interfaces/api/test_monitoring_site_views.py @@ -1,3 +1,5 @@ +import uuid + import pytest from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection @@ -984,6 +986,56 @@ def test_get_monitoring_site_linked_resources_rejects_include(client): assert response.status_code == 400 +def test_get_monitoring_site_linked_resource_returns_item(client): + owner = UserFactory() + parent = _make_monitoring_site_linked_resources(owner, ["A", "B"]) + linked_resource = MonitoringSiteLinkedResource.objects.get(monitoring_site=parent, name="B") + client.force_login(owner) + + response = client.get(f"{_linked_resources_url(parent.id)}/{linked_resource.id}") + + assert response.status_code == 200 + data = response.json()["data"] + assert data["id"] == str(linked_resource.id) + assert data["name"] == "B" + assert data["type"] == "Report" + assert data["link"] == "https://example.com/B" + + +def test_get_monitoring_site_linked_resource_returns_404_for_unknown_id(client): + owner = UserFactory() + parent = _make_monitoring_site_linked_resources(owner, ["A"]) + client.force_login(owner) + + response = client.get(f"{_linked_resources_url(parent.id)}/{uuid.uuid4()}") + + assert response.status_code == 404 + + +def test_get_monitoring_site_linked_resource_returns_404_for_other_parents_resource(client): + owner = UserFactory() + parent = _make_monitoring_site_linked_resources(owner, ["A"]) + other_parent = _make_monitoring_site_linked_resources(owner, ["B"]) + other_resource = MonitoringSiteLinkedResource.objects.get(monitoring_site=other_parent) + client.force_login(owner) + + response = client.get(f"{_linked_resources_url(parent.id)}/{other_resource.id}") + + assert response.status_code == 404 + + +def test_get_monitoring_site_linked_resource_returns_404_for_private_parent_when_outsider(client): + parent = MonitoringSiteFactory(workspace=WorkspaceFactory(), private=True) + linked_resource = MonitoringSiteLinkedResource.objects.create( + monitoring_site=parent, name="A", type="Report", url="https://example.com/A" + ) + client.force_login(UserFactory()) + + response = client.get(f"{_linked_resources_url(parent.id)}/{linked_resource.id}") + + assert response.status_code == 404 + + def test_add_monitoring_site_linked_resource_succeeds_with_link(client): owner = UserFactory() workspace = WorkspaceFactory(owner=owner) diff --git a/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts index d23d8791..c0844884 100644 --- a/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/monitoring-site-datastream.service.spec.ts @@ -167,14 +167,16 @@ describe('MonitoringSiteService', () => { }) describe('linked resources', () => { - it('createLinkedResource posts, then re-fetches the list to find the new entry by id', async () => { + it('createLinkedResource posts, then fetches the new entry by id', async () => { const fetchMock = vi.fn().mockImplementation(async (input: string | URL, init?: RequestInit) => { if (init?.method === 'POST') { return jsonResponse({ id: 'linked-1' }) } - return jsonResponse([ - { id: 'linked-1', name: 'Site Report', type: 'Report', link: 'https://example.com/report.pdf' }, - ]) + return jsonResponse({ + data: { id: 'linked-1', name: 'Site Report', type: 'Report', link: 'https://example.com/report.pdf' }, + included: null, + links: [], + }) }) vi.stubGlobal('fetch', fetchMock) @@ -184,19 +186,22 @@ describe('MonitoringSiteService', () => { ) expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[1][0])).toMatch(/\/linked-resources\/linked-1$/) expect(res.ok).toBe(true) if (!res.ok) return expect(res.data).toMatchObject({ id: 'linked-1', name: 'Site Report' }) }) - it('updateLinkedResource patches, then re-fetches the list to find the updated entry by id', async () => { + it('updateLinkedResource patches, then fetches the updated entry by id', async () => { const fetchMock = vi.fn().mockImplementation(async (input: string | URL, init?: RequestInit) => { if (init?.method === 'PATCH') { return new Response(null, { status: 204, headers: { 'Content-Length': '0' } }) } - return jsonResponse([ - { id: 'linked-1', name: 'Updated Report', type: 'Report', link: 'https://example.com/report.pdf' }, - ]) + return jsonResponse({ + data: { id: 'linked-1', name: 'Updated Report', type: 'Report', link: 'https://example.com/report.pdf' }, + included: null, + links: [], + }) }) vi.stubGlobal('fetch', fetchMock) @@ -207,15 +212,16 @@ describe('MonitoringSiteService', () => { ) expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[1][0])).toMatch(/\/linked-resources\/linked-1$/) expect(res.ok).toBe(true) if (!res.ok) return expect(res.data).toMatchObject({ id: 'linked-1', name: 'Updated Report' }) }) - it('createLinkedResource returns ok:false when the new entry is missing from the refetched list', async () => { + it('createLinkedResource returns ok:false when the new entry cannot be fetched', async () => { const fetchMock = vi.fn().mockImplementation(async (input: string | URL, init?: RequestInit) => { if (init?.method === 'POST') return jsonResponse({ id: 'linked-1' }) - return jsonResponse([]) + return jsonResponse({ message: 'Linked resource does not exist' }, 404) }) vi.stubGlobal('fetch', fetchMock) @@ -286,33 +292,38 @@ describe('DatastreamService', () => { const client = new HydroServer({ host: 'https://hydro.example.com' }) describe('linked resources', () => { - it('createLinkedResource posts, then re-fetches the list to find the new entry by id', async () => { + it('createLinkedResource posts, then fetches the new entry by id', async () => { const fetchMock = vi.fn().mockImplementation(async (input: string | URL, init?: RequestInit) => { if (init?.method === 'POST') { return jsonResponse({ id: 'linked-1' }) } - return jsonResponse([ - { id: 'linked-1', name: 'Datastream Report', type: 'Report', link: 'https://example.com/report.pdf' }, - ]) + return jsonResponse({ + data: { id: 'linked-1', name: 'Datastream Report', type: 'Report', link: 'https://example.com/report.pdf' }, + included: null, + links: [], + }) }) vi.stubGlobal('fetch', fetchMock) const res = await client.datastreams.createLinkedResource('ds-1', new FormData()) expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[1][0])).toMatch(/\/linked-resources\/linked-1$/) expect(res.ok).toBe(true) if (!res.ok) return expect(res.data).toMatchObject({ id: 'linked-1', name: 'Datastream Report' }) }) - it('updateLinkedResource patches, then re-fetches the list to find the updated entry by id', async () => { + it('updateLinkedResource patches, then fetches the updated entry by id', async () => { const fetchMock = vi.fn().mockImplementation(async (input: string | URL, init?: RequestInit) => { if (init?.method === 'PATCH') { return new Response(null, { status: 204, headers: { 'Content-Length': '0' } }) } - return jsonResponse([ - { id: 'linked-1', name: 'Updated Report', type: 'Report', link: 'https://example.com/report.pdf' }, - ]) + return jsonResponse({ + data: { id: 'linked-1', name: 'Updated Report', type: 'Report', link: 'https://example.com/report.pdf' }, + included: null, + links: [], + }) }) vi.stubGlobal('fetch', fetchMock) @@ -323,6 +334,7 @@ describe('DatastreamService', () => { ) expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[1][0])).toMatch(/\/linked-resources\/linked-1$/) expect(res.ok).toBe(true) if (!res.ok) return expect(res.data).toMatchObject({ id: 'linked-1', name: 'Updated Report' }) diff --git a/packages/hydroserver-ts/src/api/services/datastream.service.ts b/packages/hydroserver-ts/src/api/services/datastream.service.ts index 7bb09d47..a8b845ee 100644 --- a/packages/hydroserver-ts/src/api/services/datastream.service.ts +++ b/packages/hydroserver-ts/src/api/services/datastream.service.ts @@ -16,7 +16,10 @@ import { ProcessingLevel, Unit, } from '../../types' -import { normalizeLinkCollection } from './link-normalization' +import { + normalizeLinkCollection, + normalizeLinkRecord, +} from './link-normalization' const DATASTREAM_EXPAND_INCLUDE = 'workspace,monitoringSite,method,observedProperty,processingLevel,unit' as const @@ -230,7 +233,7 @@ export class DatastreamService extends HydroServerBaseService { const url = `${this._route}/${datastreamId}/linked-resources` const res = await apiMethods.post<{ id: string }>(url, data) if (!res.ok) return res - return this.findLinkedResource(datastreamId, res.data.id) + return this.getLinkedResource(datastreamId, res.data.id) } async updateLinkedResource( @@ -241,24 +244,17 @@ export class DatastreamService extends HydroServerBaseService { const url = `${this._route}/${datastreamId}/linked-resources/${linkedResourceId}` const res = await apiMethods.patch(url, data) if (!res.ok) return res - return this.findLinkedResource(datastreamId, linkedResourceId) + return this.getLinkedResource(datastreamId, linkedResourceId) } - private async findLinkedResource( + async getLinkedResource( datastreamId: string, linkedResourceId: string ): Promise> { - const res = await this.getLinkedResources(datastreamId) + const url = `${this._route}/${datastreamId}/linked-resources/${linkedResourceId}` + const res = await apiMethods.fetch(url) if (!res.ok) return res - const found = res.data.find((r) => r.id === linkedResourceId) - if (!found) { - return { - ok: false, - status: 404, - message: 'Linked resource not found after save.', - } - } - return { ...res, data: found } + return { ...res, data: normalizeLinkRecord(res.data, this._client.host) } } deleteLinkedResource(datastreamId: string, linkedResourceId: string) { diff --git a/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts b/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts index d465014b..d3184411 100644 --- a/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts +++ b/packages/hydroserver-ts/src/api/services/monitoring-site.service.ts @@ -12,7 +12,10 @@ import { MonitoringSiteTaskSummary, } from '../../types' import { ApiResponse } from '../responseInterceptor' -import { normalizeLinkCollection } from './link-normalization' +import { + normalizeLinkCollection, + normalizeLinkRecord, +} from './link-normalization' type LinkedResourceResponse = Data.components['schemas']['LinkedResourceGetResponse'] @@ -94,7 +97,7 @@ export class MonitoringSiteService extends HydroServerBaseService(url, data) if (!res.ok) return res - return this.findLinkedResource(monitoringSiteId, res.data.id) + return this.getLinkedResource(monitoringSiteId, res.data.id) } async updateLinkedResource( @@ -105,24 +108,17 @@ export class MonitoringSiteService extends HydroServerBaseService(url, data) if (!res.ok) return res - return this.findLinkedResource(monitoringSiteId, linkedResourceId) + return this.getLinkedResource(monitoringSiteId, linkedResourceId) } - private async findLinkedResource( + async getLinkedResource( monitoringSiteId: string, linkedResourceId: string ): Promise> { - const res = await this.getLinkedResources(monitoringSiteId) + const url = `${this._route}/${monitoringSiteId}/linked-resources/${linkedResourceId}` + const res = await apiMethods.fetch(url) if (!res.ok) return res - const found = res.data.find((r) => r.id === linkedResourceId) - if (!found) { - return { - ok: false, - status: 404, - message: 'Linked resource not found after save.', - } - } - return { ...res, data: found } + return { ...res, data: normalizeLinkRecord(res.data, this._client.host) } } deleteLinkedResource(monitoringSiteId: string, linkedResourceId: string) { diff --git a/packages/hydroserver-ts/src/generated/ogc.types.d.ts b/packages/hydroserver-ts/src/generated/ogc.types.d.ts index 4abdf046..38f336d3 100644 --- a/packages/hydroserver-ts/src/generated/ogc.types.d.ts +++ b/packages/hydroserver-ts/src/generated/ogc.types.d.ts @@ -468,7 +468,11 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** + * Get Datastream Linked Resource + * @description Get a linked resource associated with a Datastream. + */ + get: operations["interfaces_api_views_sta_datastream_get_datastream_linked_resource"]; put?: never; post?: never; /** @@ -1085,7 +1089,11 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** + * Get Monitoring Site Linked Resource + * @description Get a linked resource associated with a MonitoringSite. + */ + get: operations["interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resource"]; put?: never; post?: never; /** @@ -3758,6 +3766,19 @@ export interface components { */ links: components["schemas"]["Link"][]; }; + /** ItemResponse[LinkedResourceGetResponse] */ + ItemResponse_LinkedResourceGetResponse_: { + data: components["schemas"]["LinkedResourceGetResponse"]; + /** Included */ + included?: { + [key: string]: unknown[]; + } | null; + /** + * Links + * @default [] + */ + links: components["schemas"]["Link"][]; + }; /** ItemResponse[LinkedResourceTypeResponse] */ ItemResponse_LinkedResourceTypeResponse_: { data: components["schemas"]["LinkedResourceTypeResponse"]; @@ -9381,6 +9402,56 @@ export interface operations { }; }; }; + interfaces_api_views_sta_datastream_get_datastream_linked_resource: { + parameters: { + query?: never; + header?: never; + path: { + datastream_id: string; + linked_resource_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ItemResponse_LinkedResourceGetResponse_"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; interfaces_api_views_sta_datastream_remove_datastream_linked_resource: { parameters: { query?: never; @@ -12187,6 +12258,56 @@ export interface operations { }; }; }; + interfaces_api_views_sta_monitoring_site_get_monitoring_site_linked_resource: { + parameters: { + query?: never; + header?: never; + path: { + monitoring_site_id: string; + linked_resource_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ItemResponse_LinkedResourceGetResponse_"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; interfaces_api_views_sta_monitoring_site_remove_monitoring_site_linked_resource: { parameters: { query?: never; diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py index e63255b7..83a5cbd5 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/datastream.py @@ -338,16 +338,36 @@ def add_linked_resource( "post", path, data=data, files={"file": file} if file is not None else None ).json() - return next( - r for r in self.get_linked_resources(uid) if r["id"] == response["id"] - ) + return self.get_linked_resource(uid=uid, linked_resource_id=response["id"]) - def get_linked_resources(self, uid: Union[UUID, str]) -> List[Dict[str, str]]: - """Get all linked resources associated with a HydroServer datastream.""" + def get_linked_resources( + self, + uid: Union[UUID, str], + offset: int = ..., + limit: int = ..., + type: List[str] = ..., + ) -> List[Dict[str, str]]: + """Get a page of linked resources associated with a HydroServer datastream.""" + + params = { + "offset": offset, + "limit": limit, + "type": type, + } + params = {k: v for k, v in params.items() if v is not ...} path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/linked-resources" - return self.client.request("get", path).json() + return self.client.request("get", path, params=params).json()["data"] + + def get_linked_resource( + self, uid: Union[UUID, str], linked_resource_id: Union[UUID, str] + ) -> Dict[str, str]: + """Get a linked resource associated with a HydroServer datastream.""" + + path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/linked-resources/{str(linked_resource_id)}" + + return self.client.request("get", path).json()["data"] def update_linked_resource( self, @@ -379,9 +399,7 @@ def update_linked_resource( "patch", path, data=data, files={"file": file} if file is not None else None ) - return next( - r for r in self.get_linked_resources(uid) if r["id"] == str(linked_resource_id) - ) + return self.get_linked_resource(uid=uid, linked_resource_id=linked_resource_id) def delete_linked_resource(self, uid: Union[UUID, str], linked_resource_id: Union[UUID, str]) -> None: """Delete a linked resource from a HydroServer datastream.""" diff --git a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/monitoring_site.py b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/monitoring_site.py index c084ea68..45632e79 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/services/sta/monitoring_site.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/services/sta/monitoring_site.py @@ -167,16 +167,36 @@ def add_linked_resource( "post", path, data=data, files={"file": file} if file is not None else None ).json() - return next( - r for r in self.get_linked_resources(uid) if r["id"] == response["id"] - ) + return self.get_linked_resource(uid=uid, linked_resource_id=response["id"]) - def get_linked_resources(self, uid: Union[UUID, str]) -> List[Dict[str, str]]: - """Get all linked resources associated with a HydroServer monitoring_site.""" + def get_linked_resources( + self, + uid: Union[UUID, str], + offset: int = ..., + limit: int = ..., + type: List[str] = ..., + ) -> List[Dict[str, str]]: + """Get a page of linked resources associated with a HydroServer monitoring_site.""" + + params = { + "offset": offset, + "limit": limit, + "type": type, + } + params = {k: v for k, v in params.items() if v is not ...} path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/linked-resources" - return self.client.request("get", path).json() + return self.client.request("get", path, params=params).json()["data"] + + def get_linked_resource( + self, uid: Union[UUID, str], linked_resource_id: Union[UUID, str] + ) -> Dict[str, str]: + """Get a linked resource associated with a HydroServer monitoring_site.""" + + path = f"/{self.client.base_route}/{self.model.get_route()}/{str(uid)}/linked-resources/{str(linked_resource_id)}" + + return self.client.request("get", path).json()["data"] def update_linked_resource( self, @@ -208,9 +228,7 @@ def update_linked_resource( "patch", path, data=data, files={"file": file} if file is not None else None ) - return next( - r for r in self.get_linked_resources(uid) if r["id"] == str(linked_resource_id) - ) + return self.get_linked_resource(uid=uid, linked_resource_id=linked_resource_id) def delete_linked_resource(self, uid: Union[UUID, str], linked_resource_id: Union[UUID, str]) -> None: """Delete a linked resource from a HydroServer monitoring_site.""" diff --git a/packages/hydroserverpy/tests/api/test_linked_resources.py b/packages/hydroserverpy/tests/api/test_linked_resources.py new file mode 100644 index 00000000..e7a6c6f1 --- /dev/null +++ b/packages/hydroserverpy/tests/api/test_linked_resources.py @@ -0,0 +1,118 @@ +import uuid +from unittest.mock import MagicMock + +import pytest + +from hydroserverpy.api.services.sta.datastream import DatastreamService +from hydroserverpy.api.services.sta.monitoring_site import MonitoringSiteService + + +class FakeResponse: + def __init__(self, payload=None, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + if self._payload is None: + raise ValueError("No JSON content (empty response body)") + return self._payload + + +def make_client(): + client = MagicMock() + client.base_route = "/api/ogc" + return client + + +def linked_resource_payload(linked_resource_id, name="Report A"): + return { + "id": str(linked_resource_id), + "name": name, + "description": None, + "type": "Report", + "link": f"https://example.com/{name}", + } + + +@pytest.fixture(params=[DatastreamService, MonitoringSiteService]) +def service(request): + return request.param(make_client()) + + +def test_get_linked_resources_unwraps_data_and_forwards_params(service): + parent_id = uuid.uuid4() + items = [linked_resource_payload(uuid.uuid4())] + service.client.request.return_value = FakeResponse( + {"data": items, "meta": {"limit": 10, "offset": 20, "totalCount": 21}, "links": []} + ) + + result = service.get_linked_resources(parent_id, offset=20, limit=10, type=["Report"]) + + assert result == items + method, path = service.client.request.call_args.args + assert method == "get" + assert path.endswith(f"/{parent_id}/linked-resources") + assert service.client.request.call_args.kwargs["params"] == { + "offset": 20, + "limit": 10, + "type": ["Report"], + } + + +def test_get_linked_resources_omits_unset_params(service): + service.client.request.return_value = FakeResponse({"data": [], "meta": {}, "links": []}) + + service.get_linked_resources(uuid.uuid4()) + + assert service.client.request.call_args.kwargs["params"] == {} + + +def test_get_linked_resource_unwraps_data(service): + parent_id = uuid.uuid4() + linked_resource_id = uuid.uuid4() + item = linked_resource_payload(linked_resource_id) + service.client.request.return_value = FakeResponse({"data": item, "links": []}) + + result = service.get_linked_resource(parent_id, linked_resource_id) + + assert result == item + service.client.request.assert_called_once() + method, path = service.client.request.call_args.args + assert method == "get" + assert path.endswith(f"/{parent_id}/linked-resources/{linked_resource_id}") + + +def test_add_linked_resource_returns_created_item(service): + parent_id = uuid.uuid4() + linked_resource_id = uuid.uuid4() + item = linked_resource_payload(linked_resource_id) + service.client.request.side_effect = [ + FakeResponse({"id": str(linked_resource_id)}, status_code=201), + FakeResponse({"data": item, "links": []}), + ] + + result = service.add_linked_resource( + parent_id, name="Report A", type="Report", url="https://example.com/Report A" + ) + + assert result == item + method, path = service.client.request.call_args.args + assert method == "get" + assert path.endswith(f"/{parent_id}/linked-resources/{linked_resource_id}") + + +def test_update_linked_resource_returns_updated_item(service): + parent_id = uuid.uuid4() + linked_resource_id = uuid.uuid4() + item = linked_resource_payload(linked_resource_id, name="Renamed") + service.client.request.side_effect = [ + FakeResponse(status_code=204), + FakeResponse({"data": item, "links": []}), + ] + + result = service.update_linked_resource(parent_id, linked_resource_id, name="Renamed") + + assert result == item + method, path = service.client.request.call_args.args + assert method == "get" + assert path.endswith(f"/{parent_id}/linked-resources/{linked_resource_id}") From e6ea7fc19c508cdb3c20b0e4248c94bcf52f8008 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Wed, 30 Sep 2026 13:56:38 -0700 Subject: [PATCH 25/25] Fixed paginatedFetch in hydroserver-ts --- .../src/api/__tests__/apiMethods.spec.ts | 69 +++++++++++++++++++ packages/hydroserver-ts/src/api/apiMethods.ts | 14 ++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts b/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts index 5829a434..675b8b37 100644 --- a/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts @@ -174,4 +174,73 @@ describe('paginatedFetch', () => { if (!response.ok) return expect(response.data.map((item) => item.id)).toEqual(['1', '2', '3']) }) + + describe('when the server clamps the requested limit', () => { + // 5 records; the client asks for limit=4 but the server caps pages at 2. + const mockClampedServer = (meta: (offset: number) => Record) => { + const records = ['1', '2', '3', '4', '5'].map((id) => ({ id })) + const fetchMock = vi.fn().mockImplementation(async (input: string | URL) => { + const url = new URL(String(input)) + const offset = Number(url.searchParams.get('offset') ?? '0') + const limit = Math.min(Number(url.searchParams.get('limit')), 2) + return jsonResponse({ + data: records.slice(offset, offset + limit), + meta: meta(offset), + }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock + } + + const requestedOffsets = (fetchMock: ReturnType) => + fetchMock.mock.calls.map(([input]) => + Number(new URL(String(input)).searchParams.get('offset')) + ) + + it('pages by the returned meta.limit instead of the requested limit', async () => { + const fetchMock = mockClampedServer((offset) => ({ offset, limit: 2, totalCount: 5 })) + + const response = await apiMethods.paginatedFetch<{ id: string }[]>( + 'https://hydro.example.com/api/ogc/collections/observations/items?limit=4' + ) + + expect(response.ok).toBe(true) + if (!response.ok) return + expect(requestedOffsets(fetchMock)).toEqual([0, 2, 4]) + expect(response.data.map((item) => item.id)).toEqual(['1', '2', '3', '4', '5']) + expect(response.meta?.totalCount).toBe(5) + }) + + it('pages by the returned meta.limit when totalCount is missing', async () => { + mockClampedServer((offset) => ({ offset, limit: 2 })) + + const response = await apiMethods.paginatedFetch<{ id: string }[]>( + 'https://hydro.example.com/api/ogc/collections/observations/items?limit=4' + ) + + expect(response.ok).toBe(true) + if (!response.ok) return + expect(response.data.map((item) => item.id)).toEqual(['1', '2', '3', '4', '5']) + }) + }) + + it('falls back to the requested limit when the response has no meta.limit', async () => { + const pages: Record = { + '0': [{ id: '1' }, { id: '2' }], + '2': [{ id: '3' }], + } + const fetchMock = vi.fn().mockImplementation(async (input: string | URL) => { + const offset = new URL(String(input)).searchParams.get('offset') ?? '0' + return jsonResponse({ data: pages[offset] ?? [], meta: { offset: Number(offset), totalCount: 3 } }) + }) + vi.stubGlobal('fetch', fetchMock) + + const response = await apiMethods.paginatedFetch<{ id: string }[]>( + 'https://hydro.example.com/api/ogc/collections/observations/items?limit=2' + ) + + expect(response.ok).toBe(true) + if (!response.ok) return + expect(response.data.map((item) => item.id)).toEqual(['1', '2', '3']) + }) }) diff --git a/packages/hydroserver-ts/src/api/apiMethods.ts b/packages/hydroserver-ts/src/api/apiMethods.ts index 22399c5d..38ce2321 100644 --- a/packages/hydroserver-ts/src/api/apiMethods.ts +++ b/packages/hydroserver-ts/src/api/apiMethods.ts @@ -209,6 +209,10 @@ export const apiMethods = { ) } + const metaLimit = Number(firstPageMeta?.limit) + const pageSize = + Number.isInteger(metaLimit) && metaLimit > 0 ? metaLimit : limitParam + const totalCount = typeof firstPageMeta?.totalCount === 'number' ? firstPageMeta.totalCount @@ -216,7 +220,7 @@ export const apiMethods = { const offsets: number[] = [] if (totalCount !== undefined) { - for (let offset = limitParam; offset < totalCount; offset += limitParam) { + for (let offset = pageSize; offset < totalCount; offset += pageSize) { offsets.push(offset) } } @@ -233,12 +237,12 @@ export const apiMethods = { lastRowCount = pageRowCount(page.data) } - let nextOffset = limitParam * (1 + offsets.length) + let nextOffset = pageSize * (1 + offsets.length) const MAX_EXTRA_PAGES = 1000 let extraPages = 0 while ( - limitParam > 0 && - lastRowCount === limitParam && + pageSize > 0 && + lastRowCount === pageSize && extraPages < MAX_EXTRA_PAGES ) { const page = await fetchPage(nextOffset) @@ -246,7 +250,7 @@ export const apiMethods = { mergedIncluded = mergeIncluded(mergedIncluded, page.included) if (!mergePageData(page)) break lastRowCount = pageRowCount(page.data) - nextOffset += limitParam + nextOffset += pageSize extraPages += 1 }