From 74cbcdb03dd468da1844523183f2dedb57458085 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Mon, 28 Sep 2026 17:58:03 +0300 Subject: [PATCH 01/14] feat: scaffold 3d-ops-trigger skeleton (MAPCO-11834) Rename boilerplate identity to 3d-ops-trigger across package.json, openapi, helm chart/values, catalog-info and README. Pin Node>=24 (engines) and wire core deps: @map-colonies/jobnik-sdk, @map-colonies/3d-shared (local file link), zod. --- README.md | 123 +++++++----------------------- catalog-info.yaml | 10 +-- helm/Chart.yaml | 4 +- helm/values.yaml | 6 +- openapi3.yaml | 4 +- package-lock.json | 190 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 12 ++- 7 files changed, 225 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index aa757fb..4e601b6 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,53 @@ -# Map Colonies typescript service template +# 3D Operations Trigger ---------------------------------- -This is a basic repo template for building new MapColonies web services in Typescript. +Entry point for 3D operations — **ingest, delete, update, publish/unpublish**. It validates incoming requests (light & fast) and triggers the corresponding [Jobnik](https://mapcolonies.github.io/infra-portal/docs/knowledge-base/jobnik) jobs. Successor to the legacy `3d-gateway` (and, in the near future, `store-trigger`). -> [!IMPORTANT] -> To regenerate the types on openapi change run the command `npm run generate:openapi-types`. +> Epic: MAPCO-11833 · Design: [3D Ingestion High level architecture](https://mapcolonies.atlassian.net/wiki/spaces/MAPConflicResolution/pages/3342172161) · See [docs/PRD.md](./docs/PRD.md) -> [!WARNING] -> After creating a new repo based on this template, you should delete the CODEOWNERS file. - - -## Development -When in development you should use the command `npm run start:dev`. The main benefits are that it enables offline mode for the config package, and source map support for NodeJS errors. - -### Template Features: - -- eslint configuration by [@map-colonies/eslint-config](https://github.com/MapColonies/eslint-config) - -- prettier configuration by [@map-colonies/prettier-config](https://github.com/MapColonies/prettier-config) - -- jest - -- .nvmrc - -- Multi stage production-ready Dockerfile - -- commitlint - -- git hooks - -- logging by [@map-colonies/js-logger](https://github.com/MapColonies/js-logger) - -- OpenAPI request validation - -- config load with [node-config](https://www.npmjs.com/package/node-config) - -- Tracing and metrics by [@map-colonies/telemetry](https://github.com/MapColonies/telemetry) - -- github templates - -- bug report - -- feature request - -- pull request - -- github actions - -- on pull_request - -- LGTM +## API -- test +| Endpoint | Method | Summary | +| --- | --- | --- | +| `/record` | POST | Start an ingestion flow (validate, create Jobnik ingestion job) | +| `/record/{id}` | DELETE | Validate deletability, create Jobnik delete job | +| `/record/{id}` | PATCH | Update metadata for a record | +| `/record/status/{id}` | PATCH | Publish / unpublish a record | -- lint +Full OpenAPI spec: [openapi3.yaml](/openapi3.yaml). Regenerate types on spec change with `npm run generate:openapi-types`. -- snyk +## Requirements -## API -Checkout the OpenAPI spec [here](/openapi3.yaml) +- **Node.js ≥ 24** (required by `@map-colonies/jobnik-sdk`). Use the pinned version via `nvm use`. ## Installation -Install deps with npm - ```bash npm install ``` -## Run Locally - -Clone the project - -```bash - -git clone https://link-to-project - -``` - -Go to the project directory - -```bash - -cd my-project - -``` - -Install dependencies +> During development this service consumes `@map-colonies/3d-shared` (and, later, mc-models v2) via local `file:` links until those packages are published to npm. -```bash - -npm install - -``` - -Start the server +## Run Locally ```bash - -npm run start - +npm run start # build + run +npm run start:dev # offline config + source maps ``` ## Running Tests -To run tests, run the following command - ```bash - -npm run test - +npm run test # all +npm run test:unit # unit only +npm run test:integration # integration only ``` -To only run unit tests: -```bash -npm run test:unit -``` +## Development notes -To only run integration tests: -```bash -npm run test:integration -``` +- eslint / prettier via `@map-colonies/eslint-config` and `@map-colonies/prettier-config` +- vitest for tests +- OpenAPI request validation at the middleware layer +- config via [node-config](https://www.npmjs.com/package/node-config) +- tracing & metrics via `@map-colonies/telemetry` diff --git a/catalog-info.yaml b/catalog-info.yaml index 55a51de..b4ab881 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -1,17 +1,17 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: ts-server-boilerplate - description: A boilerplate github repo for a REST API service in NodeJS for MapColonies + name: 3d-ops-trigger + description: Entry point for 3D operations (ingest, delete, update, publish/unpublish) — validates requests and triggers Jobnik jobs annotations: - github.com/project-slug: MapColonies/ts-server-boilerplate + github.com/project-slug: MapColonies/3d-ops-trigger tags: - nodejs - typescript - expressjs - - boilerplate + - 3d spec: type: service lifecycle: production owner: DevInfra - system: boilerplate + system: 3d diff --git a/helm/Chart.yaml b/helm/Chart.yaml index b1f2c0f..853bc57 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 -name: ts-server-boilerplate -description: A Helm chart for ts-server-boilerplate service +name: 3d-ops-trigger +description: A Helm chart for the 3d-ops-trigger service type: application version: 1.0.0 appVersion: 1.0.0 diff --git a/helm/values.yaml b/helm/values.yaml index f982729..46736ea 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -7,7 +7,7 @@ global: mclabels: component: backend - partOf: boilerplates + partOf: 3d owner: common prometheus: enabled: true @@ -23,7 +23,7 @@ fullnameOverride: "" configManagement: offlineMode: false - name: 'service-name' + name: '3d-ops-trigger' version: 'latest' serverUrl: 'http://localhost:8080/api' @@ -66,7 +66,7 @@ caPath: '/usr/local/share/ca-certificates' caKey: 'ca.crt' image: - repository: ts-server-boilerplate + repository: 3d-ops-trigger # If commented, appVersion will be taken. See: _helpers.tpl # tag: 'latest' pullPolicy: IfNotPresent diff --git a/openapi3.yaml b/openapi3.yaml index 55ee8ee..710048e 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -1,7 +1,7 @@ openapi: 3.0.1 info: - title: service-name - description: basic template for map colonies service + title: 3d-ops-trigger + description: Entry point for 3D operations — validates requests and triggers Jobnik jobs version: 1.0.0 license: name: MIT diff --git a/package-lock.json b/package-lock.json index 6ee91ea..0a0f98a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { - "name": "service-name", + "name": "3d-ops-trigger", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "service-name", + "name": "3d-ops-trigger", "version": "1.0.0", "license": "ISC", "dependencies": { "@godaddy/terminus": "^4.12.1", + "@map-colonies/3d-shared": "file:../../3d-general/3d-shared", "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/express-access-log-middleware": "^5.0.0", + "@map-colonies/jobnik-sdk": "^1.0.1", "@map-colonies/js-logger": "^5.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", @@ -27,7 +29,8 @@ "http-status-codes": "^2.3.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", - "tsyringe": "^4.8.0" + "tsyringe": "^4.8.0", + "zod": "^3.24.1" }, "devDependencies": { "@commitlint/cli": "^21.2.2", @@ -60,6 +63,48 @@ "tsc-alias": "^1.9.1", "typescript": "^5.9.3", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "../../3d-general/3d-shared": { + "name": "@map-colonies/3d-shared", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@map-colonies/types": "^1.4.0", + "geojson": "^0.5.0", + "mime-types": "^2.1.35", + "zod": "^3.24.1" + }, + "devDependencies": { + "@commitlint/cli": "^19.6.1", + "@commitlint/config-conventional": "^19.6.0", + "@map-colonies/eslint-config": "^4.0.0", + "@map-colonies/prettier-config": "^0.0.1", + "@swc/core": "^1.7.26", + "@swc/jest": "^0.2.36", + "@types/geojson": "^7946.0.14", + "@types/jest": "^29.4.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "commitlint": "^19.6.1", + "eslint": "^8.36.0", + "husky": "^9.1.7", + "jest": "^29.0.0", + "jest-html-reporters": "^3.1.4", + "prettier": "^3.4.2", + "pretty-quick": "^4.0.0", + "rimraf": "^5.0.0", + "standard-version": "^9.5.0", + "ts-jest": "^29.0.0", + "typedoc": "^0.27.6", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=24.0.0" } }, "node_modules/@apideck/better-ajv-errors": { @@ -99,7 +144,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -114,7 +158,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/@babel/helper-string-parser": { @@ -131,7 +174,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -157,7 +199,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -888,7 +929,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-2.0.4.tgz", "integrity": "sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.10.0" @@ -969,6 +1009,10 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@map-colonies/3d-shared": { + "resolved": "../../3d-general/3d-shared", + "link": true + }, "node_modules/@map-colonies/commitlint-config": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@map-colonies/commitlint-config/-/commitlint-config-2.0.1.tgz", @@ -1134,6 +1178,81 @@ "node": ">=24" } }, + "node_modules/@map-colonies/jobnik-sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@map-colonies/jobnik-sdk/-/jobnik-sdk-1.0.1.tgz", + "integrity": "sha512-ieV91q/+F+5QWaKjW3rmvNTfENw/1mLOM/LlMqb6khYs7ODQMCQcXapEYM3VIifYMc51E46XezHNXOxQKBUtfA==", + "license": "ISC", + "dependencies": { + "@map-colonies/read-pkg": "^2.0.0", + "@map-colonies/semantic-conventions": "^1.0.0", + "@readme/openapi-parser": "^4.0.1", + "date-fns": "^4.1.0", + "http-status-codes": "^2.3.0", + "openapi-fetch": "^0.14.0", + "opossum": "^9.0.0", + "undici": "^7.19.1", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=24" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "prom-client": "^15.0.0" + } + }, + "node_modules/@map-colonies/jobnik-sdk/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-13.0.5.tgz", + "integrity": "sha512-xfh4xVJD62gG6spIc7lwxoWT+l16nZu1ELyU8FkjaP/oD2yP09EvLAU6KhtudN9aML2Khhs9pY6Slr7KGTES3w==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@map-colonies/jobnik-sdk/node_modules/@readme/openapi-parser": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@readme/openapi-parser/-/openapi-parser-4.1.2.tgz", + "integrity": "sha512-lAFH88r/CHs5VZDUocEda0OSMSQsr6801sziIjOKyVA+0hSFN+BPuelPF5XvkMROHecnPd+XEJN1iNQqCgER/g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^13.0.5", + "@readme/better-ajv-errors": "^2.3.2", + "@readme/openapi-schemas": "^3.1.0", + "@types/json-schema": "^7.0.15", + "ajv": "^8.12.0", + "ajv-draft-04": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@map-colonies/jobnik-sdk/node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/@map-colonies/js-logger": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@map-colonies/js-logger/-/js-logger-5.0.0.tgz", @@ -1281,6 +1400,14 @@ "integrity": "sha512-XUQ2/gvC6d6/nGMKIGVyZo/kemGH5uGOy4xhkYCVLNV/CJsHvJXmhiov7QpqVpb58x+/2WVVUThAStj9ogipug==", "license": "MIT" }, + "node_modules/@map-colonies/semantic-conventions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@map-colonies/semantic-conventions/-/semantic-conventions-1.0.0.tgz", + "integrity": "sha512-QcR+hHVcH3GyHtOXjmsWZXWWR6GG4Kx8+xWdkjuLlBAEG6sLX6hQObLJ1H5YhLZiw3HKCA39hjLwXyl+6ExUNg==", + "engines": { + "node": ">=24" + } + }, "node_modules/@map-colonies/tracing": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@map-colonies/tracing/-/tracing-1.0.0.tgz", @@ -3543,7 +3670,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/@readme/better-ajv-errors/-/better-ajv-errors-2.4.0.tgz", "integrity": "sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@babel/code-frame": "^7.22.5", @@ -3611,7 +3737,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@readme/openapi-schemas/-/openapi-schemas-3.1.0.tgz", "integrity": "sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6415,6 +6540,16 @@ "node": ">= 8" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -9481,6 +9616,15 @@ "@jsdevtools/ono": "7.1.3" } }, + "node_modules/openapi-fetch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.1.tgz", + "integrity": "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, "node_modules/openapi-response-validator": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/openapi-response-validator/-/openapi-response-validator-9.3.1.tgz", @@ -9541,7 +9685,6 @@ "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "dev": true, "license": "MIT" }, "node_modules/openapi-typescript": { @@ -9566,6 +9709,12 @@ "typescript": "^5.x" } }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, "node_modules/openapi-typescript/node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -9643,6 +9792,15 @@ "@types/node": "*" } }, + "node_modules/opossum": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/opossum/-/opossum-9.0.0.tgz", + "integrity": "sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw==", + "license": "Apache-2.0", + "engines": { + "node": "^24 || ^22 || ^20" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10115,7 +10273,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -12450,6 +12607,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index f080ea9..5791b80 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "service-name", + "name": "3d-ops-trigger", "version": "1.0.0", - "description": "This is template for map colonies typescript service", + "description": "Entry point for 3D operations (ingest, delete, update, publish/unpublish) — validates requests and triggers Jobnik jobs", "main": "./src/index.ts", "scripts": { "test:unit": "vitest run --coverage.enabled=false --project unit", @@ -30,9 +30,14 @@ }, "author": "MapColonies", "license": "ISC", + "engines": { + "node": ">=24.0.0" + }, "dependencies": { "@godaddy/terminus": "^4.12.1", + "@map-colonies/3d-shared": "file:../../3d-general/3d-shared", "@map-colonies/config": "^4.0.1", + "@map-colonies/jobnik-sdk": "^1.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/express-access-log-middleware": "^5.0.0", "@map-colonies/js-logger": "^5.0.0", @@ -49,7 +54,8 @@ "http-status-codes": "^2.3.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", - "tsyringe": "^4.8.0" + "tsyringe": "^4.8.0", + "zod": "^3.24.1" }, "devDependencies": { "@commitlint/cli": "^21.2.2", From 1658058268436831fcfe4cae4e00e5eed8303528 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Mon, 28 Sep 2026 18:19:07 +0300 Subject: [PATCH 02/14] feat: add OpenAPI spec and /record endpoints (MAPCO-11588) Define the four 3D operations in openapi3.yaml (POST /record, DELETE /record/{id}, PATCH /record/{id}, PATCH /record/status/{id}) and wire the record resource (router, controller, manager) with tsyringe. Managers are stubbed pending Jobnik/catalog integration. Replace boilerplate example resources. Integration tests at the HTTP seam + a RecordManager unit test. --- .prettierignore | 1 + openapi3.yaml | 163 ++++++++++++---- .../controllers/anotherResourceController.ts | 29 --- .../models/anotherResourceManager.ts | 20 -- .../routes/anotherResourceRouter.ts | 16 -- src/common/interfaces.ts | 6 + src/containerConfig.ts | 6 +- src/openapi.d.ts | 180 ++++++++++++++---- src/record/controllers/recordController.ts | 78 ++++++++ src/record/models/recordManager.ts | 47 +++++ src/record/routes/recordRouter.ts | 19 ++ .../controllers/resourceNameController.ts | 35 ---- .../models/resourceNameManager.ts | 36 ---- src/resourceName/routes/resourceNameRouter.ts | 17 -- src/serverBuilder.ts | 9 +- .../anotherResourceName.spec.ts | 56 ------ tests/integration/record/record.spec.ts | 101 ++++++++++ .../resourceName/resourceName.spec.ts | 70 ------- .../models/anotherResourceManager.spec.ts | 22 --- .../unit/record/models/recordManager.spec.ts | 51 +++++ .../models/resourceNameModel.spec.ts | 36 ---- 21 files changed, 581 insertions(+), 417 deletions(-) delete mode 100644 src/anotherResource/controllers/anotherResourceController.ts delete mode 100644 src/anotherResource/models/anotherResourceManager.ts delete mode 100644 src/anotherResource/routes/anotherResourceRouter.ts create mode 100644 src/record/controllers/recordController.ts create mode 100644 src/record/models/recordManager.ts create mode 100644 src/record/routes/recordRouter.ts delete mode 100644 src/resourceName/controllers/resourceNameController.ts delete mode 100644 src/resourceName/models/resourceNameManager.ts delete mode 100644 src/resourceName/routes/resourceNameRouter.ts delete mode 100644 tests/integration/anotherResource/anotherResourceName.spec.ts create mode 100644 tests/integration/record/record.spec.ts delete mode 100644 tests/integration/resourceName/resourceName.spec.ts delete mode 100644 tests/unit/anotherResource/models/anotherResourceManager.spec.ts create mode 100644 tests/unit/record/models/recordManager.spec.ts delete mode 100644 tests/unit/resourceName/models/resourceNameModel.spec.ts diff --git a/.prettierignore b/.prettierignore index 805dd09..d30b4be 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ coverage helm reports .release-please-manifest.json +HANDOFF.md diff --git a/openapi3.yaml b/openapi3.yaml index 710048e..74a26a0 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -7,71 +7,136 @@ info: name: MIT url: https://opensource.org/licenses/MIT paths: - /anotherResource: - get: - operationId: getAnotherResource + /record: + post: + operationId: createRecord tags: - - anotherResource - summary: gets the resource + - record + summary: Start an ingestion process flow + description: Validates the request (light & fast) and creates a Jobnik ingestion job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' + responses: + 201: + description: Ingestion job created + content: + application/json: + schema: + $ref: '#/components/schemas/jobResponse' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /record/{id}: + delete: + operationId: deleteRecord + tags: + - record + summary: Start a delete process flow + description: Validates the record can be deleted and creates a Jobnik delete job. + parameters: + - $ref: '#/components/parameters/recordId' responses: 200: - description: OK + description: Delete job created content: application/json: schema: - $ref: '#/components/schemas/anotherResource' + $ref: '#/components/schemas/jobResponse' 400: description: Bad Request content: application/json: schema: $ref: '#/components/schemas/error' - /resourceName: - get: - operationId: getResourceName + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + patch: + operationId: updateRecord tags: - - resourceName - summary: gets the resource + - record + summary: Update metadata for a record + parameters: + - $ref: '#/components/parameters/recordId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updatePayload' responses: 200: - description: OK + description: Metadata updated content: application/json: schema: - $ref: '#/components/schemas/resource' + $ref: '#/components/schemas/ackResponse' 400: description: Bad Request content: application/json: schema: $ref: '#/components/schemas/error' - post: - operationId: createResource + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /record/status/{id}: + patch: + operationId: updateRecordStatus tags: - - resourceName - summary: creates a new record of type resource + - record + summary: Update the publish/unpublish status of a record + parameters: + - $ref: '#/components/parameters/recordId' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/resource' + $ref: '#/components/schemas/statusPayload' responses: - 201: - description: created + 200: + description: Status updated content: application/json: schema: - $ref: '#/components/schemas/resource' + $ref: '#/components/schemas/ackResponse' 400: description: Bad Request content: application/json: schema: $ref: '#/components/schemas/error' + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' security: - {} components: + parameters: + recordId: + name: id + in: path + required: true + description: The record identifier + schema: + type: string schemas: error: type: object @@ -80,27 +145,51 @@ components: properties: message: type: string - resource: + ingestionPayload: type: object required: - - id - - name - - description + - modelPath + - tilesetFilename + - metadata properties: - id: - type: number - format: int64 - name: + modelPath: type: string - description: + description: Path on the shared storage to the 3DTiles folder or 3TZ archive + tilesetFilename: type: string - anotherResource: + description: The tileset entry name (e.g. tileset.json) + metadata: + type: object + description: 3D record metadata (business validation applied downstream) + additionalProperties: true + updatePayload: + type: object + description: Partial metadata fields to update + additionalProperties: true + statusPayload: + type: object + required: + - status + properties: + status: + type: string + enum: + - PUBLISHED + - UNPUBLISHED + jobResponse: + type: object + required: + - jobId + - status + properties: + jobId: + type: string + status: + type: string + ackResponse: type: object required: - - kind - - isAlive + - message properties: - kind: + message: type: string - isAlive: - type: boolean diff --git a/src/anotherResource/controllers/anotherResourceController.ts b/src/anotherResource/controllers/anotherResourceController.ts deleted file mode 100644 index 56f3879..0000000 --- a/src/anotherResource/controllers/anotherResourceController.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { type Registry, Counter } from 'prom-client'; -import httpStatus from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; -import { AnotherResourceManager } from '../models/anotherResourceManager'; - -@injectable() -export class AnotherResourceController { - private readonly getResourceCounter: Counter; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(AnotherResourceManager) private readonly manager: AnotherResourceManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.getResourceCounter = new Counter({ - name: 'get_resource', - help: 'number of get resource requests', - registers: [this.metricsRegistry], - }); - } - - public getResource: TypedRequestHandlers['getAnotherResource'] = (req, res) => { - this.getResourceCounter.inc(1); - return res.status(httpStatus.OK).json(this.manager.getResource()); - }; -} diff --git a/src/anotherResource/models/anotherResourceManager.ts b/src/anotherResource/models/anotherResourceManager.ts deleted file mode 100644 index f54fcde..0000000 --- a/src/anotherResource/models/anotherResourceManager.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; -import { components } from '@src/openapi'; -import { SERVICES } from '@common/constants'; - -const resourceInstance: IAnotherResourceModel = { - kind: 'avi', - isAlive: false, -}; - -export type IAnotherResourceModel = components['schemas']['anotherResource']; - -@injectable() -export class AnotherResourceManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - public getResource(): IAnotherResourceModel { - this.logger.info('logging'); - return resourceInstance; - } -} diff --git a/src/anotherResource/routes/anotherResourceRouter.ts b/src/anotherResource/routes/anotherResourceRouter.ts deleted file mode 100644 index 937ba6d..0000000 --- a/src/anotherResource/routes/anotherResourceRouter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { AnotherResourceController } from '../controllers/anotherResourceController'; - -const anotherResourceRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(AnotherResourceController); - - router.get('/', controller.getResource); - - return router; -}; - -export const ANOTHER_RESOURCE_ROUTER_SYMBOL = Symbol('anotherResourceRouterFactory'); - -export { anotherResourceRouterFactory }; diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 455054c..1f49da0 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -2,3 +2,9 @@ export interface IConfig { get: (setting: string) => T; has: (setting: string) => boolean; } + +export interface LogContext { + fileName: string; + class: string; + function?: string; +} diff --git a/src/containerConfig.ts b/src/containerConfig.ts index ecd03fa..afe665b 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -6,8 +6,7 @@ import { jsLogger } from '@map-colonies/js-logger'; import { type InjectionObject, registerDependencies } from '@common/dependencyRegistration'; import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; -import { resourceNameRouterFactory, RESOURCE_NAME_ROUTER_SYMBOL } from './resourceName/routes/resourceNameRouter'; -import { anotherResourceRouterFactory, ANOTHER_RESOURCE_ROUTER_SYMBOL } from './anotherResource/routes/anotherResourceRouter'; +import { recordRouterFactory, RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; import { getConfig } from './common/config'; export interface RegisterOptions { @@ -31,8 +30,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.LOGGER, provider: { useValue: logger } }, { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, - { token: RESOURCE_NAME_ROUTER_SYMBOL, provider: { useFactory: resourceNameRouterFactory } }, - { token: ANOTHER_RESOURCE_ROUTER_SYMBOL, provider: { useFactory: anotherResourceRouterFactory } }, + { token: RECORD_ROUTER_SYMBOL, provider: { useFactory: recordRouterFactory } }, { token: 'onSignal', provider: { diff --git a/src/openapi.d.ts b/src/openapi.d.ts index eb9ba76..ca8c610 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,39 +4,62 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-express-types'; export type paths = { - '/anotherResource': { + '/record': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getAnotherResource']; + get?: never; put?: never; - post?: never; + /** + * Start an ingestion process flow + * @description Validates the request (light & fast) and creates a Jobnik ingestion job. + */ + post: operations['createRecord']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/resourceName': { + '/record/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getResourceName']; + get?: never; put?: never; - /** creates a new record of type resource */ - post: operations['createResource']; + post?: never; + /** + * Start a delete process flow + * @description Validates the record can be deleted and creates a Jobnik delete job. + */ + delete: operations['deleteRecord']; + options?: never; + head?: never; + /** Update metadata for a record */ + patch: operations['updateRecord']; + trace?: never; + }; + '/record/status/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** Update the publish/unpublish status of a record */ + patch: operations['updateRecordStatus']; trace?: never; }; }; @@ -46,41 +69,95 @@ export type components = { error: { message: string; }; - resource: { - /** Format: int64 */ - id: number; - name: string; - description: string; + ingestionPayload: { + /** @description Path on the shared storage to the 3DTiles folder or 3TZ archive */ + modelPath: string; + /** @description The tileset entry name (e.g. tileset.json) */ + tilesetFilename: string; + /** @description 3D record metadata (business validation applied downstream) */ + metadata: { + [key: string]: unknown; + }; + }; + /** @description Partial metadata fields to update */ + updatePayload: { + [key: string]: unknown; + }; + statusPayload: { + /** @enum {string} */ + status: 'PUBLISHED' | 'UNPUBLISHED'; }; - anotherResource: { - kind: string; - isAlive: boolean; + jobResponse: { + jobId: string; + status: string; + }; + ackResponse: { + message: string; }; }; responses: never; - parameters: never; + parameters: { + /** @description The record identifier */ + recordId: string; + }; requestBodies: never; headers: never; pathItems: never; }; export type $defs = Record; export interface operations { - getAnotherResource: { + createRecord: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + requestBody: { + content: { + 'application/json': components['schemas']['ingestionPayload']; + }; + }; + responses: { + /** @description Ingestion job created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['jobResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + }; + }; + deleteRecord: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; + cookie?: never; + }; requestBody?: never; responses: { - /** @description OK */ + /** @description Delete job created */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['anotherResource']; + 'application/json': components['schemas']['jobResponse']; }; }; /** @description Bad Request */ @@ -92,24 +169,40 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; }; }; - getResourceName: { + updateRecord: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['updatePayload']; + }; + }; responses: { - /** @description OK */ + /** @description Metadata updated */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['ackResponse']; }; }; /** @description Bad Request */ @@ -121,28 +214,40 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; }; }; - createResource: { + updateRecordStatus: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['statusPayload']; }; }; responses: { - /** @description created */ - 201: { + /** @description Status updated */ + 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['ackResponse']; }; }; /** @description Bad Request */ @@ -154,6 +259,15 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; }; }; } diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts new file mode 100644 index 0000000..65a4bce --- /dev/null +++ b/src/record/controllers/recordController.ts @@ -0,0 +1,78 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import { type Registry, Counter } from 'prom-client'; +import type { TypedRequestHandlers } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { RecordManager } from '../models/recordManager'; + +@injectable() +export class RecordController { + private readonly logContext: LogContext; + private readonly ingestionJobCounter: Counter; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(RecordManager) private readonly manager: RecordManager, + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry + ) { + this.logContext = { + fileName: __filename, + class: RecordController.name, + }; + this.ingestionJobCounter = new Counter({ + name: 'ingestion_jobs_created', + help: 'number of ingestion jobs created', + registers: [this.metricsRegistry], + }); + } + + public createRecord: TypedRequestHandlers['createRecord'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.createRecord.name }; + try { + const job = this.manager.createIngestion(req.body); + this.ingestionJobCounter.inc(1); + return res.status(StatusCodes.CREATED).json(job); + } catch (err) { + this.logger.error({ msg: 'failed to create ingestion job', logContext, err }); + return next(err); + } + }; + + public deleteRecord: TypedRequestHandlers['deleteRecord'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.deleteRecord.name }; + const { id } = req.params; + try { + const job = this.manager.deleteRecord(id); + return res.status(StatusCodes.OK).json(job); + } catch (err) { + this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId: id }); + return next(err); + } + }; + + public updateRecord: TypedRequestHandlers['updateRecord'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.updateRecord.name }; + const { id } = req.params; + try { + const ack = this.manager.updateMetadata(id, req.body); + return res.status(StatusCodes.OK).json(ack); + } catch (err) { + this.logger.error({ msg: 'failed to update record metadata', logContext, err, recordId: id }); + return next(err); + } + }; + + public updateRecordStatus: TypedRequestHandlers['updateRecordStatus'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.updateRecordStatus.name }; + const { id } = req.params; + try { + const ack = this.manager.updateStatus(id, req.body); + return res.status(StatusCodes.OK).json(ack); + } catch (err) { + this.logger.error({ msg: 'failed to update record status', logContext, err, recordId: id }); + return next(err); + } + }; +} diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts new file mode 100644 index 0000000..a152175 --- /dev/null +++ b/src/record/models/recordManager.ts @@ -0,0 +1,47 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { inject, injectable } from 'tsyringe'; +import type { components } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; + +export type IngestionPayload = components['schemas']['ingestionPayload']; +export type UpdatePayload = components['schemas']['updatePayload']; +export type StatusPayload = components['schemas']['statusPayload']; +export type JobResponse = components['schemas']['jobResponse']; +export type AckResponse = components['schemas']['ackResponse']; + +@injectable() +export class RecordManager { + private readonly logContext: LogContext; + + public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) { + this.logContext = { + fileName: __filename, + class: RecordManager.name, + }; + } + + public createIngestion(payload: IngestionPayload): JobResponse { + const logContext = { ...this.logContext, function: this.createIngestion.name }; + this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); + return { jobId: 'stub-ingestion-job-id', status: 'PENDING' }; + } + + public deleteRecord(id: string): JobResponse { + const logContext = { ...this.logContext, function: this.deleteRecord.name }; + this.logger.info({ msg: 'creating delete job', logContext, recordId: id }); + return { jobId: 'stub-delete-job-id', status: 'PENDING' }; + } + + public updateMetadata(id: string, update: UpdatePayload): AckResponse { + const logContext = { ...this.logContext, function: this.updateMetadata.name }; + this.logger.info({ msg: 'updating record metadata', logContext, recordId: id, fields: Object.keys(update) }); + return { message: `metadata update accepted for record ${id}` }; + } + + public updateStatus(id: string, payload: StatusPayload): AckResponse { + const logContext = { ...this.logContext, function: this.updateStatus.name }; + this.logger.info({ msg: 'updating record status', logContext, recordId: id, status: payload.status }); + return { message: `status ${payload.status} accepted for record ${id}` }; + } +} diff --git a/src/record/routes/recordRouter.ts b/src/record/routes/recordRouter.ts new file mode 100644 index 0000000..4a5b495 --- /dev/null +++ b/src/record/routes/recordRouter.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { RecordController } from '../controllers/recordController'; + +const recordRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(RecordController); + + router.post('/', controller.createRecord); + router.patch('/status/:id', controller.updateRecordStatus); + router.delete('/:id', controller.deleteRecord); + router.patch('/:id', controller.updateRecord); + + return router; +}; + +export const RECORD_ROUTER_SYMBOL = Symbol('recordRouterFactory'); + +export { recordRouterFactory }; diff --git a/src/resourceName/controllers/resourceNameController.ts b/src/resourceName/controllers/resourceNameController.ts deleted file mode 100644 index c194ea6..0000000 --- a/src/resourceName/controllers/resourceNameController.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import httpStatus from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import { type Registry, Counter } from 'prom-client'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; - -import { ResourceNameManager } from '../models/resourceNameManager'; - -@injectable() -export class ResourceNameController { - private readonly createdResourceCounter: Counter; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(ResourceNameManager) private readonly manager: ResourceNameManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.createdResourceCounter = new Counter({ - name: 'created_resource', - help: 'number of created resources', - registers: [this.metricsRegistry], - }); - } - - public getResource: TypedRequestHandlers['getResourceName'] = (req, res) => { - return res.status(httpStatus.OK).json(this.manager.getResource()); - }; - - public createResource: TypedRequestHandlers['POST /resourceName'] = (req, res) => { - const createdResource = this.manager.createResource(req.body); - this.createdResourceCounter.inc(1); - return res.status(httpStatus.CREATED).json(createdResource); - }; -} diff --git a/src/resourceName/models/resourceNameManager.ts b/src/resourceName/models/resourceNameManager.ts deleted file mode 100644 index be5aa5a..0000000 --- a/src/resourceName/models/resourceNameManager.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; -import type { components } from '@openapi'; -import { SERVICES } from '@common/constants'; - -const resourceInstance: IResourceNameModel = { - id: 1, - name: 'ronin', - description: 'can you do a logistics run?', -}; - -function generateRandomId(): number { - const rangeOfIds = 100; - return Math.floor(Math.random() * rangeOfIds); -} - -export type IResourceNameModel = components['schemas']['resource']; - -@injectable() -export class ResourceNameManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - - public getResource(): IResourceNameModel { - this.logger.info({ msg: 'getting resource', resourceId: resourceInstance.id }); - - return resourceInstance; - } - - public createResource(resource: IResourceNameModel): IResourceNameModel { - const resourceId = generateRandomId(); - - this.logger.info({ msg: 'creating resource', resourceId }); - - return { ...resource, id: resourceId }; - } -} diff --git a/src/resourceName/routes/resourceNameRouter.ts b/src/resourceName/routes/resourceNameRouter.ts deleted file mode 100644 index a0e3f4a..0000000 --- a/src/resourceName/routes/resourceNameRouter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { ResourceNameController } from '../controllers/resourceNameController'; - -const resourceNameRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(ResourceNameController); - - router.get('/', controller.getResource); - router.post('/', controller.createResource); - - return router; -}; - -export const RESOURCE_NAME_ROUTER_SYMBOL = Symbol('resourceNameRouterFactory'); - -export { resourceNameRouterFactory }; diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 5554627..3e2fc98 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -10,8 +10,7 @@ import { collectMetricsExpressMiddleware } from '@map-colonies/prometheus'; import { Registry } from 'prom-client'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; -import { RESOURCE_NAME_ROUTER_SYMBOL } from './resourceName/routes/resourceNameRouter'; -import { ANOTHER_RESOURCE_ROUTER_SYMBOL } from './anotherResource/routes/anotherResourceRouter'; +import { RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; @injectable() export class ServerBuilder { @@ -21,8 +20,7 @@ export class ServerBuilder { @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(RESOURCE_NAME_ROUTER_SYMBOL) private readonly resourceNameRouter: Router, - @inject(ANOTHER_RESOURCE_ROUTER_SYMBOL) private readonly anotherResourceRouter: Router + @inject(RECORD_ROUTER_SYMBOL) private readonly recordRouter: Router ) { this.serverInstance = express(); } @@ -45,8 +43,7 @@ export class ServerBuilder { } private buildRoutes(): void { - this.serverInstance.use('/resourceName', this.resourceNameRouter); - this.serverInstance.use('/anotherResource', this.anotherResourceRouter); + this.serverInstance.use('/record', this.recordRouter); this.buildDocsRoutes(); } diff --git a/tests/integration/anotherResource/anotherResourceName.spec.ts b/tests/integration/anotherResource/anotherResourceName.spec.ts deleted file mode 100644 index fce250d..0000000 --- a/tests/integration/anotherResource/anotherResourceName.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; -import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; -import type { paths, operations } from '@openapi'; -import { getApp } from '@src/app'; -import { SERVICES } from '@src/common/constants'; -import { initConfig } from '@src/common/config'; - -describe('anotherResourceName', function () { - let requestSender: RequestSender; - - beforeAll(async function () { - await initConfig(true); - }); - - beforeEach(async function () { - const [app] = await getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = await createRequestSender('openapi3.yaml', app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getAnotherResource(); - - expect(response.status).toBe(httpStatusCodes.OK); - expect(response).toSatisfyApiSpec(); - - const resource = response.body as paths['/anotherResource']['get']['responses'][200]['content']['application/json']; - - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); - - describe('Bad Path', function () { - // All requests with status code of 400 - it('should in theory test 400 status code', function () { - expect(true).toBe(true); - }); - }); - - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - it('should in theory test 500 status code', function () { - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts new file mode 100644 index 0000000..9ac5a72 --- /dev/null +++ b/tests/integration/record/record.spec.ts @@ -0,0 +1,101 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; +import { trace } from '@opentelemetry/api'; +import httpStatusCodes from 'http-status-codes'; +import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; +import type { paths, operations } from '@openapi'; +import { getApp } from '@src/app'; +import { SERVICES } from '@common/constants'; +import { initConfig } from '@src/common/config'; + +const validIngestionPayload = { + modelPath: '/shared/models/afula', + tilesetFilename: 'tileset.json', + metadata: { productName: 'afula', productType: 'PHOTO_REALISTIC' }, +}; + +describe('record', function () { + let requestSender: RequestSender; + + beforeAll(async function () { + await initConfig(true); + }); + + beforeEach(async function () { + const [app] = await getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + ], + useChild: true, + }); + requestSender = await createRequestSender('openapi3.yaml', app); + }); + + describe('POST /record', function () { + it('should return 201 and a job response for a valid ingestion request', async function () { + const response = await requestSender.createRecord({ requestBody: validIngestionPayload }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.CREATED); + + const body = response.body as paths['/record']['post']['responses']['201']['content']['application/json']; + + expect(body.jobId).toBeTypeOf('string'); + expect(body.status).toBeTypeOf('string'); + }); + + it('should return 400 when a required field is missing', async function () { + const response = await requestSender.createRecord({ + // @ts-expect-error intentionally invalid: missing tilesetFilename and metadata + requestBody: { modelPath: '/shared/models/afula' }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + }); + }); + + describe('DELETE /record/{id}', function () { + it('should return 200 and a job response', async function () { + const response = await requestSender.deleteRecord({ pathParams: { id: 'rec-1' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body as paths['/record/{id}']['delete']['responses']['200']['content']['application/json']; + + expect(body.jobId).toBeTypeOf('string'); + }); + }); + + describe('PATCH /record/{id}', function () { + it('should return 200 and an ack for a metadata update', async function () { + const response = await requestSender.updateRecord({ pathParams: { id: 'rec-1' }, requestBody: { description: 'updated' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + expect(response.body.message).toBeTypeOf('string'); + }); + }); + + describe('PATCH /record/status/{id}', function () { + it('should return 200 and an ack for a valid status change', async function () { + const response = await requestSender.updateRecordStatus({ pathParams: { id: 'rec-1' }, requestBody: { status: 'PUBLISHED' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + }); + + it('should return 400 for an invalid status value', async function () { + const response = await requestSender.updateRecordStatus({ + pathParams: { id: 'rec-1' }, + // @ts-expect-error intentionally invalid status enum value + requestBody: { status: 'NOT_A_STATUS' }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + }); + }); +}); diff --git a/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts deleted file mode 100644 index be26f69..0000000 --- a/tests/integration/resourceName/resourceName.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; -import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; -import type { paths, operations } from '@openapi'; -import { getApp } from '@src/app'; -import { SERVICES } from '@common/constants'; -import { initConfig } from '@src/common/config'; - -describe('resourceName', function () { - let requestSender: RequestSender; - - beforeAll(async function () { - await initConfig(true); - }); - - beforeEach(async function () { - const [app] = await getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = await createRequestSender('openapi3.yaml', app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getResourceName(); - - expect(response.status).toBe(httpStatusCodes.OK); - - const resource = response.body as paths['/resourceName']['get']['responses'][200]['content']['application/json']; - - expect(response).toSatisfyApiSpec(); - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); - }); - - it('should return 200 status code and create the resource', async function () { - const response = await requestSender.createResource({ - requestBody: { - description: 'aaa', - id: 1, - name: 'aaa', - }, - }); - - expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.CREATED); - }); - }); - - describe('Bad Path', function () { - // All requests with status code of 400 - it('should in theory test 400 status code', function () { - expect(true).toBe(true); - }); - }); - - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - it('should in theory test 500 status code', function () { - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts b/tests/unit/anotherResource/models/anotherResourceManager.spec.ts deleted file mode 100644 index c086bb4..0000000 --- a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect } from 'vitest'; -import { AnotherResourceManager } from '@src/anotherResource/models/anotherResourceManager'; - -let anotherResourceManager: AnotherResourceManager; - -describe('ResourceNameManager', () => { - beforeEach(async function () { - anotherResourceManager = new AnotherResourceManager(await jsLogger({ enabled: false })); - }); - - describe('#getResource', () => { - it('should return resource of kind avi', function () { - // action - const resource = anotherResourceManager.getResource(); - - // expectation - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); -}); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts new file mode 100644 index 0000000..a6d87ab --- /dev/null +++ b/tests/unit/record/models/recordManager.spec.ts @@ -0,0 +1,51 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; + +describe('RecordManager', function () { + let manager: RecordManager; + + beforeEach(async function () { + manager = new RecordManager(await jsLogger({ enabled: false })); + }); + + describe('createIngestion', function () { + it('should return a job response with a jobId and status', function () { + const payload: IngestionPayload = { + modelPath: '/shared/models/afula', + tilesetFilename: 'tileset.json', + metadata: { productName: 'afula' }, + }; + + const result = manager.createIngestion(payload); + + expect(result.jobId).toBeTypeOf('string'); + expect(result.status).toBeTypeOf('string'); + }); + }); + + describe('deleteRecord', function () { + it('should return a job response', function () { + const result = manager.deleteRecord('rec-1'); + + expect(result.jobId).toBeTypeOf('string'); + expect(result.status).toBeTypeOf('string'); + }); + }); + + describe('updateMetadata', function () { + it('should return an ack referencing the record id', function () { + const result = manager.updateMetadata('rec-1', { description: 'x' }); + + expect(result.message).toContain('rec-1'); + }); + }); + + describe('updateStatus', function () { + it('should return an ack referencing the requested status', function () { + const result = manager.updateStatus('rec-1', { status: 'PUBLISHED' }); + + expect(result.message).toContain('PUBLISHED'); + }); + }); +}); diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts deleted file mode 100644 index 61d9bbe..0000000 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect } from 'vitest'; -import { ResourceNameManager } from '@src/resourceName/models/resourceNameManager'; - -let resourceNameManager: ResourceNameManager; - -describe('ResourceNameManager', () => { - beforeEach(async function () { - resourceNameManager = new ResourceNameManager(await jsLogger({ enabled: false })); - }); - - describe('#getResource', () => { - it('should return the resource of id 1', function () { - // action - const resource = resourceNameManager.getResource(); - - // expectation - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); - }); - }); - - describe('#createResource', () => { - it('should return the resource of id 1', function () { - // action - const resource = resourceNameManager.createResource({ description: 'meow', id: 1, name: 'cat' }); - - // expectation - expect(resource.id).toBeLessThanOrEqual(100); - expect(resource.id).toBeGreaterThanOrEqual(0); - expect(resource).toHaveProperty('name', 'cat'); - expect(resource).toHaveProperty('description', 'meow'); - }); - }); -}); From 7a980bc24d5b8bc8dcad5ba9801a33751558a008 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Mon, 28 Sep 2026 18:40:34 +0300 Subject: [PATCH 03/14] feat: add ingestion metadata validation (MAPCO-11589) Add ValidationManager that validates ingestion metadata using zod schemas from @map-colonies/3d-shared: core fields (new3DLayerMetadataSchema), footprint 2D/3D (geometrySchema), and date-before-date. Invalid metadata throws AppError(400) routed via the controller error seam. Wire the validator into RecordManager's ingestion path. Restore AppError (now used). Unit + integration tests. --- config/custom-environment-variables.json | 9 ++ config/default.json | 7 ++ helm/templates/configmap.yaml | 7 ++ helm/values.yaml | 6 ++ package-lock.json | 64 ++++++++++--- package.json | 3 +- src/common/appError.ts | 20 ++++ src/common/config.ts | 57 +++++++++--- src/externalServices/catalog/catalogCall.ts | 53 +++++++++++ src/externalServices/catalog/interfaces.ts | 17 ++++ .../lookupTables/interfaces.ts | 5 + .../lookupTables/lookupTablesCall.ts | 41 ++++++++ src/record/controllers/recordController.ts | 4 +- src/record/models/recordManager.ts | 9 +- src/validator/validationManager.ts | 93 +++++++++++++++++++ tests/helpers/metadata.ts | 28 ++++++ tests/integration/record/record.spec.ts | 23 ++++- .../catalog/catalogCall.spec.ts | 36 +++++++ .../lookupTables/lookupTablesCall.spec.ts | 37 ++++++++ .../unit/record/models/recordManager.spec.ts | 11 ++- .../unit/validator/validationManager.spec.ts | 88 ++++++++++++++++++ 21 files changed, 584 insertions(+), 34 deletions(-) create mode 100644 config/custom-environment-variables.json create mode 100644 src/common/appError.ts create mode 100644 src/externalServices/catalog/catalogCall.ts create mode 100644 src/externalServices/catalog/interfaces.ts create mode 100644 src/externalServices/lookupTables/interfaces.ts create mode 100644 src/externalServices/lookupTables/lookupTablesCall.ts create mode 100644 src/validator/validationManager.ts create mode 100644 tests/helpers/metadata.ts create mode 100644 tests/unit/externalServices/catalog/catalogCall.spec.ts create mode 100644 tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts create mode 100644 tests/unit/validator/validationManager.spec.ts diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json new file mode 100644 index 0000000..c956cd1 --- /dev/null +++ b/config/custom-environment-variables.json @@ -0,0 +1,9 @@ +{ + "externalServices": { + "catalog": "CATALOG_URL", + "lookupTables": { + "url": "LOOKUP_TABLES_URL", + "subUrl": "LOOKUP_TABLES_SUB_URL" + } + } +} diff --git a/config/default.json b/config/default.json index 02f9345..0f5589d 100644 --- a/config/default.json +++ b/config/default.json @@ -32,5 +32,12 @@ "options": null } } + }, + "externalServices": { + "catalog": "http://127.0.0.1:8080", + "lookupTables": { + "url": "http://127.0.0.1:8080", + "subUrl": "lookup-tables/lookupData" + } } } diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 0cd9c61..2635724 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -30,6 +30,13 @@ data: {{- end }} {{- end }} npm_config_cache: /tmp/ + {{- with .Values.externalServices }} + CATALOG_URL: {{ .catalog | quote }} + {{- with .lookupTables }} + LOOKUP_TABLES_URL: {{ .url | quote }} + LOOKUP_TABLES_SUB_URL: {{ .subUrl | quote }} + {{- end }} + {{- end }} {{- with .Values.configManagement }} CONFIG_NAME: {{ .name | quote }} CONFIG_VERSION: {{ .version | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index 46736ea..a6b7973 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -78,6 +78,12 @@ server: responseCompressionEnabled: true requestPayloadLimit: 1mb +externalServices: + catalog: '' + lookupTables: + url: '' + subUrl: '' + telemetry: logger: level: info diff --git a/package-lock.json b/package-lock.json index 0a0f98a..def67c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", "@opentelemetry/api": "^1.9.1", + "axios": "^1.20.0", "compression": "^1.8.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", @@ -5599,7 +5600,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5622,13 +5622,40 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dev": true, + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/balanced-match": { @@ -6103,7 +6130,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6596,7 +6622,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6777,7 +6802,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7595,7 +7619,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -7616,7 +7639,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7977,7 +7999,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9792,6 +9813,16 @@ "@types/node": "*" } }, + "node_modules/openapi-validator/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/opossum": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/opossum/-/opossum-9.0.0.tgz", @@ -10649,6 +10680,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", diff --git a/package.json b/package.json index 5791b80..6fb2382 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "@godaddy/terminus": "^4.12.1", "@map-colonies/3d-shared": "file:../../3d-general/3d-shared", "@map-colonies/config": "^4.0.1", - "@map-colonies/jobnik-sdk": "^1.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/express-access-log-middleware": "^5.0.0", + "@map-colonies/jobnik-sdk": "^1.0.1", "@map-colonies/js-logger": "^5.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", @@ -48,6 +48,7 @@ "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", "@opentelemetry/api": "^1.9.1", + "axios": "^1.20.0", "compression": "^1.8.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", diff --git a/src/common/appError.ts b/src/common/appError.ts new file mode 100644 index 0000000..26b6212 --- /dev/null +++ b/src/common/appError.ts @@ -0,0 +1,20 @@ +import type { StatusCodes } from 'http-status-codes'; + +export class AppError extends Error { + public override readonly name: string; + public readonly status: StatusCodes; + public readonly isOperational: boolean; + + public constructor(name: string, status: StatusCodes, description: string, isOperational: boolean) { + super(description); + + Object.setPrototypeOf(this, new.target.prototype); + + this.name = name; + this.status = status; + this.isOperational = isOperational; + this.message = description; + + Error.captureStackTrace(this); + } +} diff --git a/src/common/config.ts b/src/common/config.ts index 3541a48..47a6218 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -1,21 +1,56 @@ import { type ConfigInstance, config } from '@map-colonies/config'; import { commonBoilerplateV3, type commonBoilerplateV3Type } from '@map-colonies/schemas'; -// Choose here the type of the config instance and import this type from the entire application -type ConfigType = ConfigInstance; +interface LookupTablesConfig { + url: string; + subUrl: string; +} + +interface ExternalServicesConfig { + lookupTables: LookupTablesConfig; + catalog: string; +} + +type OpsTriggerConfigType = commonBoilerplateV3Type & { externalServices: ExternalServicesConfig }; + +type ConfigType = ConfigInstance; + +const opsTriggerConfigSchema = { + $id: 'https://mapcolonies.com/3d/opsTrigger/v1', + type: 'object', + allOf: [ + { $ref: commonBoilerplateV3.$id }, + { + type: 'object', + required: ['externalServices'], + properties: { + externalServices: { + type: 'object', + required: ['lookupTables', 'catalog'], + properties: { + lookupTables: { + type: 'object', + required: ['url', 'subUrl'], + properties: { + url: { type: 'string' }, + subUrl: { type: 'string' }, + }, + }, + catalog: { type: 'string' }, + }, + }, + }, + }, + ], +}; let configInstance: ConfigType | undefined; -/** - * Initializes the configuration by fetching it from the server. - * This should only be called from the instrumentation file. - * @returns A Promise that resolves when the configuration is successfully initialized. - */ async function initConfig(offlineMode?: boolean): Promise { - configInstance = await config({ - schema: commonBoilerplateV3, + configInstance = (await config({ + schema: opsTriggerConfigSchema as unknown as typeof commonBoilerplateV3, offlineMode, - }); + })) as unknown as ConfigType; } function getConfig(): ConfigType { @@ -26,4 +61,4 @@ function getConfig(): ConfigType { } export { getConfig, initConfig }; -export type { ConfigType }; +export type { ConfigType, LookupTablesConfig, ExternalServicesConfig }; diff --git a/src/externalServices/catalog/catalogCall.ts b/src/externalServices/catalog/catalogCall.ts new file mode 100644 index 0000000..3f0fd9e --- /dev/null +++ b/src/externalServices/catalog/catalogCall.ts @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import { AppError } from '@common/appError'; +import type { ConfigType } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import type { IFindRecordsPayload, Record3D } from './interfaces'; + +@injectable() +export class CatalogCall { + private readonly logContext: LogContext; + private readonly catalog: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.catalog = this.config.get('externalServices.catalog'); + this.logContext = { + fileName: __filename, + class: CatalogCall.name, + }; + } + + public async findRecords(payload: IFindRecordsPayload): Promise { + const logContext = { ...this.logContext, function: this.findRecords.name }; + this.logger.debug({ msg: `Searching in catalog ${this.catalog}/metadata/find`, logContext, payload }); + try { + const response = await axios.post(`${this.catalog}/metadata/find`, payload); + if (response.status !== StatusCodes.OK.valueOf()) { + this.logger.error({ msg: `Catalog returned unexpected status: ${response.status}`, logContext }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during record lookup', true); + } + + const records = response.data; + if (!Array.isArray(records)) { + return []; + } + + this.logger.debug({ msg: `Found ${records.length} record(s) in catalog`, logContext }); + return records; + } catch (err) { + if (err instanceof AppError) { + throw err; + } + + this.logger.error({ msg: 'Something went wrong in catalog when trying to find records', logContext, err }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog find', true); + } + } +} diff --git a/src/externalServices/catalog/interfaces.ts b/src/externalServices/catalog/interfaces.ts new file mode 100644 index 0000000..7aa1b75 --- /dev/null +++ b/src/externalServices/catalog/interfaces.ts @@ -0,0 +1,17 @@ +export interface Record3D { + id: string; + productId?: string; + productName?: string; + productVersion?: number; + productStatus?: string; + links?: string; +} + +export interface IFindRecordsPayload { + id?: string; + productId?: string; + productName?: string; + productType?: string; + classification?: string; + productStatus?: string; +} diff --git a/src/externalServices/lookupTables/interfaces.ts b/src/externalServices/lookupTables/interfaces.ts new file mode 100644 index 0000000..99c30e4 --- /dev/null +++ b/src/externalServices/lookupTables/interfaces.ts @@ -0,0 +1,5 @@ +export interface ILookupOption { + value: string; + translationCode: string; + properties?: Record; +} diff --git a/src/externalServices/lookupTables/lookupTablesCall.ts b/src/externalServices/lookupTables/lookupTablesCall.ts new file mode 100644 index 0000000..7125e3a --- /dev/null +++ b/src/externalServices/lookupTables/lookupTablesCall.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import { AppError } from '@common/appError'; +import type { ConfigType, LookupTablesConfig } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import type { ILookupOption } from './interfaces'; + +@injectable() +export class LookupTablesCall { + private readonly logContext: LogContext; + private readonly lookupTables: LookupTablesConfig; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.lookupTables = this.config.get('externalServices.lookupTables'); + this.logContext = { + fileName: __filename, + class: LookupTablesCall.name, + }; + } + + public async getClassifications(): Promise { + const logContext = { ...this.logContext, function: this.getClassifications.name }; + this.logger.debug({ msg: 'Get Classifications from lookup-tables service', logContext }); + try { + const response = await axios.get(`${this.lookupTables.url}/${this.lookupTables.subUrl}/classification`); + const classifications = response.data.map((item) => item.value); + this.logger.debug({ msg: 'Got Classifications', logContext, classifications }); + + return classifications; + } catch (err) { + this.logger.error({ msg: 'something went wrong with lookup-tables service', logContext, err }); + throw new AppError('lookup-tables', StatusCodes.INTERNAL_SERVER_ERROR, 'there is a problem with lookup-tables', true); + } + } +} diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts index 65a4bce..84fed2a 100644 --- a/src/record/controllers/recordController.ts +++ b/src/record/controllers/recordController.ts @@ -28,10 +28,10 @@ export class RecordController { }); } - public createRecord: TypedRequestHandlers['createRecord'] = (req, res, next) => { + public createRecord: TypedRequestHandlers['createRecord'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.createRecord.name }; try { - const job = this.manager.createIngestion(req.body); + const job = await this.manager.createIngestion(req.body); this.ingestionJobCounter.inc(1); return res.status(StatusCodes.CREATED).json(job); } catch (err) { diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts index a152175..7f907ae 100644 --- a/src/record/models/recordManager.ts +++ b/src/record/models/recordManager.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'tsyringe'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; +import { ValidationManager } from '../../validator/validationManager'; export type IngestionPayload = components['schemas']['ingestionPayload']; export type UpdatePayload = components['schemas']['updatePayload']; @@ -14,16 +15,20 @@ export type AckResponse = components['schemas']['ackResponse']; export class RecordManager { private readonly logContext: LogContext; - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) { + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(ValidationManager) private readonly validator: ValidationManager + ) { this.logContext = { fileName: __filename, class: RecordManager.name, }; } - public createIngestion(payload: IngestionPayload): JobResponse { + public async createIngestion(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.createIngestion.name }; this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); + await this.validator.validateIngestion(payload.metadata); return { jobId: 'stub-ingestion-job-id', status: 'PENDING' }; } diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts new file mode 100644 index 0000000..bd97e2d --- /dev/null +++ b/src/validator/validationManager.ts @@ -0,0 +1,93 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'tsyringe'; +import { new3DLayerMetadataSchema, geometrySchema } from '@map-colonies/3d-shared'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; +import { CatalogCall } from '../externalServices/catalog/catalogCall'; + +export const ERROR_METADATA_DATE = 'imagingTimeBeginUTC must not be later than imagingTimeEndUTC'; +export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC are required'; +export const ERROR_METADATA_INVALID_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC must be valid dates'; +export const ERROR_METADATA_FOOTPRINT = 'Invalid footprint! Must be a GeoJSON Polygon or MultiPolygon with all-2D or all-3D coordinates'; +export const ERROR_METADATA_PRODUCT_NAME_UNIQUE = 'product name is not unique!'; + +@injectable() +export class ValidationManager { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, + @inject(CatalogCall) private readonly catalog: CatalogCall + ) { + this.logContext = { + fileName: __filename, + class: ValidationManager.name, + }; + } + + public async validateIngestion(metadata: Record): Promise { + const logContext = { ...this.logContext, function: this.validateIngestion.name }; + this.logger.info({ msg: 'ingestion validation start', logContext }); + + const parsed = new3DLayerMetadataSchema.safeParse(metadata); + if (!parsed.success) { + const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } + + const footprintResult = geometrySchema.safeParse(metadata.footprint); + if (!footprintResult.success) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); + } + + this.validateDates(metadata.imagingTimeBeginUTC, metadata.imagingTimeEndUTC); + + await this.validateClassification(parsed.data.classification); + await this.validateProductNameUnique(parsed.data.productName); + } + + private async validateProductNameUnique(productName: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductNameUnique.name }; + const records = await this.catalog.findRecords({ productName }); + this.logger.debug({ msg: 'product name uniqueness validation', logContext, productName, matches: records.length }); + + if (records.length > 0) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); + } + } + + private validateDates(start: unknown, end: unknown): void { + if (start === undefined || end === undefined) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_MISSING_DATE, true); + } + + const startTime = new Date(start as string).getTime(); + const endTime = new Date(end as string).getTime(); + if (Number.isNaN(startTime) || Number.isNaN(endTime)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_INVALID_DATE, true); + } + + if (startTime > endTime) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_DATE, true); + } + } + + private async validateClassification(classification: string): Promise { + const logContext = { ...this.logContext, function: this.validateClassification.name }; + const classifications = await this.lookupTables.getClassifications(); + this.logger.debug({ msg: 'classification validation', logContext, classifications }); + + if (!classifications.includes(classification)) { + throw new AppError( + 'badRequest', + StatusCodes.BAD_REQUEST, + `classification is not a valid value. Optional values: ${classifications.join()}`, + true + ); + } + } +} diff --git a/tests/helpers/metadata.ts b/tests/helpers/metadata.ts new file mode 100644 index 0000000..44d1527 --- /dev/null +++ b/tests/helpers/metadata.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { LAYER_3D_PRODUCT_TYPE_LIST } from '@map-colonies/3d-shared'; + +export const buildValidMetadata = (): Record => ({ + productId: 'p-1', + productName: 'afula', + productType: LAYER_3D_PRODUCT_TYPE_LIST[0], + classification: 'abc123', + srsId: '4326', + srsName: 'WGS84GEO', + region: ['ישראל'], + producerName: 'IDFMU', + productionSystem: 'sys', + productionSystemVersion: '1', + productionDate: '2025-07-08T11:26:00.000Z', + footprint: { + type: 'Polygon', + coordinates: [ + [ + [34.45, 31.48], + [34.46, 31.48], + [34.45, 31.48], + ], + ], + }, + imagingTimeBeginUTC: '2025-07-06T11:10:00.000Z', + imagingTimeEndUTC: '2025-07-10T11:10:00.000Z', +}); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index 9ac5a72..0a1ccfb 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -1,5 +1,5 @@ import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; +import { describe, beforeEach, it, expect, beforeAll, vi } from 'vitest'; import { trace } from '@opentelemetry/api'; import httpStatusCodes from 'http-status-codes'; import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; @@ -7,11 +7,19 @@ import type { paths, operations } from '@openapi'; import { getApp } from '@src/app'; import { SERVICES } from '@common/constants'; import { initConfig } from '@src/common/config'; +import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { buildValidMetadata } from '@tests/helpers/metadata'; + +const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; +const catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; + +const validMetadata = buildValidMetadata(); const validIngestionPayload = { modelPath: '/shared/models/afula', tilesetFilename: 'tileset.json', - metadata: { productName: 'afula', productType: 'PHOTO_REALISTIC' }, + metadata: validMetadata, }; describe('record', function () { @@ -26,6 +34,8 @@ describe('record', function () { override: [ { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + { token: LookupTablesCall, provider: { useValue: lookupStub } }, + { token: CatalogCall, provider: { useValue: catalogStub } }, ], useChild: true, }); @@ -54,6 +64,15 @@ describe('record', function () { expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); }); + + it('should return 400 when metadata fails business validation', async function () { + const response = await requestSender.createRecord({ + requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + }); }); describe('DELETE /record/{id}', function () { diff --git a/tests/unit/externalServices/catalog/catalogCall.spec.ts b/tests/unit/externalServices/catalog/catalogCall.spec.ts new file mode 100644 index 0000000..94d45ec --- /dev/null +++ b/tests/unit/externalServices/catalog/catalogCall.spec.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; + +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +const configStub = { get: (): unknown => 'http://catalog' } as unknown as ConfigType; + +describe('CatalogCall', function () { + let client: CatalogCall; + + beforeEach(async function () { + vi.clearAllMocks(); + client = new CatalogCall(configStub, await jsLogger({ enabled: false })); + }); + + it('should return the records found by the catalog service', async function () { + mockedAxios.post.mockResolvedValue({ status: StatusCodes.OK, data: [{ id: '1', productName: 'afula' }] }); + + const records = await client.findRecords({ productName: 'afula' }); + + expect(records).toHaveLength(1); + expect(records[0].productName).toBe('afula'); + }); + + it('should throw an AppError when the catalog service fails', async function () { + mockedAxios.post.mockRejectedValue(new Error('service down')); + + await expect(client.findRecords({ productName: 'afula' })).rejects.toThrow(AppError); + }); +}); diff --git a/tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts b/tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts new file mode 100644 index 0000000..17ae1f8 --- /dev/null +++ b/tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts @@ -0,0 +1,37 @@ +import axios from 'axios'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; + +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +const configStub = { get: (): unknown => ({ url: 'http://lookup', subUrl: 'lookup-tables/lookupData' }) } as unknown as ConfigType; + +describe('LookupTablesCall', function () { + let client: LookupTablesCall; + + beforeEach(async function () { + vi.clearAllMocks(); + client = new LookupTablesCall(configStub, await jsLogger({ enabled: false })); + }); + + it('should return the classification values from the service', async function () { + mockedAxios.get.mockResolvedValue({ + data: [ + { value: 'a', translationCode: 'x' }, + { value: 'b', translationCode: 'y' }, + ], + }); + + await expect(client.getClassifications()).resolves.toEqual(['a', 'b']); + }); + + it('should throw an AppError when the lookup-tables service fails', async function () { + mockedAxios.get.mockRejectedValue(new Error('service down')); + + await expect(client.getClassifications()).rejects.toThrow(AppError); + }); +}); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts index a6d87ab..fde0ed2 100644 --- a/tests/unit/record/models/recordManager.spec.ts +++ b/tests/unit/record/models/recordManager.spec.ts @@ -1,23 +1,26 @@ import { jsLogger } from '@map-colonies/js-logger'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; +import type { ValidationManager } from '@src/validator/validationManager'; + +const noopValidator = { validateIngestion: vi.fn().mockResolvedValue(undefined) } as unknown as ValidationManager; describe('RecordManager', function () { let manager: RecordManager; beforeEach(async function () { - manager = new RecordManager(await jsLogger({ enabled: false })); + manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator); }); describe('createIngestion', function () { - it('should return a job response with a jobId and status', function () { + it('should return a job response with a jobId and status', async function () { const payload: IngestionPayload = { modelPath: '/shared/models/afula', tilesetFilename: 'tileset.json', metadata: { productName: 'afula' }, }; - const result = manager.createIngestion(payload); + const result = await manager.createIngestion(payload); expect(result.jobId).toBeTypeOf('string'); expect(result.status).toBeTypeOf('string'); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts new file mode 100644 index 0000000..c2638a8 --- /dev/null +++ b/tests/unit/validator/validationManager.spec.ts @@ -0,0 +1,88 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StatusCodes } from 'http-status-codes'; +import { + ValidationManager, + ERROR_METADATA_DATE, + ERROR_METADATA_FOOTPRINT, + ERROR_METADATA_MISSING_DATE, + ERROR_METADATA_INVALID_DATE, +} from '@src/validator/validationManager'; +import { AppError } from '@src/common/appError'; +import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { buildValidMetadata as validMetadata } from '@tests/helpers/metadata'; + +const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; + +describe('ValidationManager', function () { + let validator: ValidationManager; + let catalogStub: CatalogCall; + + beforeEach(async function () { + catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; + validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub); + }); + + it('should pass a fully valid metadata object', async function () { + await expect(validator.validateIngestion(validMetadata())).resolves.toBeUndefined(); + }); + + it('should throw 400 when a required core field is missing', async function () { + const metadata = validMetadata(); + delete metadata.productName; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(AppError); + }); + + it('should throw 400 for an invalid productType', async function () { + const metadata = { ...validMetadata(), productType: 'NOT_A_3D_TYPE' }; + + let thrown: unknown; + try { + await validator.validateIngestion(metadata); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should throw the footprint error for a malformed geometry', async function () { + const metadata = { ...validMetadata(), footprint: { type: 'Point', coordinates: [34.45, 31.48] } }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_FOOTPRINT); + }); + + it('should throw the date error when imagingTimeBeginUTC is after imagingTimeEndUTC', async function () { + const metadata = { ...validMetadata(), imagingTimeBeginUTC: '2025-07-11T00:00:00.000Z' }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_DATE); + }); + + it('should throw the missing-date error when a source date is absent', async function () { + const metadata = validMetadata(); + delete metadata.imagingTimeEndUTC; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_MISSING_DATE); + }); + + it('should throw the invalid-date error for an unparseable date', async function () { + const metadata = { ...validMetadata(), imagingTimeBeginUTC: 'not-a-date' }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_INVALID_DATE); + }); + + it('should throw 400 when the classification is not in the lookup table', async function () { + const metadata = { ...validMetadata(), classification: 'not-a-real-classification' }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(AppError); + }); + + it('should throw 400 when the product name already exists in the catalog', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([{ id: 'existing', productName: 'afula' }]); + + await expect(validator.validateIngestion(validMetadata())).rejects.toThrow(AppError); + }); +}); From b1fc7ae599debfbccf404100ce042ec688939992 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Tue, 29 Sep 2026 14:48:00 +0300 Subject: [PATCH 04/14] feat: create Jobnik ingestion + delete jobs (MAPCO-11590) Add JobnikClient wrapping the @map-colonies/jobnik-sdk Producer. - Ingestion: create 3D-Ingestion job + validation stage/task (+ data-extraction stage/task only for 3tz input). - Delete: validate deletability (record exists, productType not QuantizedMeshDTMBest, productStatus UNPUBLISHED) then create a 3D-Delete job. RecordManager ingest/delete now return real Jobnik jobs. Add externalServices.jobManager config (schema, default, custom-environment-variables, helm). Unit + integration tests. --- config/custom-environment-variables.json | 38 ++ config/default.json | 26 + helm/templates/configmap.yaml | 22 + helm/values.yaml | 21 + package-lock.json | 534 ++++++++++++++++++ package.json | 1 + src/common/config.ts | 90 ++- src/common/constants.ts | 15 + src/common/util.ts | 4 + src/containerConfig.ts | 2 + src/externalServices/catalog/interfaces.ts | 2 + src/externalServices/jobnik/jobnikClient.ts | 105 ++++ src/providers/getProvider.ts | 11 + src/providers/interfaces.ts | 3 + src/providers/nfsProvider.ts | 43 ++ src/providers/s3Provider.ts | 56 ++ src/record/controllers/recordController.ts | 4 +- src/record/models/recordManager.ts | 13 +- src/validator/validationManager.ts | 48 +- tests/integration/record/record.spec.ts | 23 +- .../jobnik/jobnikClient.spec.ts | 89 +++ tests/unit/providers/nfsProvider.spec.ts | 38 ++ tests/unit/providers/s3Provider.spec.ts | 59 ++ .../unit/record/models/recordManager.spec.ts | 16 +- .../unit/validator/validationManager.spec.ts | 89 ++- 25 files changed, 1316 insertions(+), 36 deletions(-) create mode 100644 src/common/util.ts create mode 100644 src/externalServices/jobnik/jobnikClient.ts create mode 100644 src/providers/getProvider.ts create mode 100644 src/providers/interfaces.ts create mode 100644 src/providers/nfsProvider.ts create mode 100644 src/providers/s3Provider.ts create mode 100644 tests/unit/externalServices/jobnik/jobnikClient.spec.ts create mode 100644 tests/unit/providers/nfsProvider.spec.ts create mode 100644 tests/unit/providers/s3Provider.spec.ts diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index c956cd1..6c1060f 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -5,5 +5,43 @@ "url": "LOOKUP_TABLES_URL", "subUrl": "LOOKUP_TABLES_SUB_URL" } + }, + "jobManager": { + "url": "JOB_MANAGER_URL", + "ingestion": { + "jobType": "JOB_INGESTION_TYPE", + "taskType": "TASK_INGESTION_TYPE", + "batches": { + "__name": "INGESTION_TASK_BATCHES", + "__format": "number" + } + }, + "delete": { + "jobType": "JOB_DELETE_TYPE", + "taskType": "TASK_DELETE_TYPE" + } + }, + "provider": "PROVIDER_FROM", + "NFS": { + "pvPath": "PV_SOURCE_PATH" + }, + "S3": { + "accessKeyId": "S3_ACCESS_KEY_ID", + "secretAccessKey": "S3_SECRET_ACCESS_KEY", + "endpointUrl": "S3_ENDPOINT_URL", + "bucket": "S3_BUCKET", + "region": "S3_REGION", + "forcePathStyle": { + "__name": "S3_FORCE_PATH_STYLE", + "__format": "boolean" + }, + "sslEnabled": { + "__name": "S3_SSL_ENABLED", + "__format": "boolean" + }, + "maxAttempts": { + "__name": "S3_MAX_ATTEMPTS", + "__format": "number" + } } } diff --git a/config/default.json b/config/default.json index 0f5589d..319977f 100644 --- a/config/default.json +++ b/config/default.json @@ -39,5 +39,31 @@ "url": "http://127.0.0.1:8080", "subUrl": "lookup-tables/lookupData" } + }, + "jobManager": { + "url": "http://127.0.0.1:8080", + "ingestion": { + "jobType": "Ingestion_New_3D", + "taskType": "tilesCopying", + "batches": 100 + }, + "delete": { + "jobType": "Delete_3D", + "taskType": "deleteModel" + } + }, + "provider": "NFS", + "NFS": { + "pvPath": "/app/models" + }, + "S3": { + "accessKeyId": "minio", + "secretAccessKey": "minio", + "endpointUrl": "http://127.0.0.1:9000", + "bucket": "3dtiles", + "region": "us-east-1", + "forcePathStyle": true, + "sslEnabled": false, + "maxAttempts": 3 } } diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 2635724..f150bf7 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -37,6 +37,28 @@ data: LOOKUP_TABLES_SUB_URL: {{ .subUrl | quote }} {{- end }} {{- end }} + {{- with .Values.jobManager }} + JOB_MANAGER_URL: {{ .url | quote }} + JOB_INGESTION_TYPE: {{ .ingestion.jobType | quote }} + TASK_INGESTION_TYPE: {{ .ingestion.taskType | quote }} + INGESTION_TASK_BATCHES: {{ .ingestion.batches | quote }} + JOB_DELETE_TYPE: {{ .delete.jobType | quote }} + TASK_DELETE_TYPE: {{ .delete.taskType | quote }} + {{- end }} + PROVIDER_FROM: {{ .Values.provider | quote }} + {{- if eq .Values.provider "NFS" }} + PV_SOURCE_PATH: {{ .Values.NFS.pvPath | quote }} + {{- end }} + {{- if eq .Values.provider "S3" }} + {{- with .Values.S3 }} + S3_ENDPOINT_URL: {{ .endpointUrl | quote }} + S3_BUCKET: {{ .bucket | quote }} + S3_REGION: {{ .region | quote }} + S3_FORCE_PATH_STYLE: {{ .forcePathStyle | quote }} + S3_SSL_ENABLED: {{ .sslEnabled | quote }} + S3_MAX_ATTEMPTS: {{ .maxAttempts | quote }} + {{- end }} + {{- end }} {{- with .Values.configManagement }} CONFIG_NAME: {{ .name | quote }} CONFIG_VERSION: {{ .version | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index a6b7973..c3cea6d 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -84,6 +84,27 @@ externalServices: url: '' subUrl: '' +jobManager: + url: + ingestion: + jobType: '' + taskType: '' + batches: + delete: + jobType: '' + taskType: '' + +provider: 'NFS' +NFS: + pvPath: '' +S3: + endpointUrl: '' + bucket: '' + region: '' + forcePathStyle: true + sslEnabled: false + maxAttempts: 3 + telemetry: logger: level: info diff --git a/package-lock.json b/package-lock.json index def67c3..f23dc6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-s3": "^3.1142.0", "@godaddy/terminus": "^4.12.1", "@map-colonies/3d-shared": "file:../../3d-general/3d-shared", "@map-colonies/config": "^4.0.1", @@ -141,6 +142,416 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz", + "integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/checksums/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1142.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1142.0.tgz", + "integrity": "sha512-OC9AcGMFOsBc95YSPWH3O7DE6RUTy7jqeOpVvzO2Dv3rw3w4vQRK+YdLXIbO2yXfmx3N59Y55yAJdDRJFMQK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1001.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/middleware-sdk-s3": "^3.972.77", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz", + "integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -4224,6 +4635,123 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@smithy/core": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz", + "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5748,6 +6276,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", diff --git a/package.json b/package.json index 6fb2382..4d9db38 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "node": ">=24.0.0" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1142.0", "@godaddy/terminus": "^4.12.1", "@map-colonies/3d-shared": "file:../../3d-general/3d-shared", "@map-colonies/config": "^4.0.1", diff --git a/src/common/config.ts b/src/common/config.ts index 47a6218..7dfad2a 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -11,7 +11,45 @@ interface ExternalServicesConfig { catalog: string; } -type OpsTriggerConfigType = commonBoilerplateV3Type & { externalServices: ExternalServicesConfig }; +interface JobManagerConfig { + url: string; + ingestion: { + jobType: string; + taskType: string; + batches: number; + }; + delete: { + jobType: string; + taskType: string; + }; +} + +interface NFSConfig { + pvPath: string; +} + +interface S3Config { + accessKeyId: string; + secretAccessKey: string; + endpointUrl: string; + bucket: string; + region: string; + forcePathStyle: boolean; + sslEnabled: boolean; + maxAttempts: number; +} + +type ProviderSource = 'NFS' | 'S3'; + +type OpsTriggerConfigType = commonBoilerplateV3Type & { + externalServices: ExternalServicesConfig; + jobManager: JobManagerConfig; + provider: ProviderSource; + // eslint-disable-next-line @typescript-eslint/naming-convention + NFS: NFSConfig; + // eslint-disable-next-line @typescript-eslint/naming-convention + S3: S3Config; +}; type ConfigType = ConfigInstance; @@ -22,8 +60,30 @@ const opsTriggerConfigSchema = { { $ref: commonBoilerplateV3.$id }, { type: 'object', - required: ['externalServices'], + required: ['externalServices', 'jobManager', 'provider'], properties: { + provider: { type: 'string', enum: ['NFS', 'S3'] }, + // eslint-disable-next-line @typescript-eslint/naming-convention + NFS: { + type: 'object', + required: ['pvPath'], + properties: { pvPath: { type: 'string' } }, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + S3: { + type: 'object', + required: ['accessKeyId', 'secretAccessKey', 'endpointUrl', 'bucket', 'region', 'forcePathStyle', 'sslEnabled', 'maxAttempts'], + properties: { + accessKeyId: { type: 'string' }, + secretAccessKey: { type: 'string' }, + endpointUrl: { type: 'string' }, + bucket: { type: 'string' }, + region: { type: 'string' }, + forcePathStyle: { type: 'boolean' }, + sslEnabled: { type: 'boolean' }, + maxAttempts: { type: 'number' }, + }, + }, externalServices: { type: 'object', required: ['lookupTables', 'catalog'], @@ -39,6 +99,30 @@ const opsTriggerConfigSchema = { catalog: { type: 'string' }, }, }, + jobManager: { + type: 'object', + required: ['url', 'ingestion', 'delete'], + properties: { + url: { type: 'string' }, + ingestion: { + type: 'object', + required: ['jobType'], + properties: { + jobType: { type: 'string' }, + taskType: { type: 'string' }, + batches: { type: 'number' }, + }, + }, + delete: { + type: 'object', + required: ['jobType'], + properties: { + jobType: { type: 'string' }, + taskType: { type: 'string' }, + }, + }, + }, + }, }, }, ], @@ -61,4 +145,4 @@ function getConfig(): ConfigType { } export { getConfig, initConfig }; -export type { ConfigType, LookupTablesConfig, ExternalServicesConfig }; +export type { ConfigType, LookupTablesConfig, ExternalServicesConfig, JobManagerConfig, NFSConfig, S3Config, ProviderSource }; diff --git a/src/common/constants.ts b/src/common/constants.ts index 6e7c100..492751a 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -6,11 +6,26 @@ export const DEFAULT_SERVER_PORT = 80; export const IGNORED_OUTGOING_TRACE_ROUTES = [/^.*\/v1\/metrics.*$/]; export const IGNORED_INCOMING_TRACE_ROUTES = [/^.*\/docs.*$/]; +export const DOMAIN = '3D'; + +/* eslint-disable @typescript-eslint/naming-convention */ +export const STAGE_TYPES = { + DATA_EXTRACTION: 'data-extraction', + VALIDATION: 'validation', + CREATE_UPLOAD_MODEL_TASKS: 'create-upload-model-tasks', + UPLOAD_MODEL_DATA: 'upload-model-data', + CLEAR_DATA: 'clear-data', + UPLOAD_MODEL_PARTS: 'upload-model-parts', + INGESTION_FINALIZER: 'ingestion-finalizer', +} satisfies Record; +/* eslint-enable @typescript-eslint/naming-convention */ + /* eslint-disable @typescript-eslint/naming-convention */ export const SERVICES = { LOGGER: Symbol('Logger'), CONFIG: Symbol('Config'), TRACER: Symbol('Tracer'), METRICS: Symbol('METRICS'), + PROVIDER: Symbol('Provider'), } satisfies Record; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/common/util.ts b/src/common/util.ts new file mode 100644 index 0000000..542af20 --- /dev/null +++ b/src/common/util.ts @@ -0,0 +1,4 @@ +export const is3tz = (modelPath: string): boolean => modelPath.toLowerCase().endsWith('.3tz'); + +export const buildModelFilePath = (modelPath: string, tilesetFilename: string): string => + is3tz(modelPath) ? modelPath : `${modelPath}/${tilesetFilename}`; diff --git a/src/containerConfig.ts b/src/containerConfig.ts index afe665b..3dbe41f 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -7,6 +7,7 @@ import { type InjectionObject, registerDependencies } from '@common/dependencyRe import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; import { recordRouterFactory, RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; +import { providerFactory } from './providers/getProvider'; import { getConfig } from './common/config'; export interface RegisterOptions { @@ -30,6 +31,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.LOGGER, provider: { useValue: logger } }, { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, + { token: SERVICES.PROVIDER, provider: { useFactory: providerFactory } }, { token: RECORD_ROUTER_SYMBOL, provider: { useFactory: recordRouterFactory } }, { token: 'onSignal', diff --git a/src/externalServices/catalog/interfaces.ts b/src/externalServices/catalog/interfaces.ts index 7aa1b75..0b0609b 100644 --- a/src/externalServices/catalog/interfaces.ts +++ b/src/externalServices/catalog/interfaces.ts @@ -2,7 +2,9 @@ export interface Record3D { id: string; productId?: string; productName?: string; + productType?: string; productVersion?: number; + producerName?: string; productStatus?: string; links?: string; } diff --git a/src/externalServices/jobnik/jobnikClient.ts b/src/externalServices/jobnik/jobnikClient.ts new file mode 100644 index 0000000..8315a3c --- /dev/null +++ b/src/externalServices/jobnik/jobnikClient.ts @@ -0,0 +1,105 @@ +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import type { Registry } from 'prom-client'; +import { JobnikSDK } from '@map-colonies/jobnik-sdk'; +import { SERVICES, STAGE_TYPES } from '@common/constants'; +import { is3tz } from '@common/util'; +import type { ConfigType, JobManagerConfig } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import type { IngestionPayload, JobResponse } from '../../record/models/recordManager'; +import type { Record3D } from '../catalog/interfaces'; + +interface StageDescriptor { + type: string; + data: Record; + task?: Record; + only3tz?: boolean; +} + +@injectable() +export class JobnikClient { + private readonly logContext: LogContext; + private readonly producer: ReturnType; + private readonly jobManager: JobManagerConfig; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry + ) { + this.jobManager = this.config.get('jobManager'); + const sdk = new JobnikSDK({ baseUrl: this.jobManager.url, metricsRegistry: this.metricsRegistry }); + this.producer = sdk.getProducer(); + this.logContext = { + fileName: __filename, + class: JobnikClient.name, + }; + } + + public async createIngestionJob(payload: IngestionPayload): Promise { + const logContext = { ...this.logContext, function: this.createIngestionJob.name }; + const isArchive = is3tz(payload.modelPath); + + const job = await this.producer.createJob({ + name: this.jobManager.ingestion.jobType, + data: { modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename, metadata: payload.metadata }, + }); + + const stages: StageDescriptor[] = [ + { + type: STAGE_TYPES.DATA_EXTRACTION, + data: { modelPath: payload.modelPath }, + task: { modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }, + only3tz: true, + }, + { + type: STAGE_TYPES.VALIDATION, + data: {}, + task: { modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename, metadata: payload.metadata }, + }, + { + type: STAGE_TYPES.CREATE_UPLOAD_MODEL_TASKS, + data: {}, + task: { modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }, + }, + { type: STAGE_TYPES.UPLOAD_MODEL_DATA, data: {} }, + { type: STAGE_TYPES.CLEAR_DATA, data: { modelPath: payload.modelPath }, task: { modelPath: payload.modelPath }, only3tz: true }, + { type: STAGE_TYPES.UPLOAD_MODEL_PARTS, data: {}, task: { metadata: payload.metadata } }, + { type: STAGE_TYPES.INGESTION_FINALIZER, data: { metadata: payload.metadata }, task: { metadata: payload.metadata } }, + ]; + + for (const descriptor of stages) { + if (descriptor.only3tz === true && !isArchive) { + continue; + } + const stage = await this.producer.createStage(job.id, { type: descriptor.type, data: descriptor.data }); + if (descriptor.task !== undefined) { + await this.producer.createTasks(stage.id, descriptor.type, [{ data: descriptor.task }]); + } + } + + this.logger.info({ msg: 'ingestion job created', logContext, jobId: job.id, is3tz: isArchive }); + + return { jobId: job.id, status: job.status }; + } + + public async createDeleteJob(record: Record3D): Promise { + const logContext = { ...this.logContext, function: this.createDeleteJob.name }; + + const job = await this.producer.createJob({ + name: this.jobManager.delete.jobType, + data: { + modelId: record.id, + productId: record.productId, + productVersion: record.productVersion, + productName: record.productName, + productType: record.productType, + producerName: record.producerName, + }, + }); + + this.logger.info({ msg: 'delete job created', logContext, jobId: job.id, recordId: record.id }); + + return { jobId: job.id, status: job.status }; + } +} diff --git a/src/providers/getProvider.ts b/src/providers/getProvider.ts new file mode 100644 index 0000000..26e6d30 --- /dev/null +++ b/src/providers/getProvider.ts @@ -0,0 +1,11 @@ +import type { FactoryFunction } from 'tsyringe'; +import { SERVICES } from '@common/constants'; +import type { ConfigType } from '@common/config'; +import { NFSProvider } from './nfsProvider'; +import { S3Provider } from './s3Provider'; +import type { Provider } from './interfaces'; + +export const providerFactory: FactoryFunction = (dependencyContainer) => { + const config = dependencyContainer.resolve(SERVICES.CONFIG); + return config.get('provider') === 'S3' ? dependencyContainer.resolve(S3Provider) : dependencyContainer.resolve(NFSProvider); +}; diff --git a/src/providers/interfaces.ts b/src/providers/interfaces.ts new file mode 100644 index 0000000..87ad465 --- /dev/null +++ b/src/providers/interfaces.ts @@ -0,0 +1,3 @@ +export interface Provider { + fileExists: (relativePath: string) => Promise; +} diff --git a/src/providers/nfsProvider.ts b/src/providers/nfsProvider.ts new file mode 100644 index 0000000..a4cb296 --- /dev/null +++ b/src/providers/nfsProvider.ts @@ -0,0 +1,43 @@ +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import type { ConfigType } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import type { Provider } from './interfaces'; + +@injectable() +export class NFSProvider implements Provider { + private readonly logContext: LogContext; + private readonly pvPath: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.pvPath = this.config.get('NFS').pvPath; + this.logContext = { + fileName: __filename, + class: NFSProvider.name, + }; + } + + public async fileExists(relativePath: string): Promise { + const logContext = { ...this.logContext, function: this.fileExists.name }; + const fullPath = join(this.pvPath, relativePath); + try { + await access(fullPath); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.logger.debug({ msg: `path does not exist: ${fullPath}`, logContext }); + return false; + } + this.logger.error({ msg: 'something went wrong with the NFS provider', logContext, fullPath, err }); + throw new AppError('nfs', StatusCodes.INTERNAL_SERVER_ERROR, 'there is a problem with the NFS provider', true); + } + } +} diff --git a/src/providers/s3Provider.ts b/src/providers/s3Provider.ts new file mode 100644 index 0000000..7f95301 --- /dev/null +++ b/src/providers/s3Provider.ts @@ -0,0 +1,56 @@ +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { S3Client, HeadObjectCommand, type S3ClientConfig } from '@aws-sdk/client-s3'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import type { ConfigType, S3Config } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import type { Provider } from './interfaces'; + +@injectable() +export class S3Provider implements Provider { + private readonly logContext: LogContext; + private readonly s3Config: S3Config; + private readonly s3: S3Client; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.s3Config = this.config.get('S3'); + const s3ClientConfig: S3ClientConfig = { + endpoint: this.s3Config.endpointUrl, + forcePathStyle: this.s3Config.forcePathStyle, + credentials: { + accessKeyId: this.s3Config.accessKeyId, + secretAccessKey: this.s3Config.secretAccessKey, + }, + region: this.s3Config.region, + maxAttempts: this.s3Config.maxAttempts, + tls: this.s3Config.sslEnabled, + }; + this.s3 = new S3Client(s3ClientConfig); + this.logContext = { + fileName: __filename, + class: S3Provider.name, + }; + } + + public async fileExists(key: string): Promise { + const logContext = { ...this.logContext, function: this.fileExists.name }; + try { + // eslint-disable-next-line @typescript-eslint/naming-convention + await this.s3.send(new HeadObjectCommand({ Bucket: this.s3Config.bucket, Key: key })); + return true; + } catch (err) { + const statusCode = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + if (statusCode === StatusCodes.NOT_FOUND) { + this.logger.debug({ msg: `key does not exist: ${key}`, logContext }); + return false; + } + this.logger.error({ msg: 'something went wrong with S3', logContext, key, err }); + throw new AppError('s3', StatusCodes.INTERNAL_SERVER_ERROR, 'there is a problem with S3', true); + } + } +} diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts index 84fed2a..af0b6ee 100644 --- a/src/record/controllers/recordController.ts +++ b/src/record/controllers/recordController.ts @@ -40,11 +40,11 @@ export class RecordController { } }; - public deleteRecord: TypedRequestHandlers['deleteRecord'] = (req, res, next) => { + public deleteRecord: TypedRequestHandlers['deleteRecord'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.deleteRecord.name }; const { id } = req.params; try { - const job = this.manager.deleteRecord(id); + const job = await this.manager.deleteRecord(id); return res.status(StatusCodes.OK).json(job); } catch (err) { this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId: id }); diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts index 7f907ae..37867d8 100644 --- a/src/record/models/recordManager.ts +++ b/src/record/models/recordManager.ts @@ -4,6 +4,7 @@ import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; import { ValidationManager } from '../../validator/validationManager'; +import { JobnikClient } from '../../externalServices/jobnik/jobnikClient'; export type IngestionPayload = components['schemas']['ingestionPayload']; export type UpdatePayload = components['schemas']['updatePayload']; @@ -17,7 +18,8 @@ export class RecordManager { public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(ValidationManager) private readonly validator: ValidationManager + @inject(ValidationManager) private readonly validator: ValidationManager, + @inject(JobnikClient) private readonly jobnik: JobnikClient ) { this.logContext = { fileName: __filename, @@ -28,14 +30,15 @@ export class RecordManager { public async createIngestion(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.createIngestion.name }; this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); - await this.validator.validateIngestion(payload.metadata); - return { jobId: 'stub-ingestion-job-id', status: 'PENDING' }; + await this.validator.validateIngestion(payload); + return this.jobnik.createIngestionJob(payload); } - public deleteRecord(id: string): JobResponse { + public async deleteRecord(id: string): Promise { const logContext = { ...this.logContext, function: this.deleteRecord.name }; this.logger.info({ msg: 'creating delete job', logContext, recordId: id }); - return { jobId: 'stub-delete-job-id', status: 'PENDING' }; + const record = await this.validator.validateDelete(id); + return this.jobnik.createDeleteJob(record); } public updateMetadata(id: string, update: UpdatePayload): AckResponse { diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index bd97e2d..1ce2a50 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -5,14 +5,25 @@ import { new3DLayerMetadataSchema, geometrySchema } from '@map-colonies/3d-share import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; import { AppError } from '@common/appError'; +import { buildModelFilePath } from '@common/util'; import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; import { CatalogCall } from '../externalServices/catalog/catalogCall'; +import type { Record3D } from '../externalServices/catalog/interfaces'; +import type { Provider } from '../providers/interfaces'; +import type { IngestionPayload } from '../record/models/recordManager'; + +const BLOCKED_DELETE_PRODUCT_TYPE = 'QuantizedMeshDTMBest'; +const RECORD_STATUS_UNPUBLISHED = 'UNPUBLISHED'; export const ERROR_METADATA_DATE = 'imagingTimeBeginUTC must not be later than imagingTimeEndUTC'; export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC are required'; export const ERROR_METADATA_INVALID_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC must be valid dates'; export const ERROR_METADATA_FOOTPRINT = 'Invalid footprint! Must be a GeoJSON Polygon or MultiPolygon with all-2D or all-3D coordinates'; export const ERROR_METADATA_PRODUCT_NAME_UNIQUE = 'product name is not unique!'; +export const ERROR_DELETE_RECORD_NOT_FOUND = "recordId doesn't match exactly one existing record"; +export const ERROR_DELETE_PRODUCT_TYPE = 'Cannot delete a record whose productType is "QuantizedMeshDTMBest"'; +export const ERROR_DELETE_STATUS = 'Cannot delete a record whose productStatus is not "UNPUBLISHED"'; +export const ERROR_FILE_NOT_FOUND = 'The model files do not exist in the agreed storage'; @injectable() export class ValidationManager { @@ -21,7 +32,8 @@ export class ValidationManager { public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, - @inject(CatalogCall) private readonly catalog: CatalogCall + @inject(CatalogCall) private readonly catalog: CatalogCall, + @inject(SERVICES.PROVIDER) private readonly provider: Provider ) { this.logContext = { fileName: __filename, @@ -29,10 +41,11 @@ export class ValidationManager { }; } - public async validateIngestion(metadata: Record): Promise { + public async validateIngestion(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.validateIngestion.name }; this.logger.info({ msg: 'ingestion validation start', logContext }); + const metadata = payload.metadata; const parsed = new3DLayerMetadataSchema.safeParse(metadata); if (!parsed.success) { const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); @@ -48,6 +61,37 @@ export class ValidationManager { await this.validateClassification(parsed.data.classification); await this.validateProductNameUnique(parsed.data.productName); + await this.validateFileExists(payload.modelPath, payload.tilesetFilename); + } + + public async validateDelete(recordId: string): Promise { + const logContext = { ...this.logContext, function: this.validateDelete.name }; + const records = await this.catalog.findRecords({ id: recordId }); + this.logger.debug({ msg: 'delete validation', logContext, recordId, matches: records.length }); + + const [record] = records; + if (records.length !== 1 || record === undefined) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_RECORD_NOT_FOUND, true); + } + + if (record.productType === BLOCKED_DELETE_PRODUCT_TYPE) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_PRODUCT_TYPE, true); + } + if (record.productStatus !== RECORD_STATUS_UNPUBLISHED) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_STATUS, true); + } + + return record; + } + + private async validateFileExists(modelPath: string, tilesetFilename: string): Promise { + const logContext = { ...this.logContext, function: this.validateFileExists.name }; + const path = buildModelFilePath(modelPath, tilesetFilename); + const exists = await this.provider.fileExists(path); + this.logger.debug({ msg: 'file existence validation', logContext, path, exists }); + if (!exists) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_FILE_NOT_FOUND, true); + } } private async validateProductNameUnique(productName: string): Promise { diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index 0a1ccfb..0275902 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -9,10 +9,29 @@ import { SERVICES } from '@common/constants'; import { initConfig } from '@src/common/config'; import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import { buildValidMetadata } from '@tests/helpers/metadata'; +const deletableRecord = { + id: 'rec-1', + productId: 'p-1', + productName: 'afula', + productType: '3DPhotoRealistic', + productVersion: 1, + producerName: 'IDFMU', + productStatus: 'UNPUBLISHED', +}; + const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; -const catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; +// findRecords by id (delete) → an existing deletable record; by productName (uniqueness) → none +const catalogStub = { + findRecords: vi.fn().mockImplementation((payload: { id?: string }) => (payload.id !== undefined ? [deletableRecord] : [])), +} as unknown as CatalogCall; +const jobnikStub = { + createIngestionJob: vi.fn().mockResolvedValue({ jobId: 'job-1', status: 'PENDING' }), + createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), +} as unknown as JobnikClient; +const providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; const validMetadata = buildValidMetadata(); @@ -36,6 +55,8 @@ describe('record', function () { { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, { token: LookupTablesCall, provider: { useValue: lookupStub } }, { token: CatalogCall, provider: { useValue: catalogStub } }, + { token: JobnikClient, provider: { useValue: jobnikStub } }, + { token: SERVICES.PROVIDER, provider: { useValue: providerStub } }, ], useChild: true, }); diff --git a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts new file mode 100644 index 0000000..c34ae47 --- /dev/null +++ b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { Registry } from 'prom-client'; +import { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; +import { STAGE_TYPES } from '@src/common/constants'; +import type { ConfigType } from '@src/common/config'; +import type { IngestionPayload } from '@src/record/models/recordManager'; + +const jobManagerConfig = { + url: 'http://job-manager', + ingestion: { jobType: 'Ingestion_New_3D', taskType: 'tilesCopying', batches: 100 }, + delete: { jobType: 'Delete_3D', taskType: 'deleteModel' }, +}; + +const producerMock = vi.hoisted(() => ({ + createJob: vi.fn(), + createStage: vi.fn(), + createTasks: vi.fn(), +})); + +vi.mock('@map-colonies/jobnik-sdk', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + JobnikSDK: class { + public getProducer(): typeof producerMock { + return producerMock; + } + }, +})); + +const configStub = { get: (): unknown => jobManagerConfig } as unknown as ConfigType; + +const buildPayload = (modelPath: string): IngestionPayload => ({ + modelPath, + tilesetFilename: 'tileset.json', + metadata: { productName: 'afula' }, +}); + +describe('JobnikClient', function () { + let client: JobnikClient; + + beforeEach(async function () { + vi.clearAllMocks(); + producerMock.createJob.mockResolvedValue({ id: 'job-1', status: 'PENDING' }); + producerMock.createStage.mockResolvedValue({ id: 'stage-1' }); + producerMock.createTasks.mockResolvedValue([]); + client = new JobnikClient(configStub, await jsLogger({ enabled: false }), new Registry()); + }); + + it('should create the ingestion job and a validation stage for a folder input', async function () { + const result = await client.createIngestionJob(buildPayload('/shared/models/afula')); + + expect(producerMock.createJob).toHaveBeenCalledWith(expect.objectContaining({ name: jobManagerConfig.ingestion.jobType })); + const folderStageTypes = (producerMock.createStage.mock.calls as [string, { type: string }][]).map((call) => call[1].type); + expect(folderStageTypes).toEqual([ + STAGE_TYPES.VALIDATION, + STAGE_TYPES.CREATE_UPLOAD_MODEL_TASKS, + STAGE_TYPES.UPLOAD_MODEL_DATA, + STAGE_TYPES.UPLOAD_MODEL_PARTS, + STAGE_TYPES.INGESTION_FINALIZER, + ]); + const validationTaskCall = (producerMock.createTasks.mock.calls as [string, string, { data: { metadata?: unknown } }[]][]).find( + (call) => call[1] === STAGE_TYPES.VALIDATION + ); + expect(validationTaskCall?.[2][0]?.data.metadata).toEqual({ productName: 'afula' }); + expect(result).toEqual({ jobId: 'job-1', status: 'PENDING' }); + }); + + it('should also create the data-extraction and clear-data stages for a 3tz input', async function () { + await client.createIngestionJob(buildPayload('/shared/models/afula.3tz')); + + const stageTypes = (producerMock.createStage.mock.calls as [string, { type: string }][]).map((call) => call[1].type); + expect(stageTypes).toEqual([ + STAGE_TYPES.DATA_EXTRACTION, + STAGE_TYPES.VALIDATION, + STAGE_TYPES.CREATE_UPLOAD_MODEL_TASKS, + STAGE_TYPES.UPLOAD_MODEL_DATA, + STAGE_TYPES.CLEAR_DATA, + STAGE_TYPES.UPLOAD_MODEL_PARTS, + STAGE_TYPES.INGESTION_FINALIZER, + ]); + }); + + it('should create a delete job with the record details', async function () { + const result = await client.createDeleteJob({ id: 'rec-1', productId: 'p-1', productName: 'afula', productType: '3DPhotoRealistic' }); + + expect(producerMock.createJob).toHaveBeenCalledWith(expect.objectContaining({ name: jobManagerConfig.delete.jobType })); + expect(result).toEqual({ jobId: 'job-1', status: 'PENDING' }); + }); +}); diff --git a/tests/unit/providers/nfsProvider.spec.ts b/tests/unit/providers/nfsProvider.spec.ts new file mode 100644 index 0000000..a37add4 --- /dev/null +++ b/tests/unit/providers/nfsProvider.spec.ts @@ -0,0 +1,38 @@ +import { access } from 'node:fs/promises'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { NFSProvider } from '@src/providers/nfsProvider'; +import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; + +vi.mock('node:fs/promises', () => ({ access: vi.fn() })); +const mockedAccess = vi.mocked(access); + +const configStub = { get: (): unknown => ({ pvPath: '/data/models' }) } as unknown as ConfigType; + +describe('NFSProvider', function () { + let provider: NFSProvider; + + beforeEach(async function () { + vi.clearAllMocks(); + provider = new NFSProvider(configStub, await jsLogger({ enabled: false })); + }); + + it('should return true when the path is accessible', async function () { + mockedAccess.mockResolvedValue(undefined); + + await expect(provider.fileExists('afula/tileset.json')).resolves.toBe(true); + }); + + it('should return false when the path does not exist (ENOENT)', async function () { + mockedAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + + await expect(provider.fileExists('missing/tileset.json')).resolves.toBe(false); + }); + + it('should throw an AppError on non-ENOENT errors', async function () { + mockedAccess.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + + await expect(provider.fileExists('locked/tileset.json')).rejects.toThrow(AppError); + }); +}); diff --git a/tests/unit/providers/s3Provider.spec.ts b/tests/unit/providers/s3Provider.spec.ts new file mode 100644 index 0000000..4e0f1e9 --- /dev/null +++ b/tests/unit/providers/s3Provider.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { S3Provider } from '@src/providers/s3Provider'; +import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; + +const sendMock = vi.hoisted(() => vi.fn()); + +vi.mock('@aws-sdk/client-s3', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + S3Client: class { + public send = sendMock; + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + HeadObjectCommand: class { + public constructor(public readonly input: unknown) {} + }, +})); + +const configStub = { + get: (): unknown => ({ + accessKeyId: 'k', + secretAccessKey: 's', + endpointUrl: 'http://s3', + bucket: 'b', + region: 'us-east-1', + forcePathStyle: true, + sslEnabled: false, + maxAttempts: 3, + }), +} as unknown as ConfigType; + +describe('S3Provider', function () { + let provider: S3Provider; + + beforeEach(async function () { + vi.clearAllMocks(); + provider = new S3Provider(configStub, await jsLogger({ enabled: false })); + }); + + it('should return true when the object exists', async function () { + sendMock.mockResolvedValue({}); + + await expect(provider.fileExists('afula/tileset.json')).resolves.toBe(true); + }); + + it('should return false when the object is not found (404)', async function () { + sendMock.mockRejectedValue({ $metadata: { httpStatusCode: StatusCodes.NOT_FOUND } }); + + await expect(provider.fileExists('missing')).resolves.toBe(false); + }); + + it('should throw an AppError on other S3 errors', async function () { + sendMock.mockRejectedValue({ $metadata: { httpStatusCode: StatusCodes.INTERNAL_SERVER_ERROR } }); + + await expect(provider.fileExists('boom')).rejects.toThrow(AppError); + }); +}); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts index fde0ed2..403dc42 100644 --- a/tests/unit/record/models/recordManager.spec.ts +++ b/tests/unit/record/models/recordManager.spec.ts @@ -2,14 +2,22 @@ import { jsLogger } from '@map-colonies/js-logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; import type { ValidationManager } from '@src/validator/validationManager'; +import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; -const noopValidator = { validateIngestion: vi.fn().mockResolvedValue(undefined) } as unknown as ValidationManager; +const noopValidator = { + validateIngestion: vi.fn().mockResolvedValue(undefined), + validateDelete: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), +} as unknown as ValidationManager; +const jobnikStub = { + createIngestionJob: vi.fn().mockResolvedValue({ jobId: 'job-1', status: 'PENDING' }), + createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), +} as unknown as JobnikClient; describe('RecordManager', function () { let manager: RecordManager; beforeEach(async function () { - manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator); + manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator, jobnikStub); }); describe('createIngestion', function () { @@ -28,8 +36,8 @@ describe('RecordManager', function () { }); describe('deleteRecord', function () { - it('should return a job response', function () { - const result = manager.deleteRecord('rec-1'); + it('should return a job response', async function () { + const result = await manager.deleteRecord('rec-1'); expect(result.jobId).toBeTypeOf('string'); expect(result.status).toBeTypeOf('string'); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index c2638a8..c9231fc 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -7,40 +7,54 @@ import { ERROR_METADATA_FOOTPRINT, ERROR_METADATA_MISSING_DATE, ERROR_METADATA_INVALID_DATE, + ERROR_DELETE_RECORD_NOT_FOUND, + ERROR_DELETE_PRODUCT_TYPE, + ERROR_DELETE_STATUS, + ERROR_FILE_NOT_FOUND, } from '@src/validator/validationManager'; import { AppError } from '@src/common/appError'; import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; -import { buildValidMetadata as validMetadata } from '@tests/helpers/metadata'; +import type { Provider } from '@src/providers/interfaces'; +import type { IngestionPayload } from '@src/record/models/recordManager'; +import { buildValidMetadata } from '@tests/helpers/metadata'; const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; +const ingest = (metadata: Record): IngestionPayload => ({ + modelPath: '/shared/models/afula', + tilesetFilename: 'tileset.json', + metadata, +}); + describe('ValidationManager', function () { let validator: ValidationManager; let catalogStub: CatalogCall; + let providerStub: Provider; beforeEach(async function () { catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; - validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub); + providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; + validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub, providerStub); }); - it('should pass a fully valid metadata object', async function () { - await expect(validator.validateIngestion(validMetadata())).resolves.toBeUndefined(); + it('should pass a fully valid ingestion payload', async function () { + await expect(validator.validateIngestion(ingest(buildValidMetadata()))).resolves.toBeUndefined(); }); it('should throw 400 when a required core field is missing', async function () { - const metadata = validMetadata(); + const metadata = buildValidMetadata(); delete metadata.productName; - await expect(validator.validateIngestion(metadata)).rejects.toThrow(AppError); + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(AppError); }); it('should throw 400 for an invalid productType', async function () { - const metadata = { ...validMetadata(), productType: 'NOT_A_3D_TYPE' }; + const metadata = { ...buildValidMetadata(), productType: 'NOT_A_3D_TYPE' }; let thrown: unknown; try { - await validator.validateIngestion(metadata); + await validator.validateIngestion(ingest(metadata)); } catch (err) { thrown = err; } @@ -50,39 +64,76 @@ describe('ValidationManager', function () { }); it('should throw the footprint error for a malformed geometry', async function () { - const metadata = { ...validMetadata(), footprint: { type: 'Point', coordinates: [34.45, 31.48] } }; + const metadata = { ...buildValidMetadata(), footprint: { type: 'Point', coordinates: [34.45, 31.48] } }; - await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_FOOTPRINT); + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(ERROR_METADATA_FOOTPRINT); }); it('should throw the date error when imagingTimeBeginUTC is after imagingTimeEndUTC', async function () { - const metadata = { ...validMetadata(), imagingTimeBeginUTC: '2025-07-11T00:00:00.000Z' }; + const metadata = { ...buildValidMetadata(), imagingTimeBeginUTC: '2025-07-11T00:00:00.000Z' }; - await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_DATE); + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(ERROR_METADATA_DATE); }); it('should throw the missing-date error when a source date is absent', async function () { - const metadata = validMetadata(); + const metadata = buildValidMetadata(); delete metadata.imagingTimeEndUTC; - await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_MISSING_DATE); + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(ERROR_METADATA_MISSING_DATE); }); it('should throw the invalid-date error for an unparseable date', async function () { - const metadata = { ...validMetadata(), imagingTimeBeginUTC: 'not-a-date' }; + const metadata = { ...buildValidMetadata(), imagingTimeBeginUTC: 'not-a-date' }; - await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_INVALID_DATE); + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(ERROR_METADATA_INVALID_DATE); }); it('should throw 400 when the classification is not in the lookup table', async function () { - const metadata = { ...validMetadata(), classification: 'not-a-real-classification' }; + const metadata = { ...buildValidMetadata(), classification: 'not-a-real-classification' }; - await expect(validator.validateIngestion(metadata)).rejects.toThrow(AppError); + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(AppError); }); it('should throw 400 when the product name already exists in the catalog', async function () { (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([{ id: 'existing', productName: 'afula' }]); - await expect(validator.validateIngestion(validMetadata())).rejects.toThrow(AppError); + await expect(validator.validateIngestion(ingest(buildValidMetadata()))).rejects.toThrow(AppError); + }); + + it('should throw 400 when the model files do not exist', async function () { + (providerStub.fileExists as ReturnType).mockResolvedValueOnce(false); + + await expect(validator.validateIngestion(ingest(buildValidMetadata()))).rejects.toThrow(ERROR_FILE_NOT_FOUND); + }); + + describe('validateDelete', function () { + it('should return the record when it is deletable', async function () { + const record = { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'UNPUBLISHED' }; + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([record]); + + await expect(validator.validateDelete('rec-1')).resolves.toEqual(record); + }); + + it('should throw when the record is not found', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([]); + + await expect(validator.validateDelete('missing')).rejects.toThrow(ERROR_DELETE_RECORD_NOT_FOUND); + }); + + it('should throw when the productType is blocked', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([ + { id: 'rec-1', productType: 'QuantizedMeshDTMBest', productStatus: 'UNPUBLISHED' }, + ]); + + await expect(validator.validateDelete('rec-1')).rejects.toThrow(ERROR_DELETE_PRODUCT_TYPE); + }); + + it('should throw when the record is not unpublished', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([ + { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'PUBLISHED' }, + ]); + + await expect(validator.validateDelete('rec-1')).rejects.toThrow(ERROR_DELETE_STATUS); + }); }); }); From 104fb380677585928f6e93b6b3a25bb4ec745c85 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Tue, 29 Sep 2026 18:36:12 +0300 Subject: [PATCH 05/14] feat: reject duplicate in-flight ingestion jobs (MAPCO-11589) Add ValidationManager check that rejects an ingestion when an in-flight Jobnik ingestion job already exists for the same productName, complementing the catalog unique-name check (which only sees finished records). JobnikClient.hasInFlightIngestionJob queries GET /v1/jobs by job name and filters client-side on non-terminal status + data.metadata.productName, paginating through all pages. Query failures raise AppError. JobnikClient is now a singleton so RecordManager and ValidationManager share one SDK instance instead of double-registering its metrics. Interim until Jobnik ships native duplicate support (epic MAPCO-11833). --- src/common/constants.ts | 3 + src/externalServices/jobnik/jobnikClient.ts | 39 +++++++++- src/validator/validationManager.ts | 14 ++++ tests/integration/record/record.spec.ts | 1 + .../jobnik/jobnikClient.spec.ts | 76 +++++++++++++++++++ .../unit/validator/validationManager.spec.ts | 21 ++++- 6 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index 492751a..9715684 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,4 +1,5 @@ import { readPackageJsonSync } from '@map-colonies/read-pkg'; +import type { Job } from '@map-colonies/jobnik-sdk'; export const SERVICE_NAME = readPackageJsonSync().name ?? 'unknown_service'; export const DEFAULT_SERVER_PORT = 80; @@ -20,6 +21,8 @@ export const STAGE_TYPES = { } satisfies Record; /* eslint-enable @typescript-eslint/naming-convention */ +export const IN_FLIGHT_JOB_STATUSES: readonly Job['status'][] = ['PENDING', 'IN_PROGRESS', 'PAUSED', 'CREATED']; + /* eslint-disable @typescript-eslint/naming-convention */ export const SERVICES = { LOGGER: Symbol('Logger'), diff --git a/src/externalServices/jobnik/jobnikClient.ts b/src/externalServices/jobnik/jobnikClient.ts index 8315a3c..593ce4e 100644 --- a/src/externalServices/jobnik/jobnikClient.ts +++ b/src/externalServices/jobnik/jobnikClient.ts @@ -1,9 +1,11 @@ -import { inject, injectable } from 'tsyringe'; +import { inject, singleton } from 'tsyringe'; +import { StatusCodes } from 'http-status-codes'; import type { Logger } from '@map-colonies/js-logger'; import type { Registry } from 'prom-client'; import { JobnikSDK } from '@map-colonies/jobnik-sdk'; -import { SERVICES, STAGE_TYPES } from '@common/constants'; +import { IN_FLIGHT_JOB_STATUSES, SERVICES, STAGE_TYPES } from '@common/constants'; import { is3tz } from '@common/util'; +import { AppError } from '@common/appError'; import type { ConfigType, JobManagerConfig } from '@common/config'; import type { LogContext } from '@common/interfaces'; import type { IngestionPayload, JobResponse } from '../../record/models/recordManager'; @@ -16,10 +18,11 @@ interface StageDescriptor { only3tz?: boolean; } -@injectable() +@singleton() export class JobnikClient { private readonly logContext: LogContext; private readonly producer: ReturnType; + private readonly apiClient: ReturnType; private readonly jobManager: JobManagerConfig; public constructor( @@ -30,12 +33,42 @@ export class JobnikClient { this.jobManager = this.config.get('jobManager'); const sdk = new JobnikSDK({ baseUrl: this.jobManager.url, metricsRegistry: this.metricsRegistry }); this.producer = sdk.getProducer(); + this.apiClient = sdk.getApiClient(); this.logContext = { fileName: __filename, class: JobnikClient.name, }; } + public async hasInFlightIngestionJob(productName: string): Promise { + const logContext = { ...this.logContext, function: this.hasInFlightIngestionJob.name }; + const pageSize = 100; + + for (let page = 1; ; page++) { + const { data, error } = await this.apiClient.GET('/v1/jobs', { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Jobnik query params are snake_case + params: { query: { job_name: this.jobManager.ingestion.jobType, page, page_size: pageSize } }, + }); + if (error !== undefined) { + this.logger.error({ msg: 'failed querying Jobnik for in-flight jobs', logContext, productName, err: error }); + throw new AppError('jobnik', StatusCodes.INTERNAL_SERVER_ERROR, 'failed querying Jobnik for in-flight jobs', false); + } + + const jobs = data.items; + const match = jobs.some((job) => { + const metadata = (job.data as { metadata?: { productName?: string } }).metadata; + return IN_FLIGHT_JOB_STATUSES.includes(job.status) && metadata?.productName === productName; + }); + if (match) { + this.logger.debug({ msg: 'in-flight ingestion job found', logContext, productName }); + return true; + } + if (jobs.length < pageSize) { + return false; + } + } + } + public async createIngestionJob(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.createIngestionJob.name }; const isArchive = is3tz(payload.modelPath); diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 1ce2a50..07edbf0 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -8,6 +8,7 @@ import { AppError } from '@common/appError'; import { buildModelFilePath } from '@common/util'; import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; import { CatalogCall } from '../externalServices/catalog/catalogCall'; +import { JobnikClient } from '../externalServices/jobnik/jobnikClient'; import type { Record3D } from '../externalServices/catalog/interfaces'; import type { Provider } from '../providers/interfaces'; import type { IngestionPayload } from '../record/models/recordManager'; @@ -20,6 +21,7 @@ export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeE export const ERROR_METADATA_INVALID_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC must be valid dates'; export const ERROR_METADATA_FOOTPRINT = 'Invalid footprint! Must be a GeoJSON Polygon or MultiPolygon with all-2D or all-3D coordinates'; export const ERROR_METADATA_PRODUCT_NAME_UNIQUE = 'product name is not unique!'; +export const ERROR_PRODUCT_NAME_IN_FLIGHT = 'product name already has an in-flight ingestion job'; export const ERROR_DELETE_RECORD_NOT_FOUND = "recordId doesn't match exactly one existing record"; export const ERROR_DELETE_PRODUCT_TYPE = 'Cannot delete a record whose productType is "QuantizedMeshDTMBest"'; export const ERROR_DELETE_STATUS = 'Cannot delete a record whose productStatus is not "UNPUBLISHED"'; @@ -33,6 +35,7 @@ export class ValidationManager { @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, @inject(CatalogCall) private readonly catalog: CatalogCall, + @inject(JobnikClient) private readonly jobnik: JobnikClient, @inject(SERVICES.PROVIDER) private readonly provider: Provider ) { this.logContext = { @@ -61,6 +64,7 @@ export class ValidationManager { await this.validateClassification(parsed.data.classification); await this.validateProductNameUnique(parsed.data.productName); + await this.validateProductNameNotInFlight(parsed.data.productName); await this.validateFileExists(payload.modelPath, payload.tilesetFilename); } @@ -104,6 +108,16 @@ export class ValidationManager { } } + private async validateProductNameNotInFlight(productName: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductNameNotInFlight.name }; + const inFlight = await this.jobnik.hasInFlightIngestionJob(productName); + this.logger.debug({ msg: 'in-flight duplicate validation', logContext, productName, inFlight }); + + if (inFlight) { + throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_PRODUCT_NAME_IN_FLIGHT, true); + } + } + private validateDates(start: unknown, end: unknown): void { if (start === undefined || end === undefined) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_MISSING_DATE, true); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index 0275902..f640d2c 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -30,6 +30,7 @@ const catalogStub = { const jobnikStub = { createIngestionJob: vi.fn().mockResolvedValue({ jobId: 'job-1', status: 'PENDING' }), createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), + hasInFlightIngestionJob: vi.fn().mockResolvedValue(false), } as unknown as JobnikClient; const providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; diff --git a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts index c34ae47..395cc7e 100644 --- a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts +++ b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts @@ -18,12 +18,20 @@ const producerMock = vi.hoisted(() => ({ createTasks: vi.fn(), })); +const apiClientMock = vi.hoisted(() => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- mirrors the SDK ApiClient.GET method + GET: vi.fn(), +})); + vi.mock('@map-colonies/jobnik-sdk', () => ({ // eslint-disable-next-line @typescript-eslint/naming-convention JobnikSDK: class { public getProducer(): typeof producerMock { return producerMock; } + public getApiClient(): typeof apiClientMock { + return apiClientMock; + } }, })); @@ -43,6 +51,7 @@ describe('JobnikClient', function () { producerMock.createJob.mockResolvedValue({ id: 'job-1', status: 'PENDING' }); producerMock.createStage.mockResolvedValue({ id: 'stage-1' }); producerMock.createTasks.mockResolvedValue([]); + apiClientMock.GET.mockResolvedValue({ data: { total: 0, items: [] } }); client = new JobnikClient(configStub, await jsLogger({ enabled: false }), new Registry()); }); @@ -50,7 +59,9 @@ describe('JobnikClient', function () { const result = await client.createIngestionJob(buildPayload('/shared/models/afula')); expect(producerMock.createJob).toHaveBeenCalledWith(expect.objectContaining({ name: jobManagerConfig.ingestion.jobType })); + const folderStageTypes = (producerMock.createStage.mock.calls as [string, { type: string }][]).map((call) => call[1].type); + expect(folderStageTypes).toEqual([ STAGE_TYPES.VALIDATION, STAGE_TYPES.CREATE_UPLOAD_MODEL_TASKS, @@ -58,9 +69,11 @@ describe('JobnikClient', function () { STAGE_TYPES.UPLOAD_MODEL_PARTS, STAGE_TYPES.INGESTION_FINALIZER, ]); + const validationTaskCall = (producerMock.createTasks.mock.calls as [string, string, { data: { metadata?: unknown } }[]][]).find( (call) => call[1] === STAGE_TYPES.VALIDATION ); + expect(validationTaskCall?.[2][0]?.data.metadata).toEqual({ productName: 'afula' }); expect(result).toEqual({ jobId: 'job-1', status: 'PENDING' }); }); @@ -69,6 +82,7 @@ describe('JobnikClient', function () { await client.createIngestionJob(buildPayload('/shared/models/afula.3tz')); const stageTypes = (producerMock.createStage.mock.calls as [string, { type: string }][]).map((call) => call[1].type); + expect(stageTypes).toEqual([ STAGE_TYPES.DATA_EXTRACTION, STAGE_TYPES.VALIDATION, @@ -86,4 +100,66 @@ describe('JobnikClient', function () { expect(producerMock.createJob).toHaveBeenCalledWith(expect.objectContaining({ name: jobManagerConfig.delete.jobType })); expect(result).toEqual({ jobId: 'job-1', status: 'PENDING' }); }); + + describe('hasInFlightIngestionJob', function () { + it('should query jobs by the ingestion job name', async function () { + await client.hasInFlightIngestionJob('afula'); + + expect(apiClientMock.GET).toHaveBeenCalledWith('/v1/jobs', { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Jobnik query params are snake_case + params: { query: { job_name: jobManagerConfig.ingestion.jobType, page: 1, page_size: 100 } }, + }); + }); + + it('should return true when a non-terminal job matches the product name', async function () { + apiClientMock.GET.mockResolvedValueOnce({ + data: { total: 1, items: [{ status: 'IN_PROGRESS', data: { metadata: { productName: 'afula' } } }] }, + }); + + await expect(client.hasInFlightIngestionJob('afula')).resolves.toBe(true); + }); + + it('should return false when the only matching job is in a terminal status', async function () { + apiClientMock.GET.mockResolvedValueOnce({ + data: { total: 1, items: [{ status: 'COMPLETED', data: { metadata: { productName: 'afula' } } }] }, + }); + + await expect(client.hasInFlightIngestionJob('afula')).resolves.toBe(false); + }); + + it('should return false when no in-flight job matches the product name', async function () { + apiClientMock.GET.mockResolvedValueOnce({ + data: { total: 1, items: [{ status: 'PENDING', data: { metadata: { productName: 'haifa' } } }] }, + }); + + await expect(client.hasInFlightIngestionJob('afula')).resolves.toBe(false); + }); + + it('should page through results and match a job on a later page', async function () { + const fullPage = Array.from({ length: 100 }, () => ({ status: 'IN_PROGRESS', data: { metadata: { productName: 'haifa' } } })); + apiClientMock.GET.mockResolvedValueOnce({ data: { total: 101, items: fullPage } }); + apiClientMock.GET.mockResolvedValueOnce({ + data: { total: 101, items: [{ status: 'PENDING', data: { metadata: { productName: 'afula' } } }] }, + }); + + await expect(client.hasInFlightIngestionJob('afula')).resolves.toBe(true); + expect(apiClientMock.GET).toHaveBeenNthCalledWith(2, '/v1/jobs', { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Jobnik query params are snake_case + params: { query: { job_name: jobManagerConfig.ingestion.jobType, page: 2, page_size: 100 } }, + }); + }); + + it('should stop paging once a page is shorter than the page size', async function () { + apiClientMock.GET.mockResolvedValueOnce({ data: { total: 1, items: [{ status: 'PENDING', data: { metadata: { productName: 'haifa' } } }] } }); + + await expect(client.hasInFlightIngestionJob('afula')).resolves.toBe(false); + expect(apiClientMock.GET).toHaveBeenCalledTimes(1); + }); + + it('should throw an AppError when the jobs query returns an error', async function () { + apiClientMock.GET.mockResolvedValueOnce({ error: { message: 'boom' } }); + + await expect(client.hasInFlightIngestionJob('afula')).rejects.toThrow(/failed querying Jobnik/); + }); + }); }); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index c9231fc..d88ae0c 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -11,10 +11,12 @@ import { ERROR_DELETE_PRODUCT_TYPE, ERROR_DELETE_STATUS, ERROR_FILE_NOT_FOUND, + ERROR_PRODUCT_NAME_IN_FLIGHT, } from '@src/validator/validationManager'; import { AppError } from '@src/common/appError'; import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import type { Provider } from '@src/providers/interfaces'; import type { IngestionPayload } from '@src/record/models/recordManager'; import { buildValidMetadata } from '@tests/helpers/metadata'; @@ -30,12 +32,14 @@ const ingest = (metadata: Record): IngestionPayload => ({ describe('ValidationManager', function () { let validator: ValidationManager; let catalogStub: CatalogCall; + let jobnikStub: JobnikClient; let providerStub: Provider; beforeEach(async function () { catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; + jobnikStub = { hasInFlightIngestionJob: vi.fn().mockResolvedValue(false) } as unknown as JobnikClient; providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; - validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub, providerStub); + validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub, jobnikStub, providerStub); }); it('should pass a fully valid ingestion payload', async function () { @@ -100,6 +104,21 @@ describe('ValidationManager', function () { await expect(validator.validateIngestion(ingest(buildValidMetadata()))).rejects.toThrow(AppError); }); + it('should throw 409 when the product name has an in-flight ingestion job', async function () { + (jobnikStub.hasInFlightIngestionJob as ReturnType).mockResolvedValueOnce(true); + + let thrown: unknown; + try { + await validator.validateIngestion(ingest(buildValidMetadata())); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.CONFLICT); + expect((thrown as AppError).message).toBe(ERROR_PRODUCT_NAME_IN_FLIGHT); + }); + it('should throw 400 when the model files do not exist', async function () { (providerStub.fileExists as ReturnType).mockResolvedValueOnce(false); From 660434a6f8675771ac83f9956cc4d4267daf3979 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 10:31:12 +0300 Subject: [PATCH 06/14] feat: extract tileset.json from 3tz and validate footprint (MAPCO-11837) Add tileset validation to the ingestion flow for both 3tz and folder inputs: read tileset.json, extract the model polygon from its bounding volume, and require the footprint to intersect and cover the model above a configurable threshold. TilesetReader reads tileset.json without a zip dependency: for a 3tz it reads the first local file entry directly via a file handle (real 3tz archives break yauzl's central-directory parsing), guarding against data-descriptor/ZIP64 entries whose size is absent from the local header; for a folder it reads the file from disk. Polygon extraction (region/sphere bounding volumes) lives in @map-colonies/3d-shared. Coverage is checked with turf against validation.percentageLimit (default 10), wired through schema, config, custom-environment-variables and helm. Unit and integration tests included. --- config/custom-environment-variables.json | 6 + config/default.json | 3 + helm/templates/configmap.yaml | 1 + helm/values.yaml | 3 + package-lock.json | 2982 +++++++++++++++++ package.json | 1 + src/common/config.ts | 16 +- src/tileset/tilesetReader.ts | 108 + src/validator/validationManager.ts | 58 +- tests/helpers/metadata.ts | 8 +- tests/helpers/tilesets/folder/tileset.json | 35 + tests/helpers/tilesets/region.3tz | Bin 0 -> 1362 bytes tests/integration/record/record.spec.ts | 7 + tests/unit/tileset/tilesetReader.spec.ts | 77 + .../unit/validator/validationManager.spec.ts | 53 +- 15 files changed, 3351 insertions(+), 7 deletions(-) create mode 100644 src/tileset/tilesetReader.ts create mode 100644 tests/helpers/tilesets/folder/tileset.json create mode 100644 tests/helpers/tilesets/region.3tz create mode 100644 tests/unit/tileset/tilesetReader.spec.ts diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 6c1060f..30bd2cd 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -21,6 +21,12 @@ "taskType": "TASK_DELETE_TYPE" } }, + "validation": { + "percentageLimit": { + "__name": "VALIDATION_PERCENTAGE_LIMIT", + "__format": "number" + } + }, "provider": "PROVIDER_FROM", "NFS": { "pvPath": "PV_SOURCE_PATH" diff --git a/config/default.json b/config/default.json index 319977f..8de50f9 100644 --- a/config/default.json +++ b/config/default.json @@ -52,6 +52,9 @@ "taskType": "deleteModel" } }, + "validation": { + "percentageLimit": 10 + }, "provider": "NFS", "NFS": { "pvPath": "/app/models" diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index f150bf7..9b572c3 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -45,6 +45,7 @@ data: JOB_DELETE_TYPE: {{ .delete.jobType | quote }} TASK_DELETE_TYPE: {{ .delete.taskType | quote }} {{- end }} + VALIDATION_PERCENTAGE_LIMIT: {{ .Values.validation.percentageLimit | quote }} PROVIDER_FROM: {{ .Values.provider | quote }} {{- if eq .Values.provider "NFS" }} PV_SOURCE_PATH: {{ .Values.NFS.pvPath | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index c3cea6d..e08d90b 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -94,6 +94,9 @@ jobManager: jobType: '' taskType: '' +validation: + percentageLimit: 10 + provider: 'NFS' NFS: pvPath: '' diff --git a/package-lock.json b/package-lock.json index f23dc6e..08ceef5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", "@opentelemetry/api": "^1.9.1", + "@turf/turf": "^7.4.0", "axios": "^1.20.0", "compression": "^1.8.0", "express": "^4.21.2", @@ -76,8 +77,10 @@ "license": "ISC", "dependencies": { "@map-colonies/types": "^1.4.0", + "@turf/turf": "^7.4.0", "geojson": "^0.5.0", "mime-types": "^2.1.35", + "proj4": "^2.22.0", "zod": "^3.24.1" }, "devDependencies": { @@ -90,6 +93,7 @@ "@types/geojson": "^7946.0.14", "@types/jest": "^29.4.0", "@types/node": "^20.0.0", + "@types/proj4": "^2.5.6", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "commitlint": "^19.6.1", @@ -4759,6 +4763,2754 @@ "dev": true, "license": "MIT" }, + "node_modules/@turf/along": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.4.0.tgz", + "integrity": "sha512-uWh26sph1bJm3eO1toaoq9fbPRNxtbQlW/u0m04n7m6uFyGrUu4qE+8DTDQeF+cHGWbMQft4zshHZy0rz4Nyog==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.4.0", + "@turf/destination": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/along/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/angle": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/angle/-/angle-7.4.0.tgz", + "integrity": "sha512-QcylOejONtVUQVG25/GznC2URvYLta6HCDLPB12wT/AEsG3pEKvUh5RGJDcFwk8WKfKPRp9C24kzsbp1Nwp9XQ==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/rhumb-bearing": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/angle/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/area": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.4.0.tgz", + "integrity": "sha512-B7q5f6QwKIxxbl4/L59lAYA+FF7h2hv6st96PuKue0TGCYGSxAG/lV8LHSPUSOuOrLywN4jytJcKK3vOFBs7Nw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/area/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/bbox": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.4.0.tgz", + "integrity": "sha512-yOX9lALc2GmjYUIE99+nRj7eZA7eDtx7Ixu/hrgIo+YqHvL1reaPVTC9rQWMHMm0pH0/WHjzk8uOpAHjptrudQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox-clip": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/bbox-clip/-/bbox-clip-7.4.0.tgz", + "integrity": "sha512-Vxi8XznT/vhLHfqE9C1IMw0wi73+6EP3Bn51Vm0v9AWLUEv1zKPqR9/a4cj+s7f3diPE1g238rS48+BB5nYKSQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox-clip/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/bbox-polygon": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-7.4.0.tgz", + "integrity": "sha512-hF0ylAOhzwk9i7m/PCmTtg+TP5AnFb9B+bMXgEPIYd/C3BOVet1YaA8nF6ViN5bNF0GSkWFYivvclSgWbtZyqg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox-polygon/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/bbox/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/bearing": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-7.4.0.tgz", + "integrity": "sha512-utMyjTU5U3QfHWLRIKUAvJerCMmC6NLI7X6cmL6PA5+KYdzul/ymxL6CQrwJugrf9zt4Hnzvipwz0j5H1Yo87w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bearing/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/bezier-spline": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/bezier-spline/-/bezier-spline-7.4.0.tgz", + "integrity": "sha512-ctzTB46Mo5umz658IFh2C9zB8fs/FtqDOjcozAcUm7TUs6h4hdrXsCMRLb4a9NIX7ekPB84xQ6ZrISXwI+6/yQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bezier-spline/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-clockwise": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-7.4.0.tgz", + "integrity": "sha512-3wO+W9P63JZMK1Ud5ER0kKXpiZaeXkOR5Omq5USKlPcopwZ6TP/mJjt8lUYYWsB2WGDXuUhlYmSVec43z3wwow==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-clockwise/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-concave": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-concave/-/boolean-concave-7.4.0.tgz", + "integrity": "sha512-uSu01JxLsivXIyT8R7fN7ujyjyP0A8qTLaC/YlA2FSKDjpW1876KbWua203R9JxqZ4hgNUPCE7dhk9uTRXuZIg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-concave/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-contains": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-7.4.0.tgz", + "integrity": "sha512-vR8sfdKhSlDy6Jd6UAI2Bt27ZAU287G8lhLieIGfxKCpoUa2V8v3y0I9FH+ZpFen07I33aysHIZ4dMH92hBO3Q==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/boolean-point-on-line": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-split": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-contains/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-crosses": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-crosses/-/boolean-crosses-7.4.0.tgz", + "integrity": "sha512-athvZ8yB04BcuT53plyRJnn4jMmbiwlGk/pdxj28JZtsKipi+Ti6xmWDklFnrXpGfeaSHpPvKVt7XRduTB/qVA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-equal": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-intersect": "7.4.0", + "@turf/polygon-to-line": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-crosses/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-disjoint": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-7.4.0.tgz", + "integrity": "sha512-B6nXGFHa1+m181BZ5MOKa1l7/otws/NonUpOd0CKWTuBUzrlc6e60z/kRp5ceRYmE2LFOH5ZZxItnrsWgKc2VA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/line-intersect": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/polygon-to-line": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-disjoint/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-equal": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-7.4.0.tgz", + "integrity": "sha512-OZWSaWvG7tZn90/7sBclZxpLyjrbxh8oNfOqrmdJigTY/XzAyjCE+jaweVTY8NGRkytC53kkDO0Q2DPZptpUcw==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "geojson-equality-ts": "^1.0.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-equal/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-intersects": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-7.4.0.tgz", + "integrity": "sha512-tV30WLBgLh89uGYpWsKjU29eMhdyhSh2Sc1IKmv1W0YFHKwp6oDm2eRajMzMgdtN8cl6Jyb8oFCmw4LbMMuWAQ==", + "license": "MIT", + "dependencies": { + "@turf/boolean-disjoint": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-intersects/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-overlap": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-overlap/-/boolean-overlap-7.4.0.tgz", + "integrity": "sha512-WcwQC042FnqDbZJCTm67NoQqV3bWz3C5M89b39MJtEPDWbO6kWwgMBt3Rm9w6gwrYljPE43vVB4pLiaSdNgKSQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-intersect": "7.4.0", + "@turf/line-overlap": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "geojson-equality-ts": "^1.0.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-overlap/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-parallel": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-parallel/-/boolean-parallel-7.4.0.tgz", + "integrity": "sha512-EUjTkvbd1zumtXwP3vEQPsjdlny9UjDkWRzNrZjBpc0MVY9g0clSslMBgHbcJAtOz4lP3+6fqOgwOkjA6XJBLQ==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/line-segment": "7.4.0", + "@turf/rhumb-bearing": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-parallel/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.4.0.tgz", + "integrity": "sha512-GQrHc6WSDHhf32GB/b9AGyOeC05AQ3nGN7ooWTrlCYefKn7X9/2wG9BNexF47AuyDq5jPdOvITVR26BLFWV+mA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-point-in-polygon/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-point-on-line": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-7.4.0.tgz", + "integrity": "sha512-5i5UBeZf/LuzgavkIj8L8L5/8SYWItWzQQcE9ymFZr60lV5lM8eFvQDfzAz2R3WQFCrxXExPqNvAVZOKGUyvoA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-point-on-line/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-touches": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-touches/-/boolean-touches-7.4.0.tgz", + "integrity": "sha512-ociH8gYuQ/jpTIFDAr3krbh5lMWjfbJXG1diGdmV03smutui/LhnMF8AvqQ46/nzo1IgD0640gbdzkQn91090g==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/boolean-point-on-line": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-touches/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-valid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-valid/-/boolean-valid-7.4.0.tgz", + "integrity": "sha512-Gop9r5YA3Asz80bPDCs3PjO4YPY5fyE55wlQDqLBSr6ZxbOaJXIoKrMgaBb1RBcM+9BFYnBlnpekMG3C8Gscrg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/boolean-crosses": "7.4.0", + "@turf/boolean-disjoint": "7.4.0", + "@turf/boolean-overlap": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/boolean-point-on-line": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-intersect": "7.4.0", + "@types/geojson": "^7946.0.10", + "geojson-polygon-self-intersections": "^1.2.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-valid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/boolean-within": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-within/-/boolean-within-7.4.0.tgz", + "integrity": "sha512-wld+Og3HZlSe+QcovDY4JKLDkEqOx6gW52jkvAWIvbr4fDB/HUV95zX6SY78reJwm6hNwmnuX3nSriWooLU4HA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-contains": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-within/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/buffer": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-7.4.0.tgz", + "integrity": "sha512-ewYjC7NmmP+BZ8JcjX1TglzYH1FCMWAh5L08j0iAP4lbn3/AutVjjbwIXedZZy5He1OfgmXf+rIBcNXdeEYceg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/center": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/jsts": "^2.7.1", + "@turf/meta": "7.4.0", + "@turf/projection": "7.4.0", + "@types/geojson": "^7946.0.10", + "d3-geo": "^2.0.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/buffer/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/center": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/center/-/center-7.4.0.tgz", + "integrity": "sha512-JVuTaIdnQtmTV8eQfVxqza1okKiaFzgVeIA6Xoy/u6aBMxg2+oBpS6GcHZkEqR8OapPWValB1AYYqimUSiyOXw==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-mean": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/center-mean/-/center-mean-7.4.0.tgz", + "integrity": "sha512-M4PG7NPiy5OoxueLkgw/OIe3driLic37xSsfs6KSjg7u/ZNBysymThy9a6lYUlygS/Z1xhqSfjOsOcxHtxKTzA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-mean/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/center-median": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/center-median/-/center-median-7.4.0.tgz", + "integrity": "sha512-R2ap2S789WRFzbk54gv3hSN0vnT+djRL0nqultWmvhYBJE2FsN5Z0libZNetP+VyOV/mg5AF8CFLbkmnmqJg3Q==", + "license": "MIT", + "dependencies": { + "@turf/center-mean": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-median/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/center-of-mass": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-7.4.0.tgz", + "integrity": "sha512-wpa22gBJvwYmvh+I3idMds1mtSEYXqLFTu9s9U7TZTy2HrglIIe+6iz0zSm4is58W7TNXQQlfeCmyvURU2+kdQ==", + "license": "MIT", + "dependencies": { + "@turf/centroid": "7.4.0", + "@turf/convex": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-of-mass/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/center/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/centroid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.4.0.tgz", + "integrity": "sha512-WglCFe+TnMqeYa/LcbUp37NgIF0zHTNYmlxwUm40fyyzyLsYeDq4dgFWLTYpvRNqb5oIMV6xsBjSf+vCPtVJ6w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/circle": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-7.4.0.tgz", + "integrity": "sha512-H7dXQP7WRpIE/UwZFFGYoSewsali5rjk9yzHPNZ03Bc9F11PWA3H+lwznEWmi/wLMMx91uMU+NkyGtJUeQr9cg==", + "license": "MIT", + "dependencies": { + "@turf/destination": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/circle/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/clean-coords": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-7.4.0.tgz", + "integrity": "sha512-xl3lVPey/FyVc3Z+drAWtVuVtvt737RHKGUMGyuskmKYW0JUeFDgqSR0OnrDfxXWb58Im/65BgPd1B+TUA9WfA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-on-line": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clean-coords/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/clone": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-7.4.0.tgz", + "integrity": "sha512-IfYnuil7XYJauy3crzIYEr26QkmBiTgFdGfYwUUe3S6dawX+lyb3vhuaYyssEPcCJq83g0G7hwxZy7141Mmzjg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clone/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/clusters": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/clusters/-/clusters-7.4.0.tgz", + "integrity": "sha512-ZY8ZXONy88EnsHCTdaAILkZjkR3gd8xYx4Sc8TpRFuRTDzI2abzU6ET9zezMd+U8QWs7//QJctcfapzCpXBXQg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clusters-dbscan": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/clusters-dbscan/-/clusters-dbscan-7.4.0.tgz", + "integrity": "sha512-6uKPUvL7VNx3MueOqgGTx/pnGe5p5rgy/tA/6HOgRFLlDu0oYUTUdNg2JE0ylYOc0HW5hxCcmsRR0lz6fxw1KA==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clusters-dbscan/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/clusters-kmeans": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/clusters-kmeans/-/clusters-kmeans-7.4.0.tgz", + "integrity": "sha512-fGSapN0nNzQ8NdKSsVgwPUQ5vkwlVK4M/zGN91jY29VcKb6ua8CotH7DNGKutCByr0c7WBefhUGeYVeZEgd9tQ==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "skmeans": "0.9.7", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clusters-kmeans/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/clusters/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/collect": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/collect/-/collect-7.4.0.tgz", + "integrity": "sha512-vQ0NH9SCUacKtQGValYxF8Q1mv4g4vLdWSwuVjIDbsH7NTgGUCFQwKXeHuGqh3U9r2PCQ+3ZbsMK61ok8U75fg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/collect/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/combine": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/combine/-/combine-7.4.0.tgz", + "integrity": "sha512-G3tEctgA3Kqz9T3PjF3lQdMUodQxOOLLBy8s5Y//8qyx7IRJqOazxdCaZRo2lq7C8eEdoUud6PIAXLz0gHue4w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/combine/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/concave": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/concave/-/concave-7.4.0.tgz", + "integrity": "sha512-EzCHDEbGRVbo9vR/HJriC8U7mpxgTYgyluBDNzcaT7kX53eH7/eS1U7rp/Q36e5RXMmlAWBknqtVseGMvKE5tw==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/tin": "7.4.0", + "@types/geojson": "^7946.0.10", + "topojson-client": "3.x", + "topojson-server": "3.x", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/concave/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/convex": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-7.4.0.tgz", + "integrity": "sha512-MKKAKBMZAQJLicdJSck0rPkwvf/Yz4IFl672iNq9ZoofQxgAqQSEGtBGy4Zgnr5edDoIexzLRAoqm6woyx4Lzw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "concaveman": "^1.2.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/convex/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/destination": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-7.4.0.tgz", + "integrity": "sha512-+jFpOUtzCiN8l9ZOvr66rxi/UxizphXaBDldaw9NfxlX/HcMn3ZDzC8IiCHiNXAilVh4WpCH0qqmH9ykOgdBKA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/destination/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/difference": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/difference/-/difference-7.4.0.tgz", + "integrity": "sha512-4zV7Xi3CwUdyNsDzrPty08PiUeYYVBdbCnNVlSf8sk5Z7SL7seA42RabRG8ByTzcrbLATeDU4oTEthVhOGPlyw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/difference/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/directional-mean": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/directional-mean/-/directional-mean-7.4.0.tgz", + "integrity": "sha512-TEgKLz71yLVAvjP9ozRAtB1gk7ziSq+a7LGePkMWLMxLtoJxb7eapAqcDk/QqRUBUJ4Dw/41qtRRwkVX0hQE/A==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/destination": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/length": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/directional-mean/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/dissolve": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-7.4.0.tgz", + "integrity": "sha512-UHx+XiReD+qHXN0dKOyOxlB+NF0L1PkUHlERCPZcYNqd4JWl2uqOjMEBYxlVeBj6SO3yQPTM9kvWXPE3HqLanQ==", + "license": "MIT", + "dependencies": { + "@turf/flatten": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/dissolve/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/distance": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.4.0.tgz", + "integrity": "sha512-ODkopQDG1m/U6Mx7OMmShncaCvneomwX3lZY1CDA7x40bhDdTTRYP/n/6LicZVyIO4/oK+aXkov4NOtWYthA2g==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/distance-weight": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/distance-weight/-/distance-weight-7.4.0.tgz", + "integrity": "sha512-HUS57zzrSJAUUUlpEZYAsjK+BuCisgRf5/9pW5uW9TfegSbysA0T0C800GYqZcSPIpv22zVF5LE/hFnnu8WjHA==", + "license": "MIT", + "dependencies": { + "@turf/centroid": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/distance-weight/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/distance/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/ellipse": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/ellipse/-/ellipse-7.4.0.tgz", + "integrity": "sha512-m5s/fwYUbNN1MW/nW33sZ/jD58lMU1kGP6muU4inGAP7OxKbS15yz+Qg2aXsfyKcb0z19AFjhnCLQGWHU0sl1w==", + "license": "MIT", + "dependencies": { + "@turf/destination": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/transform-rotate": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/ellipse/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/envelope": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/envelope/-/envelope-7.4.0.tgz", + "integrity": "sha512-BNpwP70dFElOrYAVxLvjd9kMnxdowJB0ojsTs2OTvaC8Hp+qfrVTzTK5ZZ6Dzix/UpgdRlC5cDrEmbXTdvDpjA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/bbox-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/envelope/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/explode": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/explode/-/explode-7.4.0.tgz", + "integrity": "sha512-kTWoryCTAiBwFvyJYNQWLJCaFx4bSRpyCjJWmuLoBEQRh9Fi4JLDEug+6JZxygMxCMWGXBH8/R3ICl+soLPFAg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/explode/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/flatten": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-7.4.0.tgz", + "integrity": "sha512-yrXpvXtmPlXIIoRuonQ7z+Uj47Sw9KYlH9oh9NBb3QRfz53LN+OTNndkGeXPaI00BDXxO/aZ7wEGmzuoCaDn1w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/flatten/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/flip": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/flip/-/flip-7.4.0.tgz", + "integrity": "sha512-SlLwkHlY0MzFXmn/3pFNT7JILsEZer+KzLIxVxebiu6nd3aQaIOqGvxLHVFRbIXYd48UrORg1L40sWsw8z1Egg==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/flip/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/geojson-rbush": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/geojson-rbush/-/geojson-rbush-7.4.0.tgz", + "integrity": "sha512-o+debVj+6KQ2p0VebH+FgcTig2jUuG6mWMgLYvb34c7cOHoEuY6ICvKRKtHn/XcHa6eyoSk0TvnLEJAfFTQDMg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/geojson-rbush/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/great-circle": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/great-circle/-/great-circle-7.4.0.tgz", + "integrity": "sha512-dV8qrH9rpvtUsqrXgUKWojCCJTdgAKgX6eyE+zm9GUtzq0pYUxG2JXVIxWBT65ucQPigBSbS5hxx6lAOpoD/Ew==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "arc": "^0.2.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/great-circle/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/helpers": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.4.0.tgz", + "integrity": "sha512-7PAwLZqOdRzTI5g9bHvUwlloAXPDH/mlajtryk0tw4ZwGMtmXsAyF4QundsAMfy4u48Fyd3AUqMXY0SMxqJGWg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/hex-grid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/hex-grid/-/hex-grid-7.4.0.tgz", + "integrity": "sha512-IQ35KzK2N9KZ7W0xVeZlDVcXURE2h+GD0CHru/LlWW7hkjVLNYvWjm+ZlMA141As038+4XremSGNzHAZxp3xsQ==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/intersect": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/hex-grid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/interpolate": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/interpolate/-/interpolate-7.4.0.tgz", + "integrity": "sha512-QRY0i2CW5ZhEhvDa1HDCO5gjun25HSjo4X7/Q2gC8BkjwpL12Ogo883BGAlt5Qq0JZq5uVFU9Ae7W7yVjfIPfw==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/hex-grid": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/point-grid": "7.4.0", + "@turf/square-grid": "7.4.0", + "@turf/triangle-grid": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/interpolate/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/intersect": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-7.4.0.tgz", + "integrity": "sha512-n/wbZfDPoM1JKwrql8mHBoS9cdRWUC7/VE9IBZOttVPv8Guo0B6+Mzc2Z2e1PdIkSP/1R2yH6lxPpWMuuBfVEA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/intersect/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/invariant": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.4.0.tgz", + "integrity": "sha512-OAsc3qdNx+tRqzWmMNFMnVlWWACIqnYqIejZKvb2oKkKPYJJrURPXZ6OdrGD58ljJMolLoqwIZGSx3VndaEjxg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/isobands": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/isobands/-/isobands-7.4.0.tgz", + "integrity": "sha512-ClOkFMwcgQmRCespoQavY4AA+gH5qkbcQgrGsKn12VrgbresgA2hBM9bVMNDaEmDauN7RdDBoNcNhkakhD/ZOQ==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.4.0", + "@turf/bbox": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/explode": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/isobands/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/isolines": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/isolines/-/isolines-7.4.0.tgz", + "integrity": "sha512-Pu0i03/GqAcYT4yJ9fj3NWa4Z/c4Viv3Duhzua05XLJ9c70wWJmb+eniLg2l+qiuVKi4ob0Xl2AKkgkKYiezgg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/isolines/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/jsts": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@turf/jsts/-/jsts-2.7.2.tgz", + "integrity": "sha512-zAezGlwWHPyU0zxwcX2wQY3RkRpwuoBmhhNE9HY9kWhFDkCxZ3aWK5URKwa/SWKJbj9aztO+8vtdiBA28KVJFg==", + "license": "(EDL-1.0 OR EPL-1.0)", + "dependencies": { + "jsts": "2.7.1" + } + }, + "node_modules/@turf/kinks": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-7.4.0.tgz", + "integrity": "sha512-M2deZpJq45WASRru5MmGwDJkVIj+JL/rcS6LLZBQyhSna47pxOQIVDC/tIx6xyFezxXw3lhX9VsoRst3irDdAQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/kinks/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/length": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/length/-/length-7.4.0.tgz", + "integrity": "sha512-vQsJLuL7uy8m6HAMuJJKoiOHYY1de1Hd6mY/fM90qfu2RyIrkoF8J4bRUb72XZNCVnvob/eHpUMdhZeoUhSyfQ==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/length/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-arc": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-arc/-/line-arc-7.4.0.tgz", + "integrity": "sha512-fkqfm3S1nRIBoKoK3LfpjDVGswyOw1cRb0W1qV6Na02ccrCJfJ7P9C4SkKZz2iakZioFCot4emraWno3B9YsYg==", + "license": "MIT", + "dependencies": { + "@turf/circle": "7.4.0", + "@turf/destination": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-arc/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-chunk": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-chunk/-/line-chunk-7.4.0.tgz", + "integrity": "sha512-vGytKbvTc+PtRimeEJez8nGoIxuu5uQiqSrK7DD7JB4ULLnBtABAMEr0EaEQQ2fyvE1zkTsbWSQPoFlQ3M52cQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/length": "7.4.0", + "@turf/line-slice-along": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-chunk/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-intersect": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-7.4.0.tgz", + "integrity": "sha512-5qNmEJ/0tQEaUUKSt2zIZGIS6eYGn3T7Z32yN6+Py0ANs8ln3dQNWaDyOEMms/qVY7BFvSaO+8iBmD8vlHdNcA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "robust-predicates": "^2.0.4", + "tinyqueue": "^2.0.3", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-intersect/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-offset": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-offset/-/line-offset-7.4.0.tgz", + "integrity": "sha512-vAF3tNvHfo6efcrDwhjSL7UgAt2QjPyCKhIWO0g5xvuGMw9v3DZDErla3fVNT/b+DG01UmNmYQWvMzlUWcTLoQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-offset/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-overlap": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-overlap/-/line-overlap-7.4.0.tgz", + "integrity": "sha512-40ZFjTk103uXFF5MfkhqzhowvBNozMcm18yR24Z38BbLC+4BLUNUslNDqyXStGZPXQ1X0MH3LeE2TbIpw9wKRA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/boolean-point-on-line": "7.4.0", + "@turf/geojson-rbush": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-segment": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/nearest-point-on-line": "7.4.0", + "@types/geojson": "^7946.0.10", + "fast-deep-equal": "^3.1.3", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-overlap/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-segment": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-7.4.0.tgz", + "integrity": "sha512-v4Gnxpj6XqWbl4d/aVZHYwvSFzaTGGBW4eQQ9Ozqcgt+bvignOMdtVogFDHSWKDj9Gr2tbS2jYp57c8OTa+Olw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-segment/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-slice": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-slice/-/line-slice-7.4.0.tgz", + "integrity": "sha512-urpVBcce/Z2Gprhv6cRniiaOr0L2UVjBqzEpp3esrW20iu25aDXor43d2KAqAP3Yhlv+vNMFlAJAGPcX/nTSzA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/nearest-point-on-line": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-slice-along": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-slice-along/-/line-slice-along-7.4.0.tgz", + "integrity": "sha512-N3rBS/E/N2v2udmglBU1XTfWIQlxkSDuzGCjbGnWUMX2ClVgJvptQXI1zQ29aIeOTbcqKNiSbEyqBg8e+fugPw==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.4.0", + "@turf/destination": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-slice-along/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-slice/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-split": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-7.4.0.tgz", + "integrity": "sha512-bAij7u19sFVdYoAp3bQnu/cVzlORxM/W3FM+WeFN/VvGkZ6YiAtcPq/lLP4WwK3WxthTcavED3wbd32iXGpzZw==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/geojson-rbush": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-intersect": "7.4.0", + "@turf/line-segment": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/nearest-point-on-line": "7.4.0", + "@turf/truncate": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-split/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/line-to-polygon": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/line-to-polygon/-/line-to-polygon-7.4.0.tgz", + "integrity": "sha512-wUsYNTP+tN+2J0+tzakKVHdo6Y1Pe8Ne1rJUuCxZRumwOFyML6/aIQvQaXdbAjN0gmfuoZTCv78RDSZBtsAPAQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-to-polygon/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/mask": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/mask/-/mask-7.4.0.tgz", + "integrity": "sha512-NVED/D8XcTHJTi080l9ezH3f+BzFbnBdVa0s5G9Q+sZQcrabSS5BOL1kdvQhu1LS49QhAGYNBtcfKB9e0hvwJg==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/mask/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/meta": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.4.0.tgz", + "integrity": "sha512-3cLUvlEyDuSnMSzrjhaLAEiYR8xhbfWyTVQlrlEw40xL81d4KF4PqUWbjTXKpXZStdYbet2GCurl79KMypNw6g==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/midpoint": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/midpoint/-/midpoint-7.4.0.tgz", + "integrity": "sha512-+poTGThn9F2tveuUDjg1z5TE3QG8zp/IHVqpzkFwFLWX3xpeys0mev9H3LqlbzRU7waawZJ/lsqYlHg3mIWHJw==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.4.0", + "@turf/destination": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/midpoint/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/moran-index": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/moran-index/-/moran-index-7.4.0.tgz", + "integrity": "sha512-lZqEUOhpmV2Fngjv6O16Pl3zbDlRUudEeuv/iinkT5adesOAKqngIrg4wYOT0kkNaSdaqNqbKyJGZ8mAlP4Lig==", + "license": "MIT", + "dependencies": { + "@turf/distance-weight": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/moran-index/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/nearest-neighbor-analysis": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-neighbor-analysis/-/nearest-neighbor-analysis-7.4.0.tgz", + "integrity": "sha512-WBZbuH+f7/ezEdBGMToT2yYzONdYqhE79yxHTvoJMk1+o0eakg4DY+/owM5FWYgB3GnJCp260//Uz1D/q6wFlg==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.4.0", + "@turf/bbox": "7.4.0", + "@turf/bbox-polygon": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/nearest-point": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-neighbor-analysis/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/nearest-point": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point/-/nearest-point-7.4.0.tgz", + "integrity": "sha512-rH+M5H/rWiOXStiHkV3Cs5OS+Tg/531Tonr7tm2pUjVy1RRLmDqSzKn+iFs3S6ybvjSK2pJa1BbTXjYlc8Pv3Q==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point-on-line": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-7.4.0.tgz", + "integrity": "sha512-eKTKf/qODIsPankYMZrNfI3LjLaVtQvYV/1hcj9kH/Zr3GfLUszevjhTRDZtMgTFrv8+awW3+MyhmN+PJGuLdQ==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point-on-line/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/nearest-point-to-line": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-to-line/-/nearest-point-to-line-7.4.0.tgz", + "integrity": "sha512-PQjS+7/OIjib5HGHtrtDuIH0B6ifT8b4b8ADmcvKcKEMaOk5wjc+Z40oPc0n5OwRoddvbd2K2yV59+fh1cXxeA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/point-to-line-distance": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point-to-line/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/nearest-point/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/planepoint": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/planepoint/-/planepoint-7.4.0.tgz", + "integrity": "sha512-4TQxrbFpbEjIEv9C+5MO5BbnL/Ay2wrJlLxaiIbJ61LaFFuMx6ZCWu1rBYMDo72Ik5+oK4Pik1J5e8rKW3YGRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/planepoint/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/point-grid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/point-grid/-/point-grid-7.4.0.tgz", + "integrity": "sha512-Lx7M6JkLHT8A3hPc+811rALohOJ9mJBjAh+NQz9Mj/QdFmnhzGa8aia4F4agy3uSyJP/7EFXUXlPRK4Yc6/4tw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-within": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-grid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/point-on-feature": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/point-on-feature/-/point-on-feature-7.4.0.tgz", + "integrity": "sha512-H7YamrW510QJLnhWIT9oecm1siwhwvb4SZrZSqu6hUOS+pFxumJjUYogE+B6kL+Ymtui2+h6cOtOHJnrNrKaEA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/center": "7.4.0", + "@turf/explode": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/nearest-point": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-on-feature/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/point-to-line-distance": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/point-to-line-distance/-/point-to-line-distance-7.4.0.tgz", + "integrity": "sha512-2Egagkj3BK6SrcUxMBJ40PHhW/numknLsJQhlv4kl3d3t83HChhUtH2mXnhqcdi97V3jXSRV1SZIhZKc8TrwOg==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/nearest-point-on-line": "7.4.0", + "@turf/projection": "7.4.0", + "@turf/rhumb-bearing": "7.4.0", + "@turf/rhumb-distance": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-to-line-distance/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/point-to-polygon-distance": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/point-to-polygon-distance/-/point-to-polygon-distance-7.4.0.tgz", + "integrity": "sha512-37h+HzsMgXMu9MJhfQJk11aJ8ZSK4Db/kccvrIyRFv+SX7bCjFON6P4FC4+4PVACajK9VJ52h+KRufaCZqlxdA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/point-to-line-distance": "7.4.0", + "@turf/polygon-to-line": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-to-polygon-distance/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/points-within-polygon": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/points-within-polygon/-/points-within-polygon-7.4.0.tgz", + "integrity": "sha512-8TtUvUeM7USPD11rEPPR/C+aADov1G8wxmgP8ExE2KUDCg4X6cnYTxpWgOHe3uijpv4QRHGhiz12xNmB4ysyyA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/points-within-polygon/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/polygon-smooth": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-smooth/-/polygon-smooth-7.4.0.tgz", + "integrity": "sha512-UxcEo+OrHbl4+YWuhiIT2lLXvA6rohNdbRBqnZY/X9uQ41JYhh5gMYwlQxbsLTgdYA2pE6wJO52PDHvpqsPu4g==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygon-smooth/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/polygon-tangents": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-tangents/-/polygon-tangents-7.4.0.tgz", + "integrity": "sha512-QXKxDhRIDeW/+FQ8Eh6gQMPJFpyf6iXal6tdbL7+5jw9Zpm8DTFVvcPzERBZ3gey6P3CSqP/GD1zZT8gQ+mZng==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/boolean-within": "7.4.0", + "@turf/explode": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/nearest-point": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygon-tangents/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/polygon-to-line": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-7.4.0.tgz", + "integrity": "sha512-LCHZ9bojUj99et83ZLGpSR/PFx48c0tcjtfuI1nn66a6Q+EGy7GO4NsHv4rWZsC8Lljy5ldbJ3dd8+WGnAk7dw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygon-to-line/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/polygonize": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/polygonize/-/polygonize-7.4.0.tgz", + "integrity": "sha512-SwEazUYz4KFXllNNXGUt2rP6XpPBfNEOnx+1uP0rcioIsNocFfgwfCUcgHXYM+GZhG0JTD7jQB9RXeNGb6hNIg==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/envelope": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygonize/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/projection": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/projection/-/projection-7.4.0.tgz", + "integrity": "sha512-ZS932qM+18V1UBvNlrNXUCD7mh9CzQqZzw/ElVtb0kAk6N00FYQ9bo26Cca08UZKXh3rZqG/e4eCK74JNGS4Zg==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/projection/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/quadrat-analysis": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/quadrat-analysis/-/quadrat-analysis-7.4.0.tgz", + "integrity": "sha512-p+Af0+LpwnsVQuGSL1BWuLxgMjd6dYaSs+PxwJ2Y05nSXlsIFpWy+/T55r0Sfun7SoypclKURRCmDy3Q/RVdJw==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.4.0", + "@turf/bbox": "7.4.0", + "@turf/bbox-polygon": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/point-grid": "7.4.0", + "@turf/random": "7.4.0", + "@turf/square-grid": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/quadrat-analysis/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/random": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/random/-/random-7.4.0.tgz", + "integrity": "sha512-ttoPPPQppLYww3ZSeOSlbZlaH4Ki4Mxrdu7wMuDWNrINEhrkr/PuqWyIDISTpILsSARqT0Zzfjwdp0ptkUyakw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/random/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/rectangle-grid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/rectangle-grid/-/rectangle-grid-7.4.0.tgz", + "integrity": "sha512-JeRq5pgmn543DmZU6x4nSX+KVNRGEz3iJQIgbKgSMnzZeiJxfMStINvASz7mz6yky2EwUHvQOBRRlhgCqWViuQ==", + "license": "MIT", + "dependencies": { + "@turf/boolean-intersects": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rectangle-grid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/rewind": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-7.4.0.tgz", + "integrity": "sha512-Q2qOcLzymxXyIAYXM9BZQT09L63EhzgneCBMka0dPf9jVShxMeA3oF1kY20ChWkZvTGOAbM7YU9hghphbCxo2w==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rewind/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/rhumb-bearing": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-bearing/-/rhumb-bearing-7.4.0.tgz", + "integrity": "sha512-y5omgg5SqHwz46fZlra2/70z5eOKXSp/ZzRNIDYKJVYF5oNT2YbG9Z6lj4Az51bjqJQESXUoR6wRmFnpliIp0w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rhumb-bearing/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/rhumb-destination": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-destination/-/rhumb-destination-7.4.0.tgz", + "integrity": "sha512-3x4dNLheJJIK/6daH9U7dDkFRSq3wqvDIdqgSxsqgFHoNEI6xDACKGrx47JFWVbg/zh9osG5JSv96e70Y4P5XA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rhumb-destination/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/rhumb-distance": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-distance/-/rhumb-distance-7.4.0.tgz", + "integrity": "sha512-29pFCftS/yxEzrYWB777I23Mob+yw9WAECJNw+uabbJi6fwFphJfqWApMXY2ZnudVSJFCGJbByVAHVDTvOGG+w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rhumb-distance/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/sample": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/sample/-/sample-7.4.0.tgz", + "integrity": "sha512-RfU8+bp5RzxxDq5ldKk8qR+E/ADboOhlcVjFu2p99SVivlnbkMBNXlFRAwdmaDeyqI6bTuwymc7DsCxE0ZA1Uw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/sample/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/sector": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/sector/-/sector-7.4.0.tgz", + "integrity": "sha512-BGTbeT/LHW+6H43by4EQfAQaql94rOg5sxIN1T9/PeQR13KV6CnCVzxEmMzPVEZvy7VDfssv9eMerY0WGkjfYg==", + "license": "MIT", + "dependencies": { + "@turf/circle": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/line-arc": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/sector/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/shortest-path": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/shortest-path/-/shortest-path-7.4.0.tgz", + "integrity": "sha512-Bq/4/yN+CFWo0TcxXKO/VW7/BB5EgrHj9lwP4Kbk00i43flF0EpnlABWoO/MDUVFBPAkBCyWUgcCUW5MZbid6w==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/bbox-polygon": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/clean-coords": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/transform-scale": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/shortest-path/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/simplify": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/simplify/-/simplify-7.4.0.tgz", + "integrity": "sha512-HTolHFuXLReKHJoh3iz92+WpF5EsuCmmZR7fNv3hWnA4hZfkuEzbGB/WZqRdQpvDs99IUS8QIc6BAp6FHzQbBQ==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/simplify/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/square": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/square/-/square-7.4.0.tgz", + "integrity": "sha512-XjL7taeIZdh3iIS1vertswqa+dgQEgBlByfInjTj3uDDjrpHYG13Yxg6EHuOW/y1SeTWp2fTAKLRQFexC+kwGw==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/square-grid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/square-grid/-/square-grid-7.4.0.tgz", + "integrity": "sha512-jq4pxYs+FQ8Y1m3RhpTBYvSLzSwj75hurT53T1jXboEYIyeFGtGdlOy2o3yx/2cxUK+AP1KtKrQGX5v7sdpzVg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/rectangle-grid": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/square-grid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/square/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/standard-deviational-ellipse": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-7.4.0.tgz", + "integrity": "sha512-02Erb1A1BARE0MKxLMxsSL3BVHZWZrW/edZISvqZUF1eBOc7FMpIy6Pu55COkFkN3YYOQHysrc4COAZdYEjcwg==", + "license": "MIT", + "dependencies": { + "@turf/center-mean": "7.4.0", + "@turf/ellipse": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/points-within-polygon": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/standard-deviational-ellipse/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/tag": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/tag/-/tag-7.4.0.tgz", + "integrity": "sha512-IBd4Lz01FWyTnyxmzcyK3DypMkd7vMzptZIQFlkC/LDeMnPMnmT1oJD7wMdZCJIV1YGVC5abpXoNwY3xnj/eTw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/tag/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/tesselate": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/tesselate/-/tesselate-7.4.0.tgz", + "integrity": "sha512-73Jj0+9GPUYoegut9J1ex9kVYe+WAIV+tBm6EXXtaZzmobIp8fPsAvspyTzXMQvPtlUsf/St+L0W4KFwWe2KrQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/earcut": "^2.1.4", + "@types/geojson": "^7946.0.10", + "earcut": "^2.2.4", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/tesselate/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/tin": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/tin/-/tin-7.4.0.tgz", + "integrity": "sha512-54uBeNKIP8GufZCdeMUMy33e0blR4w0JoE6sYO8I6ZPshNRbVbI24ck+kREVMjzNyeuhL+l7DUrNEn/pcDgBRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/tin/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/transform-rotate": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/transform-rotate/-/transform-rotate-7.4.0.tgz", + "integrity": "sha512-JsXtvydU23rJdOlS5u9Fpdi6mPtbRCo8/6y3QSjkg37EubFTd357M+lX8+lQ+LHjs8hXxvp9LKBZwTujlqsDcw==", + "license": "MIT", + "dependencies": { + "@turf/centroid": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/rhumb-bearing": "7.4.0", + "@turf/rhumb-destination": "7.4.0", + "@turf/rhumb-distance": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/transform-rotate/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/transform-scale": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/transform-scale/-/transform-scale-7.4.0.tgz", + "integrity": "sha512-lPsvppxBfK+5z9fC1R3F/7y4113cwZ7hLG9dR97EUYaUhphQveVShrFK18S8+AXxs0J4PnDm3HptTfuJyD7bMQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.4.0", + "@turf/center": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/rhumb-bearing": "7.4.0", + "@turf/rhumb-destination": "7.4.0", + "@turf/rhumb-distance": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/transform-scale/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/transform-translate": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/transform-translate/-/transform-translate-7.4.0.tgz", + "integrity": "sha512-tMHS7lovcHRpKtM0A9w1Y337ZM279YALzPmEvbLA/1Piy6Ik2osecxGB90T+HaobYkWVWhiQ70XP48f49qFc8g==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/rhumb-destination": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/transform-translate/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/triangle-grid": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/triangle-grid/-/triangle-grid-7.4.0.tgz", + "integrity": "sha512-3aPtajJH67JkuEe+9WAEecRHBs9Q6lK0KrJIhs8QDx4VJ4nGjRazomz+UuxbhBRtn37lQ/cIxWn0nH9JYFembw==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/intersect": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/triangle-grid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/truncate": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-7.4.0.tgz", + "integrity": "sha512-eTXO62l1mD++mKlhUqLNHGcgCQvhxXJGB/EUL1KSzpVIeMYtgcmItAneWdHW+gEpmHfjeDjBuvtbZWA23r7Eig==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/truncate/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/turf": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/turf/-/turf-7.4.0.tgz", + "integrity": "sha512-enR/94yESzZb4t7lj/DSqk6oMdsSfPhMnXHn+f4umpdtZuMmFSbB/mPWxMFoyWCD0z+luwPwtJ7kdtHO5nGi9w==", + "license": "MIT", + "dependencies": { + "@turf/along": "7.4.0", + "@turf/angle": "7.4.0", + "@turf/area": "7.4.0", + "@turf/bbox": "7.4.0", + "@turf/bbox-clip": "7.4.0", + "@turf/bbox-polygon": "7.4.0", + "@turf/bearing": "7.4.0", + "@turf/bezier-spline": "7.4.0", + "@turf/boolean-clockwise": "7.4.0", + "@turf/boolean-concave": "7.4.0", + "@turf/boolean-contains": "7.4.0", + "@turf/boolean-crosses": "7.4.0", + "@turf/boolean-disjoint": "7.4.0", + "@turf/boolean-equal": "7.4.0", + "@turf/boolean-intersects": "7.4.0", + "@turf/boolean-overlap": "7.4.0", + "@turf/boolean-parallel": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/boolean-point-on-line": "7.4.0", + "@turf/boolean-touches": "7.4.0", + "@turf/boolean-valid": "7.4.0", + "@turf/boolean-within": "7.4.0", + "@turf/buffer": "7.4.0", + "@turf/center": "7.4.0", + "@turf/center-mean": "7.4.0", + "@turf/center-median": "7.4.0", + "@turf/center-of-mass": "7.4.0", + "@turf/centroid": "7.4.0", + "@turf/circle": "7.4.0", + "@turf/clean-coords": "7.4.0", + "@turf/clone": "7.4.0", + "@turf/clusters": "7.4.0", + "@turf/clusters-dbscan": "7.4.0", + "@turf/clusters-kmeans": "7.4.0", + "@turf/collect": "7.4.0", + "@turf/combine": "7.4.0", + "@turf/concave": "7.4.0", + "@turf/convex": "7.4.0", + "@turf/destination": "7.4.0", + "@turf/difference": "7.4.0", + "@turf/directional-mean": "7.4.0", + "@turf/dissolve": "7.4.0", + "@turf/distance": "7.4.0", + "@turf/distance-weight": "7.4.0", + "@turf/ellipse": "7.4.0", + "@turf/envelope": "7.4.0", + "@turf/explode": "7.4.0", + "@turf/flatten": "7.4.0", + "@turf/flip": "7.4.0", + "@turf/geojson-rbush": "7.4.0", + "@turf/great-circle": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/hex-grid": "7.4.0", + "@turf/interpolate": "7.4.0", + "@turf/intersect": "7.4.0", + "@turf/invariant": "7.4.0", + "@turf/isobands": "7.4.0", + "@turf/isolines": "7.4.0", + "@turf/kinks": "7.4.0", + "@turf/length": "7.4.0", + "@turf/line-arc": "7.4.0", + "@turf/line-chunk": "7.4.0", + "@turf/line-intersect": "7.4.0", + "@turf/line-offset": "7.4.0", + "@turf/line-overlap": "7.4.0", + "@turf/line-segment": "7.4.0", + "@turf/line-slice": "7.4.0", + "@turf/line-slice-along": "7.4.0", + "@turf/line-split": "7.4.0", + "@turf/line-to-polygon": "7.4.0", + "@turf/mask": "7.4.0", + "@turf/meta": "7.4.0", + "@turf/midpoint": "7.4.0", + "@turf/moran-index": "7.4.0", + "@turf/nearest-neighbor-analysis": "7.4.0", + "@turf/nearest-point": "7.4.0", + "@turf/nearest-point-on-line": "7.4.0", + "@turf/nearest-point-to-line": "7.4.0", + "@turf/planepoint": "7.4.0", + "@turf/point-grid": "7.4.0", + "@turf/point-on-feature": "7.4.0", + "@turf/point-to-line-distance": "7.4.0", + "@turf/point-to-polygon-distance": "7.4.0", + "@turf/points-within-polygon": "7.4.0", + "@turf/polygon-smooth": "7.4.0", + "@turf/polygon-tangents": "7.4.0", + "@turf/polygon-to-line": "7.4.0", + "@turf/polygonize": "7.4.0", + "@turf/projection": "7.4.0", + "@turf/quadrat-analysis": "7.4.0", + "@turf/random": "7.4.0", + "@turf/rectangle-grid": "7.4.0", + "@turf/rewind": "7.4.0", + "@turf/rhumb-bearing": "7.4.0", + "@turf/rhumb-destination": "7.4.0", + "@turf/rhumb-distance": "7.4.0", + "@turf/sample": "7.4.0", + "@turf/sector": "7.4.0", + "@turf/shortest-path": "7.4.0", + "@turf/simplify": "7.4.0", + "@turf/square": "7.4.0", + "@turf/square-grid": "7.4.0", + "@turf/standard-deviational-ellipse": "7.4.0", + "@turf/tag": "7.4.0", + "@turf/tesselate": "7.4.0", + "@turf/tin": "7.4.0", + "@turf/transform-rotate": "7.4.0", + "@turf/transform-scale": "7.4.0", + "@turf/transform-translate": "7.4.0", + "@turf/triangle-grid": "7.4.0", + "@turf/truncate": "7.4.0", + "@turf/union": "7.4.0", + "@turf/unkink-polygon": "7.4.0", + "@turf/voronoi": "7.4.0", + "@types/geojson": "^7946.0.10", + "@types/kdbush": "^3.0.5", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/turf/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/union": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/union/-/union-7.4.0.tgz", + "integrity": "sha512-SYVtsCW9IznTyKlcGWxssRrf90p/hdpch1UeIcZc31pkSVdmeLOyuzPx0ef3UPmaF//fgXAphMph6JWA/FpKbA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/union/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/unkink-polygon": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/unkink-polygon/-/unkink-polygon-7.4.0.tgz", + "integrity": "sha512-GAQdiRmfR3Q64LoOqorr2Ec9eKoaVssSE1Rq34ZVTGmf2H49vjOPTXfTBp9ZVva4apVUkNLPPv3r4VaUHmABog==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.4.0", + "@turf/boolean-point-in-polygon": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/unkink-polygon/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/voronoi": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/voronoi/-/voronoi-7.4.0.tgz", + "integrity": "sha512-Uw/gyd0vuRonM/zCzgKqsb4Pt8gLl3oMPPscGVtmtyH0vsdlaZuaimJFC9Xdo9sWkLxn8eokhFnkJ3MZSl2UTA==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.4.0", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/d3-voronoi": "^1.1.12", + "@types/geojson": "^7946.0.10", + "d3-voronoi": "1.1.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/voronoi/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -4848,6 +7600,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-voronoi": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.12.tgz", + "integrity": "sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4855,6 +7613,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/earcut": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4893,6 +7657,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4932,6 +7702,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/kdbush": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/kdbush/-/kdbush-3.0.5.tgz", + "integrity": "sha512-tdJz7jaWFu4nR+8b2B+CdPZ6811ighYylWsu2hpsivapzW058yP0KdfZuNY89IiRe5jbKvBGXN3LQdN2KPXVdQ==", + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.24", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", @@ -6060,6 +8836,14 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/arc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/arc/-/arc-0.2.0.tgz", + "integrity": "sha512-8NFOo126uYKQJyXNSLY/jSklgfLQL+XWAcPXGo876JwEQ8nSOPXWNI3TV2jLZMN8QEw8uksJ1ZwS4npjBca8MA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -6776,6 +9560,18 @@ "typedarray": "^0.0.6" } }, + "node_modules/concaveman": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/concaveman/-/concaveman-1.2.1.tgz", + "integrity": "sha512-PwZYKaM/ckQSa8peP5JpVr7IMJ4Nn/MHIaWUjP4be+KoZ7Botgs8seAZGpmaOM+UZXawcdYRao/px9ycrCihHw==", + "license": "ISC", + "dependencies": { + "point-in-polygon": "^1.1.0", + "rbush": "^3.0.1", + "robust-predicates": "^2.0.4", + "tinyqueue": "^2.0.3" + } + }, "node_modules/config": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/config/-/config-3.3.12.tgz", @@ -7100,6 +9896,30 @@ "node": ">= 8" } }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-geo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", + "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^2.5.0" + } + }, + "node_modules/d3-voronoi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", + "integrity": "sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==", + "license": "BSD-3-Clause" + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -7238,6 +10058,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8304,6 +11130,39 @@ "node": ">=14" } }, + "node_modules/geojson-equality-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/geojson-equality-ts/-/geojson-equality-ts-1.0.2.tgz", + "integrity": "sha512-h3Ryq+0mCSN/7yLs0eDgrZhvc9af23o/QuC4aTiuuzP/MRCtd6mf5rLsLRY44jX0RPUfM8c4GqERQmlUxPGPoQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.14" + } + }, + "node_modules/geojson-polygon-self-intersections": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/geojson-polygon-self-intersections/-/geojson-polygon-self-intersections-1.2.2.tgz", + "integrity": "sha512-6XRNF4CsRHYmR9z5YuIk5f/aOototnDf0dgMqYGcS7y1l57ttt6MAIAxl3rXyas6lq1HEbTuLMh4PgvO+OV42w==", + "license": "MIT", + "dependencies": { + "rbush": "^2.0.1" + } + }, + "node_modules/geojson-polygon-self-intersections/node_modules/quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==", + "license": "ISC" + }, + "node_modules/geojson-polygon-self-intersections/node_modules/rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "license": "MIT", + "dependencies": { + "quickselect": "^1.0.1" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -8757,6 +11616,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9160,6 +12025,15 @@ "node": ">=0.10.0" } }, + "node_modules/jsts": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/jsts/-/jsts-2.7.1.tgz", + "integrity": "sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg==", + "license": "(EDL-1.0 OR EPL-1.0)", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10980,6 +13854,37 @@ "node": ">=4" } }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/point-in-polygon-hao": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/point-in-polygon-hao/node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/polyclip-ts": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", + "integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.1.0", + "splaytree-ts": "^1.0.2" + } + }, "node_modules/postcss": { "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", @@ -11296,6 +14201,12 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -11320,6 +14231,15 @@ "node": ">= 0.8" } }, + "node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "license": "MIT", + "dependencies": { + "quickselect": "^2.0.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -11532,6 +14452,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -11910,6 +14836,12 @@ "node": ">=18" } }, + "node_modules/skmeans": { + "version": "0.9.7", + "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", + "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -11958,6 +14890,12 @@ "source-map": "^0.6.0" } }, + "node_modules/splaytree-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", + "integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==", + "license": "BDS-3-Clause" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -12359,6 +15297,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -12391,6 +15335,44 @@ "node": ">=0.6" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/topojson-server": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", + "integrity": "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "geo2topo": "bin/geo2topo" + } + }, + "node_modules/topojson-server/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", diff --git a/package.json b/package.json index 4d9db38..76b94a0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", "@opentelemetry/api": "^1.9.1", + "@turf/turf": "^7.4.0", "axios": "^1.20.0", "compression": "^1.8.0", "express": "^4.21.2", diff --git a/src/common/config.ts b/src/common/config.ts index 7dfad2a..8d3292d 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -24,6 +24,10 @@ interface JobManagerConfig { }; } +interface ValidationConfig { + percentageLimit: number; +} + interface NFSConfig { pvPath: string; } @@ -44,6 +48,7 @@ type ProviderSource = 'NFS' | 'S3'; type OpsTriggerConfigType = commonBoilerplateV3Type & { externalServices: ExternalServicesConfig; jobManager: JobManagerConfig; + validation: ValidationConfig; provider: ProviderSource; // eslint-disable-next-line @typescript-eslint/naming-convention NFS: NFSConfig; @@ -60,9 +65,16 @@ const opsTriggerConfigSchema = { { $ref: commonBoilerplateV3.$id }, { type: 'object', - required: ['externalServices', 'jobManager', 'provider'], + required: ['externalServices', 'jobManager', 'validation', 'provider'], properties: { provider: { type: 'string', enum: ['NFS', 'S3'] }, + validation: { + type: 'object', + required: ['percentageLimit'], + properties: { + percentageLimit: { type: 'number' }, + }, + }, // eslint-disable-next-line @typescript-eslint/naming-convention NFS: { type: 'object', @@ -145,4 +157,4 @@ function getConfig(): ConfigType { } export { getConfig, initConfig }; -export type { ConfigType, LookupTablesConfig, ExternalServicesConfig, JobManagerConfig, NFSConfig, S3Config, ProviderSource }; +export type { ConfigType, LookupTablesConfig, ExternalServicesConfig, JobManagerConfig, ValidationConfig, NFSConfig, S3Config, ProviderSource }; diff --git a/src/tileset/tilesetReader.ts b/src/tileset/tilesetReader.ts new file mode 100644 index 0000000..365e616 --- /dev/null +++ b/src/tileset/tilesetReader.ts @@ -0,0 +1,108 @@ +import { readFile, open } from 'node:fs/promises'; +import { inflateRawSync } from 'node:zlib'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import { AppError } from '@common/appError'; +import { is3tz, buildModelFilePath } from '@common/util'; +import type { LogContext } from '@common/interfaces'; + +const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; +const GENERAL_PURPOSE_FLAG_OFFSET = 6; +const COMPRESSION_METHOD_OFFSET = 8; +const COMPRESSED_SIZE_OFFSET = 18; +const NAME_LENGTH_OFFSET = 26; +const EXTRA_LENGTH_OFFSET = 28; +const HEADER_FIXED_LENGTH = 30; +const COMPRESSION_STORED = 0; +const COMPRESSION_DEFLATED = 8; +const DATA_DESCRIPTOR_FLAG = 0x08; +const ZIP64_SIZE_SENTINEL = 0xffffffff; + +export const ERROR_TILESET_NOT_FIRST_ENTRY = 'tileset file is not the first entry of the 3tz archive'; +export const ERROR_TILESET_UNSUPPORTED_COMPRESSION = 'tileset entry uses an unsupported compression method'; +export const ERROR_TILESET_UNSUPPORTED_ENCODING = 'tileset entry size is not available in the local header (data descriptor or ZIP64)'; +export const ERROR_TILESET_READ_FAILED = 'failed reading the tileset file'; + +@injectable() +export class TilesetReader { + private readonly logContext: LogContext; + + public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) { + this.logContext = { + fileName: __filename, + class: TilesetReader.name, + }; + } + + public async readTilesetJson(modelPath: string, tilesetFilename: string): Promise { + return is3tz(modelPath) ? this.readFromArchive(modelPath, tilesetFilename) : this.readFromFolder(modelPath, tilesetFilename); + } + + private async readFromFolder(modelPath: string, tilesetFilename: string): Promise { + const logContext = { ...this.logContext, function: this.readFromFolder.name }; + const path = buildModelFilePath(modelPath, tilesetFilename); + try { + return await readFile(path, 'utf-8'); + } catch (err) { + this.logger.error({ msg: 'failed reading tileset from folder', logContext, path, err }); + throw new AppError('tileset', StatusCodes.INTERNAL_SERVER_ERROR, ERROR_TILESET_READ_FAILED, false); + } + } + + private async readFromArchive(archivePath: string, tilesetFilename: string): Promise { + const logContext = { ...this.logContext, function: this.readFromArchive.name }; + let handle; + try { + handle = await open(archivePath, 'r'); + } catch (err) { + this.logger.error({ msg: 'failed opening 3tz archive', logContext, archivePath, err }); + throw new AppError('tileset', StatusCodes.INTERNAL_SERVER_ERROR, ERROR_TILESET_READ_FAILED, false); + } + + try { + const header = Buffer.alloc(HEADER_FIXED_LENGTH); + await handle.read(header, 0, HEADER_FIXED_LENGTH, 0); + + if (header.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_TILESET_NOT_FIRST_ENTRY, true); + } + + const flags = header.readUInt16LE(GENERAL_PURPOSE_FLAG_OFFSET); + const method = header.readUInt16LE(COMPRESSION_METHOD_OFFSET); + const compressedSize = header.readUInt32LE(COMPRESSED_SIZE_OFFSET); + const nameLength = header.readUInt16LE(NAME_LENGTH_OFFSET); + const extraLength = header.readUInt16LE(EXTRA_LENGTH_OFFSET); + + if ((flags & DATA_DESCRIPTOR_FLAG) !== 0 || compressedSize === 0 || compressedSize === ZIP64_SIZE_SENTINEL) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_TILESET_UNSUPPORTED_ENCODING, true); + } + + const nameBuffer = Buffer.alloc(nameLength); + await handle.read(nameBuffer, 0, nameLength, HEADER_FIXED_LENGTH); + if (nameBuffer.toString('utf-8') !== tilesetFilename) { + this.logger.error({ + msg: 'first 3tz entry is not the expected tileset file', + logContext, + entryName: nameBuffer.toString('utf-8'), + tilesetFilename, + }); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_TILESET_NOT_FIRST_ENTRY, true); + } + + const raw = Buffer.alloc(compressedSize); + await handle.read(raw, 0, compressedSize, HEADER_FIXED_LENGTH + nameLength + extraLength); + + if (method === COMPRESSION_STORED) { + return raw.toString('utf-8'); + } + if (method === COMPRESSION_DEFLATED) { + return inflateRawSync(raw).toString('utf-8'); + } + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_TILESET_UNSUPPORTED_COMPRESSION, true); + } finally { + await handle.close(); + } + } +} diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 07edbf0..821af7d 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -1,18 +1,24 @@ import type { Logger } from '@map-colonies/js-logger'; import { StatusCodes } from 'http-status-codes'; import { inject, injectable } from 'tsyringe'; -import { new3DLayerMetadataSchema, geometrySchema } from '@map-colonies/3d-shared'; +import { new3DLayerMetadataSchema, geometrySchema, calculatePolygonFromTileset, type TileSetJson } from '@map-colonies/3d-shared'; +import { area, feature, featureCollection, intersect, union } from '@turf/turf'; +import type { Feature, MultiPolygon, Polygon } from 'geojson'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; import { AppError } from '@common/appError'; import { buildModelFilePath } from '@common/util'; +import type { ConfigType } from '@common/config'; import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; import { CatalogCall } from '../externalServices/catalog/catalogCall'; import { JobnikClient } from '../externalServices/jobnik/jobnikClient'; +import { TilesetReader } from '../tileset/tilesetReader'; import type { Record3D } from '../externalServices/catalog/interfaces'; import type { Provider } from '../providers/interfaces'; import type { IngestionPayload } from '../record/models/recordManager'; +const FULL_COVERAGE_PERCENT = 100; + const BLOCKED_DELETE_PRODUCT_TYPE = 'QuantizedMeshDTMBest'; const RECORD_STATUS_UNPUBLISHED = 'UNPUBLISHED'; @@ -26,18 +32,25 @@ export const ERROR_DELETE_RECORD_NOT_FOUND = "recordId doesn't match exactly one export const ERROR_DELETE_PRODUCT_TYPE = 'Cannot delete a record whose productType is "QuantizedMeshDTMBest"'; export const ERROR_DELETE_STATUS = 'Cannot delete a record whose productStatus is not "UNPUBLISHED"'; export const ERROR_FILE_NOT_FOUND = 'The model files do not exist in the agreed storage'; +export const ERROR_TILESET_INVALID = 'The tileset file is not a valid 3DTiles tileset'; +export const ERROR_FOOTPRINT_FAR_FROM_MODEL = 'Invalid footprint! The footprint does not intersect the tileset model'; +export const ERROR_FOOTPRINT_COVERAGE = 'The footprint coverage of the tileset model is below the required threshold'; @injectable() export class ValidationManager { private readonly logContext: LogContext; + private readonly percentageLimit: number; public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, @inject(CatalogCall) private readonly catalog: CatalogCall, @inject(JobnikClient) private readonly jobnik: JobnikClient, + @inject(TilesetReader) private readonly tilesetReader: TilesetReader, @inject(SERVICES.PROVIDER) private readonly provider: Provider ) { + this.percentageLimit = this.config.get('validation.percentageLimit'); this.logContext = { fileName: __filename, class: ValidationManager.name, @@ -66,6 +79,7 @@ export class ValidationManager { await this.validateProductNameUnique(parsed.data.productName); await this.validateProductNameNotInFlight(parsed.data.productName); await this.validateFileExists(payload.modelPath, payload.tilesetFilename); + await this.validateTileset(payload, footprintResult.data); } public async validateDelete(recordId: string): Promise { @@ -118,6 +132,48 @@ export class ValidationManager { } } + private async validateTileset(payload: IngestionPayload, footprint: Polygon | MultiPolygon): Promise { + const logContext = { ...this.logContext, function: this.validateTileset.name }; + const content = await this.tilesetReader.readTilesetJson(payload.modelPath, payload.tilesetFilename); + + let modelPolygon: Polygon; + try { + const tilesetJson = JSON.parse(content) as TileSetJson; + modelPolygon = calculatePolygonFromTileset(tilesetJson); + } catch (err) { + const message = err instanceof Error ? err.message : ERROR_TILESET_INVALID; + this.logger.error({ msg: 'tileset polygon extraction failed', logContext, modelPath: payload.modelPath, err }); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } + + this.validateFootprintCoverage(footprint, modelPolygon); + } + + private validateFootprintCoverage(footprint: Polygon | MultiPolygon, modelPolygon: Polygon): void { + const logContext = { ...this.logContext, function: this.validateFootprintCoverage.name }; + const footprintFeature = feature(footprint); + const modelFeature: Feature = feature(modelPolygon); + + const intersection = intersect(featureCollection([footprintFeature, modelFeature])); + if (intersection === null) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_FOOTPRINT_FAR_FROM_MODEL, true); + } + + const combined = union(featureCollection([footprintFeature, modelFeature])); + const combinedArea = combined === null ? 0 : area(combined); + const coverage = combinedArea === 0 ? 0 : (FULL_COVERAGE_PERCENT * area(footprintFeature)) / combinedArea; + this.logger.debug({ msg: 'footprint coverage validation', logContext, coverage, percentageLimit: this.percentageLimit }); + + if (coverage < this.percentageLimit) { + throw new AppError( + 'badRequest', + StatusCodes.BAD_REQUEST, + `${ERROR_FOOTPRINT_COVERAGE}. coverage: ${coverage}%, minimum: ${this.percentageLimit}%`, + true + ); + } + } + private validateDates(start: unknown, end: unknown): void { if (start === undefined || end === undefined) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_MISSING_DATE, true); diff --git a/tests/helpers/metadata.ts b/tests/helpers/metadata.ts index 44d1527..c319f46 100644 --- a/tests/helpers/metadata.ts +++ b/tests/helpers/metadata.ts @@ -17,9 +17,11 @@ export const buildValidMetadata = (): Record => ({ type: 'Polygon', coordinates: [ [ - [34.45, 31.48], - [34.46, 31.48], - [34.45, 31.48], + [35.17, 32.9], + [35.18, 32.9], + [35.18, 32.91], + [35.17, 32.91], + [35.17, 32.9], ], ], }, diff --git a/tests/helpers/tilesets/folder/tileset.json b/tests/helpers/tilesets/folder/tileset.json new file mode 100644 index 0000000..c9d2f7d --- /dev/null +++ b/tests/helpers/tilesets/folder/tileset.json @@ -0,0 +1,35 @@ +{ + "asset": { "version": "1.1" }, + "geometricError": 500.0, + "root": { + "boundingVolume": { "region": [0.613745031463806, 0.5741784173210945, 0.614094097314205, 0.5744402167088937, -10.0, 200.0] }, + "geometricError": 500.0, + "refine": "ADD", + "children": [ + { + "boundingVolume": { "region": [0.613745031463806, 0.5741784173210945, 0.614094097314205, 0.5744402167088937, -10.0, 200.0] }, + "geometricError": 500.0, + "refine": "ADD", + "content": { "uri": "./terrain/tileset.json" } + }, + { + "boundingVolume": { "region": [0.613745031463806, 0.5741784173210945, 0.614094097314205, 0.5744402167088937, -10.0, 200.0] }, + "geometricError": 500.0, + "refine": "ADD", + "content": { "uri": "./buildings/tileset.json" } + }, + { + "boundingVolume": { "region": [0.613745031463806, 0.5741784173210945, 0.614094097314205, 0.5744402167088937, -10.0, 200.0] }, + "geometricError": 500.0, + "refine": "ADD", + "content": { "uri": "./roof_furniture/tileset.json" } + }, + { + "boundingVolume": { "region": [0.613745031463806, 0.5741784173210945, 0.614094097314205, 0.5744402167088937, -10.0, 200.0] }, + "geometricError": 500.0, + "refine": "ADD", + "content": { "uri": "./trees/tileset.json" } + } + ] + } +} diff --git a/tests/helpers/tilesets/region.3tz b/tests/helpers/tilesets/region.3tz new file mode 100644 index 0000000000000000000000000000000000000000..88714e6eb669b9f3c5130603a69efabb11843b99 GIT binary patch literal 1362 zcmds1KTiTN6h8q?BqRsd7(x`df;G@4y*n`>#26>P00 z2p0!GfV+vSU&GhCLB!||y}b10_4j(eUtjxLjdUifsd0CH(7~t8x8sbaDa%1DWFs2T z(6|f)pQ3I6K04H==!(jK2@XCY20^pDj)qi>X((C0CZ&+DQpVW8upos&#}^Z0kq+dG>O< z`_Oij?SNHGKfb+n?$c{hd3pGF`mEhI>+@*ueLmR+L_AuJjZ`81cNa@gy7qXhyv6sh eoViTkJM#uQ;u4)!V{=OZQt(Y+eg%l8WxoInrCgi< literal 0 HcmV?d00001 diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index f640d2c..dcdbf34 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { jsLogger } from '@map-colonies/js-logger'; import { describe, beforeEach, it, expect, beforeAll, vi } from 'vitest'; import { trace } from '@opentelemetry/api'; @@ -10,8 +12,11 @@ import { initConfig } from '@src/common/config'; import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; import { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; +import { TilesetReader } from '@src/tileset/tilesetReader'; import { buildValidMetadata } from '@tests/helpers/metadata'; +const regionTilesetJson = readFileSync(join(__dirname, '../../helpers/tilesets/folder/tileset.json'), 'utf-8'); + const deletableRecord = { id: 'rec-1', productId: 'p-1', @@ -32,6 +37,7 @@ const jobnikStub = { createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), hasInFlightIngestionJob: vi.fn().mockResolvedValue(false), } as unknown as JobnikClient; +const tilesetReaderStub = { readTilesetJson: vi.fn().mockResolvedValue(regionTilesetJson) } as unknown as TilesetReader; const providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; const validMetadata = buildValidMetadata(); @@ -57,6 +63,7 @@ describe('record', function () { { token: LookupTablesCall, provider: { useValue: lookupStub } }, { token: CatalogCall, provider: { useValue: catalogStub } }, { token: JobnikClient, provider: { useValue: jobnikStub } }, + { token: TilesetReader, provider: { useValue: tilesetReaderStub } }, { token: SERVICES.PROVIDER, provider: { useValue: providerStub } }, ], useChild: true, diff --git a/tests/unit/tileset/tilesetReader.spec.ts b/tests/unit/tileset/tilesetReader.spec.ts new file mode 100644 index 0000000..42cb6fb --- /dev/null +++ b/tests/unit/tileset/tilesetReader.spec.ts @@ -0,0 +1,77 @@ +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { + TilesetReader, + ERROR_TILESET_NOT_FIRST_ENTRY, + ERROR_TILESET_READ_FAILED, + ERROR_TILESET_UNSUPPORTED_ENCODING, +} from '@src/tileset/tilesetReader'; +import { AppError } from '@src/common/appError'; + +const fixtures = join(__dirname, '../../helpers/tilesets'); + +const buildLocalHeader = (name: string, flags: number): Buffer => { + const nameBuffer = Buffer.from(name, 'utf-8'); + const header = Buffer.alloc(30 + nameBuffer.length); + header.writeUInt32LE(0x04034b50, 0); + header.writeUInt16LE(flags, 6); + header.writeUInt16LE(0, 8); + header.writeUInt32LE(0, 18); + header.writeUInt16LE(nameBuffer.length, 26); + header.writeUInt16LE(0, 28); + nameBuffer.copy(header, 30); + return header; +}; + +describe('TilesetReader', function () { + let reader: TilesetReader; + + beforeEach(async function () { + reader = new TilesetReader(await jsLogger({ enabled: false })); + }); + + it('should read tileset.json from the first entry of a 3tz archive.', async function () { + const content = await reader.readTilesetJson(join(fixtures, 'region.3tz'), 'tileset.json'); + const parsed = JSON.parse(content) as { root: { boundingVolume: { region?: number[] } } }; + + expect(parsed.root.boundingVolume.region).toBeDefined(); + }); + + it('should read tileset.json from a folder input.', async function () { + const content = await reader.readTilesetJson(join(fixtures, 'folder'), 'tileset.json'); + const parsed = JSON.parse(content) as { root: { boundingVolume: { region?: number[] } } }; + + expect(parsed.root.boundingVolume.region).toBeDefined(); + }); + + it('should throw a 400 when the requested entry is not the first entry of the archive.', async function () { + await expect(reader.readTilesetJson(join(fixtures, 'region.3tz'), 'other.json')).rejects.toThrow(ERROR_TILESET_NOT_FIRST_ENTRY); + }); + + it('should throw a 400 when the archive entry uses a data descriptor (size absent from local header).', async function () { + const dir = await mkdtemp(join(tmpdir(), 'tileset-reader-')); + const archive = join(dir, 'descriptor.3tz'); + await writeFile(archive, buildLocalHeader('tileset.json', 0x08)); + + try { + await expect(reader.readTilesetJson(archive, 'tileset.json')).rejects.toThrow(ERROR_TILESET_UNSUPPORTED_ENCODING); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('should throw when the folder tileset file is missing.', async function () { + let thrown: unknown; + try { + await reader.readTilesetJson(join(fixtures, 'folder'), 'missing.json'); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).message).toBe(ERROR_TILESET_READ_FAILED); + }); +}); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index d88ae0c..01c965f 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { jsLogger } from '@map-colonies/js-logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { StatusCodes } from 'http-status-codes'; @@ -12,16 +14,23 @@ import { ERROR_DELETE_STATUS, ERROR_FILE_NOT_FOUND, ERROR_PRODUCT_NAME_IN_FLIGHT, + ERROR_FOOTPRINT_FAR_FROM_MODEL, } from '@src/validator/validationManager'; import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; +import type { TilesetReader } from '@src/tileset/tilesetReader'; import type { Provider } from '@src/providers/interfaces'; import type { IngestionPayload } from '@src/record/models/recordManager'; import { buildValidMetadata } from '@tests/helpers/metadata'; +const regionTilesetJson = readFileSync(join(__dirname, '../../helpers/tilesets/folder/tileset.json'), 'utf-8'); +const boxTilesetJson = JSON.stringify({ root: { boundingVolume: { box: [0, 0, 0, 100, 0, 0, 0, 100, 0, 0, 0, 100] } } }); + const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; +const configStub = { get: vi.fn().mockReturnValue(10) } as unknown as ConfigType; const ingest = (metadata: Record): IngestionPayload => ({ modelPath: '/shared/models/afula', @@ -33,13 +42,23 @@ describe('ValidationManager', function () { let validator: ValidationManager; let catalogStub: CatalogCall; let jobnikStub: JobnikClient; + let tilesetReaderStub: TilesetReader; let providerStub: Provider; beforeEach(async function () { catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; jobnikStub = { hasInFlightIngestionJob: vi.fn().mockResolvedValue(false) } as unknown as JobnikClient; + tilesetReaderStub = { readTilesetJson: vi.fn().mockResolvedValue(regionTilesetJson) } as unknown as TilesetReader; providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; - validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub, jobnikStub, providerStub); + validator = new ValidationManager( + configStub, + await jsLogger({ enabled: false }), + lookupStub, + catalogStub, + jobnikStub, + tilesetReaderStub, + providerStub + ); }); it('should pass a fully valid ingestion payload', async function () { @@ -125,6 +144,38 @@ describe('ValidationManager', function () { await expect(validator.validateIngestion(ingest(buildValidMetadata()))).rejects.toThrow(ERROR_FILE_NOT_FOUND); }); + it('should throw 400 when the footprint does not intersect the tileset model', async function () { + const metadata = buildValidMetadata(); + metadata.footprint = { + type: 'Polygon', + coordinates: [ + [ + [10, 10], + [10.01, 10], + [10.01, 10.01], + [10, 10.01], + [10, 10], + ], + ], + }; + + await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(ERROR_FOOTPRINT_FAR_FROM_MODEL); + }); + + it('should throw 400 when the tileset bounding volume is an unsupported box', async function () { + (tilesetReaderStub.readTilesetJson as ReturnType).mockResolvedValueOnce(boxTilesetJson); + + let thrown: unknown; + try { + await validator.validateIngestion(ingest(buildValidMetadata())); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.BAD_REQUEST); + }); + describe('validateDelete', function () { it('should return the record when it is deletable', async function () { const record = { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'UNPUBLISHED' }; From 69d89f28ff13e12002b7f836c20698326bdae73e Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 13:07:17 +0300 Subject: [PATCH 07/14] refactor: gateway-style tileset validation result (MAPCO-11837) Return ValidationResponse {isValid, message} from the tileset polygon extraction and footprint-intersection checks (mirroring 3d-gateway), route every failure through a single rejectValidation() that logs the outcome and throws AppError so the message reaches the client. Move ValidationResponse to common/interfaces. Match 3d-gateway's coverage/far-from-model messages and add a coverage-below-threshold test. --- src/common/interfaces.ts | 5 ++ src/validator/validationManager.ts | 86 ++++++++++++------- .../unit/validator/validationManager.spec.ts | 27 ++++++ 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 1f49da0..6220fde 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -8,3 +8,8 @@ export interface LogContext { class: string; function?: string; } + +export interface ValidationResponse { + isValid: boolean; + message?: string; +} diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 821af7d..3dfdbf6 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -5,7 +5,7 @@ import { new3DLayerMetadataSchema, geometrySchema, calculatePolygonFromTileset, import { area, feature, featureCollection, intersect, union } from '@turf/turf'; import type { Feature, MultiPolygon, Polygon } from 'geojson'; import { SERVICES } from '@common/constants'; -import type { LogContext } from '@common/interfaces'; +import type { LogContext, ValidationResponse } from '@common/interfaces'; import { AppError } from '@common/appError'; import { buildModelFilePath } from '@common/util'; import type { ConfigType } from '@common/config'; @@ -33,8 +33,8 @@ export const ERROR_DELETE_PRODUCT_TYPE = 'Cannot delete a record whose productTy export const ERROR_DELETE_STATUS = 'Cannot delete a record whose productStatus is not "UNPUBLISHED"'; export const ERROR_FILE_NOT_FOUND = 'The model files do not exist in the agreed storage'; export const ERROR_TILESET_INVALID = 'The tileset file is not a valid 3DTiles tileset'; -export const ERROR_FOOTPRINT_FAR_FROM_MODEL = 'Invalid footprint! The footprint does not intersect the tileset model'; -export const ERROR_FOOTPRINT_COVERAGE = 'The footprint coverage of the tileset model is below the required threshold'; +export const ERROR_FOOTPRINT_FAR_FROM_MODEL = "Wrong footprint! footprint's coordinates is not even close to the model!"; +export const ERROR_INTERSECTION_FAILED = 'An error caused during the validation of the intersection'; @injectable() export class ValidationManager { @@ -95,6 +95,7 @@ export class ValidationManager { if (record.productType === BLOCKED_DELETE_PRODUCT_TYPE) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_PRODUCT_TYPE, true); } + if (record.productStatus !== RECORD_STATUS_UNPUBLISHED) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_STATUS, true); } @@ -107,6 +108,7 @@ export class ValidationManager { const path = buildModelFilePath(modelPath, tilesetFilename); const exists = await this.provider.fileExists(path); this.logger.debug({ msg: 'file existence validation', logContext, path, exists }); + if (!exists) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_FILE_NOT_FOUND, true); } @@ -133,45 +135,69 @@ export class ValidationManager { } private async validateTileset(payload: IngestionPayload, footprint: Polygon | MultiPolygon): Promise { - const logContext = { ...this.logContext, function: this.validateTileset.name }; const content = await this.tilesetReader.readTilesetJson(payload.modelPath, payload.tilesetFilename); - let modelPolygon: Polygon; + const { modelPolygon, response: polygonResponse } = this.getTilesetModelPolygon(content); + if (!polygonResponse.isValid || modelPolygon === undefined) { + this.rejectValidation(polygonResponse); + } + + const intersectionResponse = this.isFootprintAndModelIntersects(footprint, modelPolygon); + if (!intersectionResponse.isValid) { + this.rejectValidation(intersectionResponse); + } + } + + private getTilesetModelPolygon(content: string): { modelPolygon?: Polygon; response: ValidationResponse } { + const logContext = { ...this.logContext, function: this.getTilesetModelPolygon.name }; try { const tilesetJson = JSON.parse(content) as TileSetJson; - modelPolygon = calculatePolygonFromTileset(tilesetJson); + const modelPolygon = calculatePolygonFromTileset(tilesetJson); + this.logger.debug({ msg: 'extracted tileset model polygon', logContext, modelPolygon }); + + return { modelPolygon, response: { isValid: true } }; } catch (err) { const message = err instanceof Error ? err.message : ERROR_TILESET_INVALID; - this.logger.error({ msg: 'tileset polygon extraction failed', logContext, modelPath: payload.modelPath, err }); - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + this.logger.error({ msg: 'tileset polygon extraction failed', logContext, err }); + return { response: { isValid: false, message } }; } - - this.validateFootprintCoverage(footprint, modelPolygon); } - private validateFootprintCoverage(footprint: Polygon | MultiPolygon, modelPolygon: Polygon): void { - const logContext = { ...this.logContext, function: this.validateFootprintCoverage.name }; - const footprintFeature = feature(footprint); - const modelFeature: Feature = feature(modelPolygon); - - const intersection = intersect(featureCollection([footprintFeature, modelFeature])); - if (intersection === null) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_FOOTPRINT_FAR_FROM_MODEL, true); + private isFootprintAndModelIntersects(footprint: Polygon | MultiPolygon, modelPolygon: Polygon): ValidationResponse { + const logContext = { ...this.logContext, function: this.isFootprintAndModelIntersects.name }; + try { + const footprintFeature = feature(footprint); + const modelFeature: Feature = feature(modelPolygon); + + const intersection = intersect(featureCollection([footprintFeature, modelFeature])); + if (intersection === null) { + return { isValid: false, message: ERROR_FOOTPRINT_FAR_FROM_MODEL }; + } + + const combined = union(featureCollection([footprintFeature, modelFeature])); + const combinedArea = combined === null ? 0 : area(combined); + const coverage = combinedArea === 0 ? 0 : (FULL_COVERAGE_PERCENT * area(footprintFeature)) / combinedArea; + this.logger.debug({ msg: 'calculated footprint coverage of the model', logContext, coverage, percentageLimit: this.percentageLimit }); + + if (coverage < this.percentageLimit) { + return { + isValid: false, + message: `The footprint intersectection with the model doesn't reach minimum required threshhold, the coverage is: ${coverage}% when the minimum coverage is ${this.percentageLimit}%`, + }; + } + return { isValid: true }; + } catch (err) { + this.logger.error({ msg: ERROR_INTERSECTION_FAILED, logContext, err }); + return { isValid: false, message: ERROR_INTERSECTION_FAILED }; } + } - const combined = union(featureCollection([footprintFeature, modelFeature])); - const combinedArea = combined === null ? 0 : area(combined); - const coverage = combinedArea === 0 ? 0 : (FULL_COVERAGE_PERCENT * area(footprintFeature)) / combinedArea; - this.logger.debug({ msg: 'footprint coverage validation', logContext, coverage, percentageLimit: this.percentageLimit }); + private rejectValidation(response: ValidationResponse): never { + const logContext = { ...this.logContext, function: this.rejectValidation.name }; + const message = response.message ?? ERROR_TILESET_INVALID; + this.logger.warn({ msg: 'ingestion validation failed', logContext, isValid: response.isValid, message }); - if (coverage < this.percentageLimit) { - throw new AppError( - 'badRequest', - StatusCodes.BAD_REQUEST, - `${ERROR_FOOTPRINT_COVERAGE}. coverage: ${coverage}%, minimum: ${this.percentageLimit}%`, - true - ); - } + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); } private validateDates(start: unknown, end: unknown): void { diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index 01c965f..18ef9aa 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -162,6 +162,33 @@ describe('ValidationManager', function () { await expect(validator.validateIngestion(ingest(metadata))).rejects.toThrow(ERROR_FOOTPRINT_FAR_FROM_MODEL); }); + it('should throw 400 when the footprint coverage of the model is below the threshold', async function () { + const metadata = buildValidMetadata(); + metadata.footprint = { + type: 'Polygon', + coordinates: [ + [ + [35.17, 32.9], + [35.172, 32.9], + [35.172, 32.902], + [35.17, 32.902], + [35.17, 32.9], + ], + ], + }; + + let thrown: unknown; + try { + await validator.validateIngestion(ingest(metadata)); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.BAD_REQUEST); + expect((thrown as AppError).message).toMatch(/minimum required threshhold/); + }); + it('should throw 400 when the tileset bounding volume is an unsupported box', async function () { (tilesetReaderStub.readTilesetJson as ReturnType).mockResolvedValueOnce(boxTilesetJson); From 2dfaf1d7d74916d21b4a1b720d1cd14170858084 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 13:29:06 +0300 Subject: [PATCH 08/14] feat: wire update, publish/unpublish and job status to real backends (MAPCO-11833) Replace the stub PATCH handlers and add a job-status endpoint, making the service a real successor to 3d-gateway + store-trigger: - PATCH /record/{id}: validate the update (record exists, not being-deleted, v2 partial schema, footprint, classification, unique name), reject records that exist in extractable-management (409), convert the footprint to 2D and patch the catalog. - PATCH /record/status/{id}: validate the record, extractable guard, then change status in the catalog. - GET /jobs/{jobId}: return the Jobnik job status and completion percentage. Add CatalogCall getRecord/patchMetadata/changeStatus, an ExtractableCall client (externalServices.extractable), JobnikClient.getJobStatus, and a job module (model/controller/route mounted at /jobs). Unit and integration tests included. --- config/custom-environment-variables.json | 1 + config/default.json | 1 + helm/templates/configmap.yaml | 1 + helm/values.yaml | 1 + openapi3.yaml | 37 ++++++++++ src/common/config.ts | 4 +- src/common/util.ts | 9 +++ src/containerConfig.ts | 2 + src/externalServices/catalog/catalogCall.ts | 40 +++++++++- src/externalServices/catalog/interfaces.ts | 6 ++ .../extractableManagement/extractableCall.ts | 39 ++++++++++ src/externalServices/jobnik/jobnikClient.ts | 20 +++++ src/job/controllers/jobController.ts | 34 +++++++++ src/job/models/jobManager.ts | 29 ++++++++ src/job/routes/jobRouter.ts | 16 ++++ src/openapi.d.ts | 55 ++++++++++++++ src/record/controllers/recordController.ts | 8 +- src/record/models/recordManager.ts | 28 +++++-- src/serverBuilder.ts | 5 +- src/validator/validationManager.ts | 72 ++++++++++++++++++ tests/integration/record/record.spec.ts | 21 ++++++ .../unit/record/models/recordManager.spec.ts | 22 ++++-- .../unit/validator/validationManager.spec.ts | 74 ++++++++++++++++++- 23 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 src/externalServices/extractableManagement/extractableCall.ts create mode 100644 src/job/controllers/jobController.ts create mode 100644 src/job/models/jobManager.ts create mode 100644 src/job/routes/jobRouter.ts diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 30bd2cd..777ce0d 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -1,6 +1,7 @@ { "externalServices": { "catalog": "CATALOG_URL", + "extractable": "EXTRACTABLE_URL", "lookupTables": { "url": "LOOKUP_TABLES_URL", "subUrl": "LOOKUP_TABLES_SUB_URL" diff --git a/config/default.json b/config/default.json index 8de50f9..d383775 100644 --- a/config/default.json +++ b/config/default.json @@ -35,6 +35,7 @@ }, "externalServices": { "catalog": "http://127.0.0.1:8080", + "extractable": "http://127.0.0.1:8080", "lookupTables": { "url": "http://127.0.0.1:8080", "subUrl": "lookup-tables/lookupData" diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 9b572c3..5fde69a 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -32,6 +32,7 @@ data: npm_config_cache: /tmp/ {{- with .Values.externalServices }} CATALOG_URL: {{ .catalog | quote }} + EXTRACTABLE_URL: {{ .extractable | quote }} {{- with .lookupTables }} LOOKUP_TABLES_URL: {{ .url | quote }} LOOKUP_TABLES_SUB_URL: {{ .subUrl | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index e08d90b..6d85078 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -80,6 +80,7 @@ server: externalServices: catalog: '' + extractable: '' lookupTables: url: '' subUrl: '' diff --git a/openapi3.yaml b/openapi3.yaml index 74a26a0..01a0f62 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -126,6 +126,27 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + /jobs/{jobId}: + get: + operationId: getJobStatus + tags: + - job + summary: Get the status and progress of a job + parameters: + - $ref: '#/components/parameters/jobId' + responses: + 200: + description: Job status + content: + application/json: + schema: + $ref: '#/components/schemas/jobStatusResponse' + 404: + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' security: - {} components: @@ -137,6 +158,13 @@ components: description: The record identifier schema: type: string + jobId: + name: jobId + in: path + required: true + description: The job identifier + schema: + type: string schemas: error: type: object @@ -193,3 +221,12 @@ components: properties: message: type: string + jobStatusResponse: + type: object + required: + - status + properties: + status: + type: string + percentage: + type: number diff --git a/src/common/config.ts b/src/common/config.ts index 8d3292d..4f46eb4 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -9,6 +9,7 @@ interface LookupTablesConfig { interface ExternalServicesConfig { lookupTables: LookupTablesConfig; catalog: string; + extractable: string; } interface JobManagerConfig { @@ -98,7 +99,7 @@ const opsTriggerConfigSchema = { }, externalServices: { type: 'object', - required: ['lookupTables', 'catalog'], + required: ['lookupTables', 'catalog', 'extractable'], properties: { lookupTables: { type: 'object', @@ -109,6 +110,7 @@ const opsTriggerConfigSchema = { }, }, catalog: { type: 'string' }, + extractable: { type: 'string' }, }, }, jobManager: { diff --git a/src/common/util.ts b/src/common/util.ts index 542af20..f4b4f2f 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -1,4 +1,13 @@ +import type { Polygon, Position } from 'geojson'; + +const TWO_DIMENSIONS = 2; + export const is3tz = (modelPath: string): boolean => modelPath.toLowerCase().endsWith('.3tz'); export const buildModelFilePath = (modelPath: string, tilesetFilename: string): string => is3tz(modelPath) ? modelPath : `${modelPath}/${tilesetFilename}`; + +export const convertPolygonTo2DPolygon = (polygon: Polygon): Polygon => ({ + ...polygon, + coordinates: polygon.coordinates.map((ring: Position[]) => ring.map((coordinate: Position) => coordinate.slice(0, TWO_DIMENSIONS))), +}); diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 3dbe41f..8a4bf69 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -7,6 +7,7 @@ import { type InjectionObject, registerDependencies } from '@common/dependencyRe import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; import { recordRouterFactory, RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; +import { jobRouterFactory, JOB_ROUTER_SYMBOL } from './job/routes/jobRouter'; import { providerFactory } from './providers/getProvider'; import { getConfig } from './common/config'; @@ -33,6 +34,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, { token: SERVICES.PROVIDER, provider: { useFactory: providerFactory } }, { token: RECORD_ROUTER_SYMBOL, provider: { useFactory: recordRouterFactory } }, + { token: JOB_ROUTER_SYMBOL, provider: { useFactory: jobRouterFactory } }, { token: 'onSignal', provider: { diff --git a/src/externalServices/catalog/catalogCall.ts b/src/externalServices/catalog/catalogCall.ts index 3f0fd9e..8e8c585 100644 --- a/src/externalServices/catalog/catalogCall.ts +++ b/src/externalServices/catalog/catalogCall.ts @@ -6,7 +6,7 @@ import { SERVICES } from '@common/constants'; import { AppError } from '@common/appError'; import type { ConfigType } from '@common/config'; import type { LogContext } from '@common/interfaces'; -import type { IFindRecordsPayload, Record3D } from './interfaces'; +import type { CatalogStatusPayload, CatalogUpdatePayload, IFindRecordsPayload, Record3D } from './interfaces'; @injectable() export class CatalogCall { @@ -50,4 +50,42 @@ export class CatalogCall { throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog find', true); } } + + public async getRecord(identifier: string): Promise { + const logContext = { ...this.logContext, function: this.getRecord.name }; + this.logger.debug({ msg: `Getting record ${identifier} from catalog`, logContext }); + try { + const response = await axios.get(`${this.catalog}/metadata/${identifier}`, { + validateStatus: (status) => status === StatusCodes.OK.valueOf() || status === StatusCodes.NOT_FOUND.valueOf(), + }); + return response.status === StatusCodes.NOT_FOUND.valueOf() ? undefined : response.data; + } catch (err) { + this.logger.error({ msg: 'Something went wrong in catalog when getting a record', logContext, identifier, err }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during record lookup', true); + } + } + + public async patchMetadata(identifier: string, payload: CatalogUpdatePayload): Promise { + const logContext = { ...this.logContext, function: this.patchMetadata.name }; + this.logger.debug({ msg: `Updating metadata for record ${identifier} in catalog`, logContext }); + try { + const response = await axios.patch(`${this.catalog}/metadata/${identifier}`, payload); + return response.data; + } catch (err) { + this.logger.error({ msg: 'Something went wrong in catalog when updating metadata', logContext, identifier, err }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during metadata update', true); + } + } + + public async changeStatus(identifier: string, payload: CatalogStatusPayload): Promise { + const logContext = { ...this.logContext, function: this.changeStatus.name }; + this.logger.debug({ msg: `Changing status for record ${identifier} in catalog`, logContext }); + try { + const response = await axios.patch(`${this.catalog}/metadata/status/${identifier}`, payload); + return response.data; + } catch (err) { + this.logger.error({ msg: 'Something went wrong in catalog when changing status', logContext, identifier, err }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during status change', true); + } + } } diff --git a/src/externalServices/catalog/interfaces.ts b/src/externalServices/catalog/interfaces.ts index 0b0609b..e3987df 100644 --- a/src/externalServices/catalog/interfaces.ts +++ b/src/externalServices/catalog/interfaces.ts @@ -17,3 +17,9 @@ export interface IFindRecordsPayload { classification?: string; productStatus?: string; } + +export type CatalogUpdatePayload = Record; + +export interface CatalogStatusPayload { + productStatus: string; +} diff --git a/src/externalServices/extractableManagement/extractableCall.ts b/src/externalServices/extractableManagement/extractableCall.ts new file mode 100644 index 0000000..02dbd22 --- /dev/null +++ b/src/externalServices/extractableManagement/extractableCall.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import { AppError } from '@common/appError'; +import type { ConfigType } from '@common/config'; +import type { LogContext } from '@common/interfaces'; + +@injectable() +export class ExtractableCall { + private readonly logContext: LogContext; + private readonly extractable: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.extractable = this.config.get('externalServices.extractable'); + this.logContext = { + fileName: __filename, + class: ExtractableCall.name, + }; + } + + public async isExtractableRecordExists(recordName: string): Promise { + const logContext = { ...this.logContext, function: this.isExtractableRecordExists.name }; + this.logger.debug({ msg: `Checking record '${recordName}' in extractable service`, logContext }); + try { + const response = await axios.get(`${this.extractable}/records/${encodeURIComponent(recordName)}`, { + validateStatus: (status) => status === StatusCodes.OK.valueOf() || status === StatusCodes.NOT_FOUND.valueOf(), + }); + return response.status === StatusCodes.OK.valueOf(); + } catch (err) { + this.logger.error({ msg: 'Something went wrong in extractable when checking a record', logContext, recordName, err }); + throw new AppError('extractable', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with extractable during record lookup', true); + } + } +} diff --git a/src/externalServices/jobnik/jobnikClient.ts b/src/externalServices/jobnik/jobnikClient.ts index 593ce4e..0a9104e 100644 --- a/src/externalServices/jobnik/jobnikClient.ts +++ b/src/externalServices/jobnik/jobnikClient.ts @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes'; import type { Logger } from '@map-colonies/js-logger'; import type { Registry } from 'prom-client'; import { JobnikSDK } from '@map-colonies/jobnik-sdk'; +import type { JobId } from '@map-colonies/jobnik-sdk'; import { IN_FLIGHT_JOB_STATUSES, SERVICES, STAGE_TYPES } from '@common/constants'; import { is3tz } from '@common/util'; import { AppError } from '@common/appError'; @@ -18,6 +19,11 @@ interface StageDescriptor { only3tz?: boolean; } +export interface JobStatusResponse { + status: string; + percentage?: number; +} + @singleton() export class JobnikClient { private readonly logContext: LogContext; @@ -69,6 +75,20 @@ export class JobnikClient { } } + public async getJobStatus(jobId: string): Promise { + const logContext = { ...this.logContext, function: this.getJobStatus.name }; + const { data, error, response } = await this.apiClient.GET('/v1/jobs/{jobId}', { params: { path: { jobId: jobId as JobId } } }); + if (error !== undefined) { + if (response.status === StatusCodes.NOT_FOUND.valueOf()) { + throw new AppError('badRequest', StatusCodes.NOT_FOUND, `job ${jobId} was not found`, true); + } + this.logger.error({ msg: 'failed querying Jobnik for job status', logContext, jobId, err: error }); + throw new AppError('jobnik', StatusCodes.INTERNAL_SERVER_ERROR, 'failed querying Jobnik for job status', false); + } + + return { status: data.status, percentage: data.percentage }; + } + public async createIngestionJob(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.createIngestionJob.name }; const isArchive = is3tz(payload.modelPath); diff --git a/src/job/controllers/jobController.ts b/src/job/controllers/jobController.ts new file mode 100644 index 0000000..64148ce --- /dev/null +++ b/src/job/controllers/jobController.ts @@ -0,0 +1,34 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import type { TypedRequestHandlers } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { JobManager } from '../models/jobManager'; + +@injectable() +export class JobController { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(JobManager) private readonly manager: JobManager + ) { + this.logContext = { + fileName: __filename, + class: JobController.name, + }; + } + + public getJobStatus: TypedRequestHandlers['getJobStatus'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.getJobStatus.name }; + const { jobId } = req.params; + try { + const status = await this.manager.getStatus(jobId); + return res.status(StatusCodes.OK).json(status); + } catch (err) { + this.logger.error({ msg: 'failed to get job status', logContext, err, jobId }); + return next(err); + } + }; +} diff --git a/src/job/models/jobManager.ts b/src/job/models/jobManager.ts new file mode 100644 index 0000000..f2767ce --- /dev/null +++ b/src/job/models/jobManager.ts @@ -0,0 +1,29 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { inject, injectable } from 'tsyringe'; +import type { components } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { JobnikClient } from '../../externalServices/jobnik/jobnikClient'; + +export type JobStatusResponse = components['schemas']['jobStatusResponse']; + +@injectable() +export class JobManager { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(JobnikClient) private readonly jobnik: JobnikClient + ) { + this.logContext = { + fileName: __filename, + class: JobManager.name, + }; + } + + public async getStatus(jobId: string): Promise { + const logContext = { ...this.logContext, function: this.getStatus.name }; + this.logger.info({ msg: 'fetching job status', logContext, jobId }); + return this.jobnik.getJobStatus(jobId); + } +} diff --git a/src/job/routes/jobRouter.ts b/src/job/routes/jobRouter.ts new file mode 100644 index 0000000..0c646d7 --- /dev/null +++ b/src/job/routes/jobRouter.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { JobController } from '../controllers/jobController'; + +const jobRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(JobController); + + router.get('/:jobId', controller.getJobStatus); + + return router; +}; + +export const JOB_ROUTER_SYMBOL = Symbol('jobRouterFactory'); + +export { jobRouterFactory }; diff --git a/src/openapi.d.ts b/src/openapi.d.ts index ca8c610..b0d39d3 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -62,6 +62,23 @@ export type paths = { patch: operations['updateRecordStatus']; trace?: never; }; + '/jobs/{jobId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the status and progress of a job */ + get: operations['getJobStatus']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; }; export type webhooks = Record; export type components = { @@ -94,11 +111,17 @@ export type components = { ackResponse: { message: string; }; + jobStatusResponse: { + status: string; + percentage?: number; + }; }; responses: never; parameters: { /** @description The record identifier */ recordId: string; + /** @description The job identifier */ + jobId: string; }; requestBodies: never; headers: never; @@ -270,5 +293,37 @@ export interface operations { }; }; }; + getJobStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The job identifier */ + jobId: components['parameters']['jobId']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Job status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['jobStatusResponse']; + }; + }; + /** @description Job not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + }; + }; } export type TypedRequestHandlers = ImportedTypedRequestHandlers; diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts index af0b6ee..db8ea86 100644 --- a/src/record/controllers/recordController.ts +++ b/src/record/controllers/recordController.ts @@ -52,11 +52,11 @@ export class RecordController { } }; - public updateRecord: TypedRequestHandlers['updateRecord'] = (req, res, next) => { + public updateRecord: TypedRequestHandlers['updateRecord'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.updateRecord.name }; const { id } = req.params; try { - const ack = this.manager.updateMetadata(id, req.body); + const ack = await this.manager.updateMetadata(id, req.body); return res.status(StatusCodes.OK).json(ack); } catch (err) { this.logger.error({ msg: 'failed to update record metadata', logContext, err, recordId: id }); @@ -64,11 +64,11 @@ export class RecordController { } }; - public updateRecordStatus: TypedRequestHandlers['updateRecordStatus'] = (req, res, next) => { + public updateRecordStatus: TypedRequestHandlers['updateRecordStatus'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.updateRecordStatus.name }; const { id } = req.params; try { - const ack = this.manager.updateStatus(id, req.body); + const ack = await this.manager.updateStatus(id, req.body); return res.status(StatusCodes.OK).json(ack); } catch (err) { this.logger.error({ msg: 'failed to update record status', logContext, err, recordId: id }); diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts index 37867d8..d1369fc 100644 --- a/src/record/models/recordManager.ts +++ b/src/record/models/recordManager.ts @@ -1,10 +1,13 @@ import type { Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; +import type { Polygon } from 'geojson'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; +import { convertPolygonTo2DPolygon } from '@common/util'; import { ValidationManager } from '../../validator/validationManager'; import { JobnikClient } from '../../externalServices/jobnik/jobnikClient'; +import { CatalogCall } from '../../externalServices/catalog/catalogCall'; export type IngestionPayload = components['schemas']['ingestionPayload']; export type UpdatePayload = components['schemas']['updatePayload']; @@ -19,7 +22,8 @@ export class RecordManager { public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(ValidationManager) private readonly validator: ValidationManager, - @inject(JobnikClient) private readonly jobnik: JobnikClient + @inject(JobnikClient) private readonly jobnik: JobnikClient, + @inject(CatalogCall) private readonly catalog: CatalogCall ) { this.logContext = { fileName: __filename, @@ -41,15 +45,29 @@ export class RecordManager { return this.jobnik.createDeleteJob(record); } - public updateMetadata(id: string, update: UpdatePayload): AckResponse { + public async updateMetadata(id: string, update: UpdatePayload): Promise { const logContext = { ...this.logContext, function: this.updateMetadata.name }; - this.logger.info({ msg: 'updating record metadata', logContext, recordId: id, fields: Object.keys(update) }); - return { message: `metadata update accepted for record ${id}` }; + this.logger.info({ msg: 'updating record metadata', logContext, recordId: id }); + const record = await this.validator.validateUpdate(id, update); + await this.validator.ensureRecordAbsentFromExtractable(record); + + const payload: Record = { ...update }; + if (payload.footprint !== undefined) { + payload.footprint = convertPolygonTo2DPolygon(payload.footprint as Polygon); + } + await this.catalog.patchMetadata(id, payload); + + return { message: `metadata updated for record ${id}` }; } - public updateStatus(id: string, payload: StatusPayload): AckResponse { + public async updateStatus(id: string, payload: StatusPayload): Promise { const logContext = { ...this.logContext, function: this.updateStatus.name }; this.logger.info({ msg: 'updating record status', logContext, recordId: id, status: payload.status }); + const record = await this.validator.validateStatusChange(id); + await this.validator.ensureRecordAbsentFromExtractable(record); + + await this.catalog.changeStatus(id, { productStatus: payload.status }); + return { message: `status ${payload.status} accepted for record ${id}` }; } } diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 3e2fc98..eff4ee4 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -11,6 +11,7 @@ import { Registry } from 'prom-client'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; import { RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; +import { JOB_ROUTER_SYMBOL } from './job/routes/jobRouter'; @injectable() export class ServerBuilder { @@ -20,7 +21,8 @@ export class ServerBuilder { @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(RECORD_ROUTER_SYMBOL) private readonly recordRouter: Router + @inject(RECORD_ROUTER_SYMBOL) private readonly recordRouter: Router, + @inject(JOB_ROUTER_SYMBOL) private readonly jobRouter: Router ) { this.serverInstance = express(); } @@ -44,6 +46,7 @@ export class ServerBuilder { private buildRoutes(): void { this.serverInstance.use('/record', this.recordRouter); + this.serverInstance.use('/jobs', this.jobRouter); this.buildDocsRoutes(); } diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 3dfdbf6..72d40a7 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -4,6 +4,7 @@ import { inject, injectable } from 'tsyringe'; import { new3DLayerMetadataSchema, geometrySchema, calculatePolygonFromTileset, type TileSetJson } from '@map-colonies/3d-shared'; import { area, feature, featureCollection, intersect, union } from '@turf/turf'; import type { Feature, MultiPolygon, Polygon } from 'geojson'; +import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext, ValidationResponse } from '@common/interfaces'; import { AppError } from '@common/appError'; @@ -12,15 +13,19 @@ import type { ConfigType } from '@common/config'; import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; import { CatalogCall } from '../externalServices/catalog/catalogCall'; import { JobnikClient } from '../externalServices/jobnik/jobnikClient'; +import { ExtractableCall } from '../externalServices/extractableManagement/extractableCall'; import { TilesetReader } from '../tileset/tilesetReader'; import type { Record3D } from '../externalServices/catalog/interfaces'; import type { Provider } from '../providers/interfaces'; import type { IngestionPayload } from '../record/models/recordManager'; +type UpdatePayload = components['schemas']['updatePayload']; + const FULL_COVERAGE_PERCENT = 100; const BLOCKED_DELETE_PRODUCT_TYPE = 'QuantizedMeshDTMBest'; const RECORD_STATUS_UNPUBLISHED = 'UNPUBLISHED'; +const RECORD_STATUS_BEING_DELETED = 'BEING_DELETED'; export const ERROR_METADATA_DATE = 'imagingTimeBeginUTC must not be later than imagingTimeEndUTC'; export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC are required'; @@ -35,6 +40,9 @@ export const ERROR_FILE_NOT_FOUND = 'The model files do not exist in the agreed export const ERROR_TILESET_INVALID = 'The tileset file is not a valid 3DTiles tileset'; export const ERROR_FOOTPRINT_FAR_FROM_MODEL = "Wrong footprint! footprint's coordinates is not even close to the model!"; export const ERROR_INTERSECTION_FAILED = 'An error caused during the validation of the intersection'; +export const ERROR_RECORD_NOT_FOUND = "record with the given identifier doesn't exist"; +export const ERROR_RECORD_BEING_DELETED = 'cannot change a record that is being deleted'; +export const ERROR_EXTRACTABLE_CONFLICT = 'the record exists in the extractable-management service and cannot be changed here'; @injectable() export class ValidationManager { @@ -48,6 +56,7 @@ export class ValidationManager { @inject(CatalogCall) private readonly catalog: CatalogCall, @inject(JobnikClient) private readonly jobnik: JobnikClient, @inject(TilesetReader) private readonly tilesetReader: TilesetReader, + @inject(ExtractableCall) private readonly extractable: ExtractableCall, @inject(SERVICES.PROVIDER) private readonly provider: Provider ) { this.percentageLimit = this.config.get('validation.percentageLimit'); @@ -103,6 +112,69 @@ export class ValidationManager { return record; } + public async validateUpdate(identifier: string, payload: UpdatePayload): Promise { + const record = await this.catalog.getRecord(identifier); + if (record === undefined) { + throw new AppError('notFound', StatusCodes.NOT_FOUND, ERROR_RECORD_NOT_FOUND, true); + } + if (record.productStatus === RECORD_STATUS_BEING_DELETED) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_RECORD_BEING_DELETED, true); + } + + const parsed = new3DLayerMetadataSchema.partial().safeParse(payload); + if (!parsed.success) { + const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } + + const footprint = (payload as { footprint?: unknown }).footprint; + if (footprint !== undefined && !geometrySchema.safeParse(footprint).success) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); + } + + const data = parsed.data; + if (data.classification !== undefined) { + await this.validateClassification(data.classification); + } + if (data.productName !== undefined) { + await this.validateProductNameUniqueExcept(data.productName, identifier); + } + + return record; + } + + public async validateStatusChange(identifier: string): Promise { + const record = await this.catalog.getRecord(identifier); + if (record === undefined) { + throw new AppError('notFound', StatusCodes.NOT_FOUND, ERROR_RECORD_NOT_FOUND, true); + } + if (record.productStatus === RECORD_STATUS_BEING_DELETED) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_RECORD_BEING_DELETED, true); + } + + return record; + } + + public async ensureRecordAbsentFromExtractable(record: Record3D): Promise { + const logContext = { ...this.logContext, function: this.ensureRecordAbsentFromExtractable.name }; + if (record.productName === undefined) { + return; + } + + const exists = await this.extractable.isExtractableRecordExists(record.productName); + this.logger.debug({ msg: 'extractable existence validation', logContext, productName: record.productName, exists }); + if (exists) { + throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_EXTRACTABLE_CONFLICT, true); + } + } + + private async validateProductNameUniqueExcept(productName: string, identifier: string): Promise { + const records = await this.catalog.findRecords({ productName }); + if (records.some((existing) => existing.id !== identifier)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); + } + } + private async validateFileExists(modelPath: string, tilesetFilename: string): Promise { const logContext = { ...this.logContext, function: this.validateFileExists.name }; const path = buildModelFilePath(modelPath, tilesetFilename); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index dcdbf34..a3ee84f 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -13,6 +13,7 @@ import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTable import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; import { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import { TilesetReader } from '@src/tileset/tilesetReader'; +import { ExtractableCall } from '@src/externalServices/extractableManagement/extractableCall'; import { buildValidMetadata } from '@tests/helpers/metadata'; const regionTilesetJson = readFileSync(join(__dirname, '../../helpers/tilesets/folder/tileset.json'), 'utf-8'); @@ -31,13 +32,18 @@ const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } // findRecords by id (delete) → an existing deletable record; by productName (uniqueness) → none const catalogStub = { findRecords: vi.fn().mockImplementation((payload: { id?: string }) => (payload.id !== undefined ? [deletableRecord] : [])), + getRecord: vi.fn().mockResolvedValue(deletableRecord), + patchMetadata: vi.fn().mockResolvedValue(deletableRecord), + changeStatus: vi.fn().mockResolvedValue(deletableRecord), } as unknown as CatalogCall; const jobnikStub = { createIngestionJob: vi.fn().mockResolvedValue({ jobId: 'job-1', status: 'PENDING' }), createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), hasInFlightIngestionJob: vi.fn().mockResolvedValue(false), + getJobStatus: vi.fn().mockResolvedValue({ status: 'IN_PROGRESS', percentage: 42 }), } as unknown as JobnikClient; const tilesetReaderStub = { readTilesetJson: vi.fn().mockResolvedValue(regionTilesetJson) } as unknown as TilesetReader; +const extractableStub = { isExtractableRecordExists: vi.fn().mockResolvedValue(false) } as unknown as ExtractableCall; const providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; const validMetadata = buildValidMetadata(); @@ -64,6 +70,7 @@ describe('record', function () { { token: CatalogCall, provider: { useValue: catalogStub } }, { token: JobnikClient, provider: { useValue: jobnikStub } }, { token: TilesetReader, provider: { useValue: tilesetReaderStub } }, + { token: ExtractableCall, provider: { useValue: extractableStub } }, { token: SERVICES.PROVIDER, provider: { useValue: providerStub } }, ], useChild: true, @@ -146,4 +153,18 @@ describe('record', function () { expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); }); }); + + describe('GET /jobs/{jobId}', function () { + it('should return 200 with the job status and percentage', async function () { + const response = await requestSender.getJobStatus({ pathParams: { jobId: 'job-1' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body as paths['/jobs/{jobId}']['get']['responses']['200']['content']['application/json']; + + expect(body.status).toBeTypeOf('string'); + expect(body.percentage).toBe(42); + }); + }); }); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts index 403dc42..bf5d1bd 100644 --- a/tests/unit/record/models/recordManager.spec.ts +++ b/tests/unit/record/models/recordManager.spec.ts @@ -3,21 +3,31 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; import type { ValidationManager } from '@src/validator/validationManager'; import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; +import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; const noopValidator = { validateIngestion: vi.fn().mockResolvedValue(undefined), validateDelete: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), + validateUpdate: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), + validateStatusChange: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), + ensureRecordAbsentFromExtractable: vi.fn().mockResolvedValue(undefined), } as unknown as ValidationManager; const jobnikStub = { createIngestionJob: vi.fn().mockResolvedValue({ jobId: 'job-1', status: 'PENDING' }), createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), } as unknown as JobnikClient; +const patchMetadataMock = vi.fn().mockResolvedValue({ id: 'rec-1' }); +const changeStatusMock = vi.fn().mockResolvedValue({ id: 'rec-1' }); +const catalogStub = { + patchMetadata: patchMetadataMock, + changeStatus: changeStatusMock, +} as unknown as CatalogCall; describe('RecordManager', function () { let manager: RecordManager; beforeEach(async function () { - manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator, jobnikStub); + manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator, jobnikStub, catalogStub); }); describe('createIngestion', function () { @@ -45,17 +55,19 @@ describe('RecordManager', function () { }); describe('updateMetadata', function () { - it('should return an ack referencing the record id', function () { - const result = manager.updateMetadata('rec-1', { description: 'x' }); + it('should validate, guard extractable, patch the catalog and return an ack', async function () { + const result = await manager.updateMetadata('rec-1', { description: 'x' }); + expect(patchMetadataMock).toHaveBeenCalledWith('rec-1', expect.objectContaining({ description: 'x' })); expect(result.message).toContain('rec-1'); }); }); describe('updateStatus', function () { - it('should return an ack referencing the requested status', function () { - const result = manager.updateStatus('rec-1', { status: 'PUBLISHED' }); + it('should validate, guard extractable, change the catalog status and return an ack', async function () { + const result = await manager.updateStatus('rec-1', { status: 'PUBLISHED' }); + expect(changeStatusMock).toHaveBeenCalledWith('rec-1', { productStatus: 'PUBLISHED' }); expect(result.message).toContain('PUBLISHED'); }); }); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index 18ef9aa..9e089ac 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -15,6 +15,9 @@ import { ERROR_FILE_NOT_FOUND, ERROR_PRODUCT_NAME_IN_FLIGHT, ERROR_FOOTPRINT_FAR_FROM_MODEL, + ERROR_RECORD_NOT_FOUND, + ERROR_RECORD_BEING_DELETED, + ERROR_EXTRACTABLE_CONFLICT, } from '@src/validator/validationManager'; import { AppError } from '@src/common/appError'; import type { ConfigType } from '@src/common/config'; @@ -22,6 +25,7 @@ import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookup import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import type { TilesetReader } from '@src/tileset/tilesetReader'; +import type { ExtractableCall } from '@src/externalServices/extractableManagement/extractableCall'; import type { Provider } from '@src/providers/interfaces'; import type { IngestionPayload } from '@src/record/models/recordManager'; import { buildValidMetadata } from '@tests/helpers/metadata'; @@ -43,12 +47,17 @@ describe('ValidationManager', function () { let catalogStub: CatalogCall; let jobnikStub: JobnikClient; let tilesetReaderStub: TilesetReader; + let extractableStub: ExtractableCall; let providerStub: Provider; beforeEach(async function () { - catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; + catalogStub = { + findRecords: vi.fn().mockResolvedValue([]), + getRecord: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), + } as unknown as CatalogCall; jobnikStub = { hasInFlightIngestionJob: vi.fn().mockResolvedValue(false) } as unknown as JobnikClient; tilesetReaderStub = { readTilesetJson: vi.fn().mockResolvedValue(regionTilesetJson) } as unknown as TilesetReader; + extractableStub = { isExtractableRecordExists: vi.fn().mockResolvedValue(false) } as unknown as ExtractableCall; providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; validator = new ValidationManager( configStub, @@ -57,6 +66,7 @@ describe('ValidationManager', function () { catalogStub, jobnikStub, tilesetReaderStub, + extractableStub, providerStub ); }); @@ -203,6 +213,68 @@ describe('ValidationManager', function () { expect((thrown as AppError).status).toBe(StatusCodes.BAD_REQUEST); }); + describe('validateUpdate', function () { + it('should return the record for a valid update payload', async function () { + await expect(validator.validateUpdate('rec-1', { description: 'x' })).resolves.toEqual({ id: 'rec-1', productName: 'afula' }); + }); + + it('should throw 404 when the record does not exist', async function () { + (catalogStub.getRecord as ReturnType).mockResolvedValueOnce(undefined); + + let thrown: unknown; + try { + await validator.validateUpdate('missing', { description: 'x' }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.NOT_FOUND); + expect((thrown as AppError).message).toBe(ERROR_RECORD_NOT_FOUND); + }); + + it('should throw 400 when the record is being deleted', async function () { + (catalogStub.getRecord as ReturnType).mockResolvedValueOnce({ id: 'rec-1', productStatus: 'BEING_DELETED' }); + + await expect(validator.validateUpdate('rec-1', { description: 'x' })).rejects.toThrow(ERROR_RECORD_BEING_DELETED); + }); + + it('should throw 400 when a renamed product name collides with another record', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([{ id: 'other', productName: 'haifa' }]); + + await expect(validator.validateUpdate('rec-1', { productName: 'haifa' })).rejects.toThrow(AppError); + }); + }); + + describe('validateStatusChange', function () { + it('should throw 404 when the record does not exist', async function () { + (catalogStub.getRecord as ReturnType).mockResolvedValueOnce(undefined); + + await expect(validator.validateStatusChange('missing')).rejects.toThrow(ERROR_RECORD_NOT_FOUND); + }); + }); + + describe('ensureRecordAbsentFromExtractable', function () { + it('should throw 409 when the record exists in extractable-management', async function () { + (extractableStub.isExtractableRecordExists as ReturnType).mockResolvedValueOnce(true); + + let thrown: unknown; + try { + await validator.ensureRecordAbsentFromExtractable({ id: 'rec-1', productName: 'afula' }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.CONFLICT); + expect((thrown as AppError).message).toBe(ERROR_EXTRACTABLE_CONFLICT); + }); + + it('should pass when the record is absent from extractable-management', async function () { + await expect(validator.ensureRecordAbsentFromExtractable({ id: 'rec-1', productName: 'afula' })).resolves.toBeUndefined(); + }); + }); + describe('validateDelete', function () { it('should return the record when it is deletable', async function () { const record = { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'UNPUBLISHED' }; From 9d411dc4ef50be2bc429ff796f22283a399bd6d1 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 16:01:19 +0300 Subject: [PATCH 09/14] feat: add validate endpoint, model-path/productId checks, delete status and bundle (MAPCO-11833) Bring the service closer to the 3d-gateway behaviour: - POST /record/validate: run the full ingestion validation and return {isValid, message} without creating a job. - Ingestion validation also enforces modelPath under the configured base path (validation.basePath) and productId uniqueness in the catalog. - Ingestion converts the footprint to 2D before creating the job. - Delete now sets the catalog record status to BEING_DELETED after the delete job is created. - Add an openapi:bundle script (redocly) producing bundledApi.yaml. Skipped by design (Jobnik model, not store-trigger): PV-path rewriting, pre-minted uuid modelId, StoreTriggerCall. --- .gitignore | 1 + config/custom-environment-variables.json | 3 +- config/default.json | 3 +- helm/templates/configmap.yaml | 1 + helm/values.yaml | 1 + openapi3.yaml | 29 +++++++++++ package.json | 1 + src/common/config.ts | 4 +- src/openapi.d.ts | 48 +++++++++++++++++++ src/record/controllers/recordController.ts | 11 +++++ src/record/models/recordManager.ts | 28 ++++++++++- src/record/routes/recordRouter.ts | 1 + src/validator/validationManager.ts | 22 +++++++++ tests/integration/record/record.spec.ts | 29 ++++++++++- .../unit/record/models/recordManager.spec.ts | 3 +- .../unit/validator/validationManager.spec.ts | 27 ++++++++++- 16 files changed, 205 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 8ba7348..ce21358 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,4 @@ jest_html_reporters.html reports config/local*.json +bundledApi.yaml diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 777ce0d..e7c5e28 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -26,7 +26,8 @@ "percentageLimit": { "__name": "VALIDATION_PERCENTAGE_LIMIT", "__format": "number" - } + }, + "basePath": "VALIDATION_BASE_PATH" }, "provider": "PROVIDER_FROM", "NFS": { diff --git a/config/default.json b/config/default.json index d383775..3aa5098 100644 --- a/config/default.json +++ b/config/default.json @@ -54,7 +54,8 @@ } }, "validation": { - "percentageLimit": 10 + "percentageLimit": 10, + "basePath": "/app/models" }, "provider": "NFS", "NFS": { diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 5fde69a..ed7a095 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -47,6 +47,7 @@ data: TASK_DELETE_TYPE: {{ .delete.taskType | quote }} {{- end }} VALIDATION_PERCENTAGE_LIMIT: {{ .Values.validation.percentageLimit | quote }} + VALIDATION_BASE_PATH: {{ .Values.validation.basePath | quote }} PROVIDER_FROM: {{ .Values.provider | quote }} {{- if eq .Values.provider "NFS" }} PV_SOURCE_PATH: {{ .Values.NFS.pvPath | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index 6d85078..4a9b5c2 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -97,6 +97,7 @@ jobManager: validation: percentageLimit: 10 + basePath: '' provider: 'NFS' NFS: diff --git a/openapi3.yaml b/openapi3.yaml index 01a0f62..9250ab3 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -33,6 +33,26 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + /record/validate: + post: + operationId: validateRecord + tags: + - record + summary: Validate an ingestion request without creating a job + description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' + responses: + 200: + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/validationResultResponse' /record/{id}: delete: operationId: deleteRecord @@ -230,3 +250,12 @@ components: type: string percentage: type: number + validationResultResponse: + type: object + required: + - isValid + properties: + isValid: + type: boolean + message: + type: string diff --git a/package.json b/package.json index 76b94a0..a7b9d79 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "prelint:fix": "npm run format:fix", "prelint": "npm run format", "lint:openapi": "redocly lint openapi3.yaml", + "openapi:bundle": "redocly bundle openapi3.yaml -o bundledApi.yaml", "lint": "eslint .", "lint:fix": "eslint --fix .", "prebuild": "npm run clean && npm run generate:openapi-types", diff --git a/src/common/config.ts b/src/common/config.ts index 4f46eb4..0f28da5 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -27,6 +27,7 @@ interface JobManagerConfig { interface ValidationConfig { percentageLimit: number; + basePath: string; } interface NFSConfig { @@ -71,9 +72,10 @@ const opsTriggerConfigSchema = { provider: { type: 'string', enum: ['NFS', 'S3'] }, validation: { type: 'object', - required: ['percentageLimit'], + required: ['percentageLimit', 'basePath'], properties: { percentageLimit: { type: 'number' }, + basePath: { type: 'string' }, }, }, // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/src/openapi.d.ts b/src/openapi.d.ts index b0d39d3..bb6fa22 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -24,6 +24,26 @@ export type paths = { patch?: never; trace?: never; }; + '/record/validate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate an ingestion request without creating a job + * @description Runs the full ingestion validation and returns the result without triggering a Jobnik job. + */ + post: operations['validateRecord']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/record/{id}': { parameters: { query?: never; @@ -115,6 +135,10 @@ export type components = { status: string; percentage?: number; }; + validationResultResponse: { + isValid: boolean; + message?: string; + }; }; responses: never; parameters: { @@ -162,6 +186,30 @@ export interface operations { }; }; }; + validateRecord: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ingestionPayload']; + }; + }; + responses: { + /** @description Validation result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['validationResultResponse']; + }; + }; + }; + }; deleteRecord: { parameters: { query?: never; diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts index db8ea86..d40b219 100644 --- a/src/record/controllers/recordController.ts +++ b/src/record/controllers/recordController.ts @@ -40,6 +40,17 @@ export class RecordController { } }; + public validateRecord: TypedRequestHandlers['validateRecord'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.validateRecord.name }; + try { + const result = await this.manager.validateIngestion(req.body); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + this.logger.error({ msg: 'failed to validate ingestion request', logContext, err }); + return next(err); + } + }; + public deleteRecord: TypedRequestHandlers['deleteRecord'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.deleteRecord.name }; const { id } = req.params; diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts index d1369fc..cf12ccf 100644 --- a/src/record/models/recordManager.ts +++ b/src/record/models/recordManager.ts @@ -4,16 +4,20 @@ import type { Polygon } from 'geojson'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; import { convertPolygonTo2DPolygon } from '@common/util'; import { ValidationManager } from '../../validator/validationManager'; import { JobnikClient } from '../../externalServices/jobnik/jobnikClient'; import { CatalogCall } from '../../externalServices/catalog/catalogCall'; +const RECORD_STATUS_BEING_DELETED = 'BEING_DELETED'; + export type IngestionPayload = components['schemas']['ingestionPayload']; export type UpdatePayload = components['schemas']['updatePayload']; export type StatusPayload = components['schemas']['statusPayload']; export type JobResponse = components['schemas']['jobResponse']; export type AckResponse = components['schemas']['ackResponse']; +export type ValidationResultResponse = components['schemas']['validationResultResponse']; @injectable() export class RecordManager { @@ -35,14 +39,36 @@ export class RecordManager { const logContext = { ...this.logContext, function: this.createIngestion.name }; this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); await this.validator.validateIngestion(payload); + + const metadata = payload.metadata as Record; + if (metadata.footprint !== undefined) { + metadata.footprint = convertPolygonTo2DPolygon(metadata.footprint as Polygon); + } + return this.jobnik.createIngestionJob(payload); } + public async validateIngestion(payload: IngestionPayload): Promise { + const logContext = { ...this.logContext, function: this.validateIngestion.name }; + this.logger.info({ msg: 'validating ingestion request', logContext, modelPath: payload.modelPath }); + try { + await this.validator.validateIngestion(payload); + return { isValid: true }; + } catch (err) { + if (err instanceof AppError) { + return { isValid: false, message: err.message }; + } + throw err; + } + } + public async deleteRecord(id: string): Promise { const logContext = { ...this.logContext, function: this.deleteRecord.name }; this.logger.info({ msg: 'creating delete job', logContext, recordId: id }); const record = await this.validator.validateDelete(id); - return this.jobnik.createDeleteJob(record); + const job = await this.jobnik.createDeleteJob(record); + await this.catalog.changeStatus(id, { productStatus: RECORD_STATUS_BEING_DELETED }); + return job; } public async updateMetadata(id: string, update: UpdatePayload): Promise { diff --git a/src/record/routes/recordRouter.ts b/src/record/routes/recordRouter.ts index 4a5b495..077ac6f 100644 --- a/src/record/routes/recordRouter.ts +++ b/src/record/routes/recordRouter.ts @@ -7,6 +7,7 @@ const recordRouterFactory: FactoryFunction = (dependencyContainer) => { const controller = dependencyContainer.resolve(RecordController); router.post('/', controller.createRecord); + router.post('/validate', controller.validateRecord); router.patch('/status/:id', controller.updateRecordStatus); router.delete('/:id', controller.deleteRecord); router.patch('/:id', controller.updateRecord); diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 72d40a7..3805e3e 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -43,11 +43,14 @@ export const ERROR_INTERSECTION_FAILED = 'An error caused during the validation export const ERROR_RECORD_NOT_FOUND = "record with the given identifier doesn't exist"; export const ERROR_RECORD_BEING_DELETED = 'cannot change a record that is being deleted'; export const ERROR_EXTRACTABLE_CONFLICT = 'the record exists in the extractable-management service and cannot be changed here'; +export const ERROR_MODEL_PATH_INVALID = 'Unknown model path! The model is not under the agreed base path'; +export const ERROR_PRODUCT_ID_EXISTS = 'a record with this productId already exists in the catalog'; @injectable() export class ValidationManager { private readonly logContext: LogContext; private readonly percentageLimit: number; + private readonly basePath: string; public constructor( @inject(SERVICES.CONFIG) private readonly config: ConfigType, @@ -60,6 +63,7 @@ export class ValidationManager { @inject(SERVICES.PROVIDER) private readonly provider: Provider ) { this.percentageLimit = this.config.get('validation.percentageLimit'); + this.basePath = this.config.get('validation.basePath'); this.logContext = { fileName: __filename, class: ValidationManager.name, @@ -83,10 +87,12 @@ export class ValidationManager { } this.validateDates(metadata.imagingTimeBeginUTC, metadata.imagingTimeEndUTC); + this.validateModelPath(payload.modelPath); await this.validateClassification(parsed.data.classification); await this.validateProductNameUnique(parsed.data.productName); await this.validateProductNameNotInFlight(parsed.data.productName); + await this.validateProductIdUnique(parsed.data.productId); await this.validateFileExists(payload.modelPath, payload.tilesetFilename); await this.validateTileset(payload, footprintResult.data); } @@ -175,6 +181,22 @@ export class ValidationManager { } } + private validateModelPath(modelPath: string): void { + if (!modelPath.startsWith(this.basePath)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, `${ERROR_MODEL_PATH_INVALID} (basePath: ${this.basePath})`, true); + } + } + + private async validateProductIdUnique(productId: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductIdUnique.name }; + const records = await this.catalog.findRecords({ productId }); + this.logger.debug({ msg: 'product id uniqueness validation', logContext, productId, matches: records.length }); + + if (records.length > 0) { + throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_PRODUCT_ID_EXISTS, true); + } + } + private async validateFileExists(modelPath: string, tilesetFilename: string): Promise { const logContext = { ...this.logContext, function: this.validateFileExists.name }; const path = buildModelFilePath(modelPath, tilesetFilename); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index a3ee84f..659ac5c 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -49,7 +49,7 @@ const providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; const validMetadata = buildValidMetadata(); const validIngestionPayload = { - modelPath: '/shared/models/afula', + modelPath: '/app/models/afula', tilesetFilename: 'tileset.json', metadata: validMetadata, }; @@ -154,6 +154,33 @@ describe('record', function () { }); }); + describe('POST /record/validate', function () { + it('should return 200 with isValid true for a valid request without creating a job', async function () { + const response = await requestSender.validateRecord({ requestBody: validIngestionPayload }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body; + + expect(body.isValid).toBe(true); + }); + + it('should return 200 with isValid false and a message for an invalid request', async function () { + const response = await requestSender.validateRecord({ + requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body; + + expect(body.isValid).toBe(false); + expect(body.message).toBeTypeOf('string'); + }); + }); + describe('GET /jobs/{jobId}', function () { it('should return 200 with the job status and percentage', async function () { const response = await requestSender.getJobStatus({ pathParams: { jobId: 'job-1' } }); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts index bf5d1bd..59cc0d7 100644 --- a/tests/unit/record/models/recordManager.spec.ts +++ b/tests/unit/record/models/recordManager.spec.ts @@ -46,11 +46,12 @@ describe('RecordManager', function () { }); describe('deleteRecord', function () { - it('should return a job response', async function () { + it('should create the delete job and set the catalog status to BEING_DELETED', async function () { const result = await manager.deleteRecord('rec-1'); expect(result.jobId).toBeTypeOf('string'); expect(result.status).toBeTypeOf('string'); + expect(changeStatusMock).toHaveBeenCalledWith('rec-1', { productStatus: 'BEING_DELETED' }); }); }); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index 9e089ac..91f0509 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -18,6 +18,7 @@ import { ERROR_RECORD_NOT_FOUND, ERROR_RECORD_BEING_DELETED, ERROR_EXTRACTABLE_CONFLICT, + ERROR_MODEL_PATH_INVALID, } from '@src/validator/validationManager'; import { AppError } from '@src/common/appError'; import type { ConfigType } from '@src/common/config'; @@ -34,7 +35,9 @@ const regionTilesetJson = readFileSync(join(__dirname, '../../helpers/tilesets/f const boxTilesetJson = JSON.stringify({ root: { boundingVolume: { box: [0, 0, 0, 100, 0, 0, 0, 100, 0, 0, 0, 100] } } }); const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; -const configStub = { get: vi.fn().mockReturnValue(10) } as unknown as ConfigType; +const configStub = { + get: vi.fn((key: string) => (key === 'validation.basePath' ? '/shared/models' : 10)), +} as unknown as ConfigType; const ingest = (metadata: Record): IngestionPayload => ({ modelPath: '/shared/models/afula', @@ -154,6 +157,28 @@ describe('ValidationManager', function () { await expect(validator.validateIngestion(ingest(buildValidMetadata()))).rejects.toThrow(ERROR_FILE_NOT_FOUND); }); + it('should throw 400 when the model path is not under the agreed base path', async function () { + const payload = { ...ingest(buildValidMetadata()), modelPath: '/etc/passwd' }; + + await expect(validator.validateIngestion(payload)).rejects.toThrow(ERROR_MODEL_PATH_INVALID); + }); + + it('should throw 409 when the productId already exists in the catalog', async function () { + (catalogStub.findRecords as ReturnType).mockImplementation((query: { productId?: string; productName?: string }) => + query.productId !== undefined ? [{ id: 'existing', productId: query.productId }] : [] + ); + + let thrown: unknown; + try { + await validator.validateIngestion(ingest(buildValidMetadata())); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.CONFLICT); + }); + it('should throw 400 when the footprint does not intersect the tileset model', async function () { const metadata = buildValidMetadata(); metadata.footprint = { From c03f02105c5a6ff5f5752e6db59caba279d252b5 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 16:18:54 +0300 Subject: [PATCH 10/14] feat: add canDelete endpoint and commit the bundled OpenApi (MAPCO-11833) - GET /record/canDelete/{id}: dry-run delete validation returning {isValid, message}, matching 3d-gateway's GET /models/canDelete/{recordId}. - Rename the bundle script to bundleOpenApi and commit bundledApi.yaml (tracked, like 3d-gateway and store-trigger; removed from .gitignore). --- .gitignore | 1 - bundledApi.yaml | 277 +++++++++++++++++++++ openapi3.yaml | 16 ++ package.json | 2 +- src/openapi.d.ts | 43 ++++ src/record/controllers/recordController.ts | 12 + src/record/models/recordManager.ts | 14 ++ src/record/routes/recordRouter.ts | 1 + tests/integration/record/record.spec.ts | 13 + 9 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 bundledApi.yaml diff --git a/.gitignore b/.gitignore index ce21358..8ba7348 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,3 @@ jest_html_reporters.html reports config/local*.json -bundledApi.yaml diff --git a/bundledApi.yaml b/bundledApi.yaml new file mode 100644 index 0000000..4cfd255 --- /dev/null +++ b/bundledApi.yaml @@ -0,0 +1,277 @@ +openapi: 3.0.1 +info: + title: 3d-ops-trigger + description: Entry point for 3D operations — validates requests and triggers Jobnik jobs + version: 1.0.0 + license: + name: MIT + url: https://opensource.org/licenses/MIT +security: + - {} +paths: + /record: + post: + operationId: createRecord + tags: + - record + summary: Start an ingestion process flow + description: Validates the request (light & fast) and creates a Jobnik ingestion job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' + responses: + '201': + description: Ingestion job created + content: + application/json: + schema: + $ref: '#/components/schemas/jobResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /record/validate: + post: + operationId: validateRecord + tags: + - record + summary: Validate an ingestion request without creating a job + description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/validationResultResponse' + /record/canDelete/{id}: + get: + operationId: canDeleteRecord + tags: + - record + summary: Validate whether a record can be deleted + description: Runs the delete validation and returns the result without creating a job. + parameters: + - $ref: '#/components/parameters/recordId' + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/validationResultResponse' + /record/{id}: + delete: + operationId: deleteRecord + tags: + - record + summary: Start a delete process flow + description: Validates the record can be deleted and creates a Jobnik delete job. + parameters: + - $ref: '#/components/parameters/recordId' + responses: + '200': + description: Delete job created + content: + application/json: + schema: + $ref: '#/components/schemas/jobResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '404': + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + patch: + operationId: updateRecord + tags: + - record + summary: Update metadata for a record + parameters: + - $ref: '#/components/parameters/recordId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updatePayload' + responses: + '200': + description: Metadata updated + content: + application/json: + schema: + $ref: '#/components/schemas/ackResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '404': + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /record/status/{id}: + patch: + operationId: updateRecordStatus + tags: + - record + summary: Update the publish/unpublish status of a record + parameters: + - $ref: '#/components/parameters/recordId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/statusPayload' + responses: + '200': + description: Status updated + content: + application/json: + schema: + $ref: '#/components/schemas/ackResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '404': + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /jobs/{jobId}: + get: + operationId: getJobStatus + tags: + - job + summary: Get the status and progress of a job + parameters: + - $ref: '#/components/parameters/jobId' + responses: + '200': + description: Job status + content: + application/json: + schema: + $ref: '#/components/schemas/jobStatusResponse' + '404': + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' +components: + parameters: + recordId: + name: id + in: path + required: true + description: The record identifier + schema: + type: string + jobId: + name: jobId + in: path + required: true + description: The job identifier + schema: + type: string + schemas: + error: + type: object + required: + - message + properties: + message: + type: string + ingestionPayload: + type: object + required: + - modelPath + - tilesetFilename + - metadata + properties: + modelPath: + type: string + description: Path on the shared storage to the 3DTiles folder or 3TZ archive + tilesetFilename: + type: string + description: The tileset entry name (e.g. tileset.json) + metadata: + type: object + description: 3D record metadata (business validation applied downstream) + additionalProperties: true + updatePayload: + type: object + description: Partial metadata fields to update + additionalProperties: true + statusPayload: + type: object + required: + - status + properties: + status: + type: string + enum: + - PUBLISHED + - UNPUBLISHED + jobResponse: + type: object + required: + - jobId + - status + properties: + jobId: + type: string + status: + type: string + ackResponse: + type: object + required: + - message + properties: + message: + type: string + jobStatusResponse: + type: object + required: + - status + properties: + status: + type: string + percentage: + type: number + validationResultResponse: + type: object + required: + - isValid + properties: + isValid: + type: boolean + message: + type: string diff --git a/openapi3.yaml b/openapi3.yaml index 9250ab3..ae7a446 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -53,6 +53,22 @@ paths: application/json: schema: $ref: '#/components/schemas/validationResultResponse' + /record/canDelete/{id}: + get: + operationId: canDeleteRecord + tags: + - record + summary: Validate whether a record can be deleted + description: Runs the delete validation and returns the result without creating a job. + parameters: + - $ref: '#/components/parameters/recordId' + responses: + 200: + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/validationResultResponse' /record/{id}: delete: operationId: deleteRecord diff --git a/package.json b/package.json index a7b9d79..de2aeb0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "prelint:fix": "npm run format:fix", "prelint": "npm run format", "lint:openapi": "redocly lint openapi3.yaml", - "openapi:bundle": "redocly bundle openapi3.yaml -o bundledApi.yaml", + "bundleOpenApi": "redocly bundle openapi3.yaml -o bundledApi.yaml", "lint": "eslint .", "lint:fix": "eslint --fix .", "prebuild": "npm run clean && npm run generate:openapi-types", diff --git a/src/openapi.d.ts b/src/openapi.d.ts index bb6fa22..25eb5b6 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -44,6 +44,26 @@ export type paths = { patch?: never; trace?: never; }; + '/record/canDelete/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Validate whether a record can be deleted + * @description Runs the delete validation and returns the result without creating a job. + */ + get: operations['canDeleteRecord']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/record/{id}': { parameters: { query?: never; @@ -210,6 +230,29 @@ export interface operations { }; }; }; + canDeleteRecord: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Validation result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['validationResultResponse']; + }; + }; + }; + }; deleteRecord: { parameters: { query?: never; diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts index d40b219..ecba02c 100644 --- a/src/record/controllers/recordController.ts +++ b/src/record/controllers/recordController.ts @@ -51,6 +51,18 @@ export class RecordController { } }; + public canDeleteRecord: TypedRequestHandlers['canDeleteRecord'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.canDeleteRecord.name }; + const { id } = req.params; + try { + const result = await this.manager.canDelete(id); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + this.logger.error({ msg: 'failed to validate record deletability', logContext, err, recordId: id }); + return next(err); + } + }; + public deleteRecord: TypedRequestHandlers['deleteRecord'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.deleteRecord.name }; const { id } = req.params; diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts index cf12ccf..d7cb86d 100644 --- a/src/record/models/recordManager.ts +++ b/src/record/models/recordManager.ts @@ -62,6 +62,20 @@ export class RecordManager { } } + public async canDelete(id: string): Promise { + const logContext = { ...this.logContext, function: this.canDelete.name }; + this.logger.info({ msg: 'validating record deletability', logContext, recordId: id }); + try { + await this.validator.validateDelete(id); + return { isValid: true }; + } catch (err) { + if (err instanceof AppError) { + return { isValid: false, message: err.message }; + } + throw err; + } + } + public async deleteRecord(id: string): Promise { const logContext = { ...this.logContext, function: this.deleteRecord.name }; this.logger.info({ msg: 'creating delete job', logContext, recordId: id }); diff --git a/src/record/routes/recordRouter.ts b/src/record/routes/recordRouter.ts index 077ac6f..aa98f7c 100644 --- a/src/record/routes/recordRouter.ts +++ b/src/record/routes/recordRouter.ts @@ -8,6 +8,7 @@ const recordRouterFactory: FactoryFunction = (dependencyContainer) => { router.post('/', controller.createRecord); router.post('/validate', controller.validateRecord); + router.get('/canDelete/:id', controller.canDeleteRecord); router.patch('/status/:id', controller.updateRecordStatus); router.delete('/:id', controller.deleteRecord); router.patch('/:id', controller.updateRecord); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index 659ac5c..31e5427 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -124,6 +124,19 @@ describe('record', function () { }); }); + describe('GET /record/canDelete/{id}', function () { + it('should return 200 with isValid true for a deletable record', async function () { + const response = await requestSender.canDeleteRecord({ pathParams: { id: 'rec-1' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body; + + expect(body.isValid).toBe(true); + }); + }); + describe('PATCH /record/{id}', function () { it('should return 200 and an ack for a metadata update', async function () { const response = await requestSender.updateRecord({ pathParams: { id: 'rec-1' }, requestBody: { description: 'updated' } }); From 487f7d2ccdd113071407b285c775f7253901e2f4 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 16:29:11 +0300 Subject: [PATCH 11/14] refactor: split API into jobOperations, jobStatus, models and metadata groups (MAPCO-11833) Restructure the routes to mirror 3d-gateway + store-trigger groupings instead of a single /record tag: - jobOperations: POST /jobOperations/ingestion, POST /jobOperations/delete - jobStatus: GET /jobStatus/{jobId} - models: POST /models/validate, GET /models/canDelete/{recordId} - metadata: PATCH /metadata/{identifier}, PATCH /metadata/status/{identifier} Delete is now POST with a body ({id}) like store-trigger. Each group has its own controller and router; RecordManager and JobManager remain the shared business models. OpenAPI, generated types, bundledApi and integration tests updated. --- bundledApi.yaml | 158 +++++++------ openapi3.yaml | 158 +++++++------ src/containerConfig.ts | 12 +- src/job/routes/jobRouter.ts | 16 -- .../controllers/jobOperationsController.ts | 54 +++++ .../routes/jobOperationsRouter.ts | 17 ++ .../controllers/jobStatusController.ts} | 6 +- src/jobStatus/routes/jobStatusRouter.ts | 16 ++ .../controllers/metadataController.ts | 46 ++++ src/metadata/routes/metadataRouter.ts | 17 ++ src/models/controllers/modelsController.ts | 45 ++++ src/models/routes/modelsRouter.ts | 17 ++ src/openapi.d.ts | 207 ++++++++++-------- src/record/controllers/recordController.ts | 101 --------- src/record/routes/recordRouter.ts | 21 -- src/serverBuilder.ts | 18 +- tests/integration/record/record.spec.ts | 101 +++++---- 17 files changed, 578 insertions(+), 432 deletions(-) delete mode 100644 src/job/routes/jobRouter.ts create mode 100644 src/jobOperations/controllers/jobOperationsController.ts create mode 100644 src/jobOperations/routes/jobOperationsRouter.ts rename src/{job/controllers/jobController.ts => jobStatus/controllers/jobStatusController.ts} (88%) create mode 100644 src/jobStatus/routes/jobStatusRouter.ts create mode 100644 src/metadata/controllers/metadataController.ts create mode 100644 src/metadata/routes/metadataRouter.ts create mode 100644 src/models/controllers/modelsController.ts create mode 100644 src/models/routes/modelsRouter.ts delete mode 100644 src/record/controllers/recordController.ts delete mode 100644 src/record/routes/recordRouter.ts diff --git a/bundledApi.yaml b/bundledApi.yaml index 4cfd255..edcb350 100644 --- a/bundledApi.yaml +++ b/bundledApi.yaml @@ -9,12 +9,12 @@ info: security: - {} paths: - /record: + /jobOperations/ingestion: post: - operationId: createRecord + operationId: createIngestion tags: - - record - summary: Start an ingestion process flow + - jobOperations + summary: Create a new ingestion job to invoke a new model ingestion flow description: Validates the request (light & fast) and creates a Jobnik ingestion job. requestBody: required: true @@ -35,35 +35,72 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /record/validate: + /jobOperations/delete: post: - operationId: validateRecord + operationId: createDelete tags: - - record - summary: Validate an ingestion request without creating a job - description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. + - jobOperations + summary: Create a new delete job to invoke a delete flow + description: Validates the record can be deleted and creates a Jobnik delete job. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/ingestionPayload' + $ref: '#/components/schemas/deletePayload' responses: '200': - description: Validation result + description: Delete job created content: application/json: schema: - $ref: '#/components/schemas/validationResultResponse' - /record/canDelete/{id}: + $ref: '#/components/schemas/jobResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '404': + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /jobStatus/{jobId}: get: - operationId: canDeleteRecord + operationId: getJobStatus tags: - - record - summary: Validate whether a record can be deleted - description: Runs the delete validation and returns the result without creating a job. + - jobStatus + summary: Get the status and progress of a job parameters: - - $ref: '#/components/parameters/recordId' + - $ref: '#/components/parameters/jobId' + responses: + '200': + description: Job status + content: + application/json: + schema: + $ref: '#/components/schemas/jobStatusResponse' + '404': + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /models/validate: + post: + operationId: validateModel + tags: + - models + summary: Validate an ingestion request without creating a job + description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' responses: '200': description: Validation result @@ -71,41 +108,30 @@ paths: application/json: schema: $ref: '#/components/schemas/validationResultResponse' - /record/{id}: - delete: - operationId: deleteRecord + /models/canDelete/{recordId}: + get: + operationId: canDeleteModel tags: - - record - summary: Start a delete process flow - description: Validates the record can be deleted and creates a Jobnik delete job. + - models + summary: Validate whether a record can be deleted + description: Runs the delete validation and returns the result without creating a job. parameters: - $ref: '#/components/parameters/recordId' responses: '200': - description: Delete job created - content: - application/json: - schema: - $ref: '#/components/schemas/jobResponse' - '400': - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/error' - '404': - description: Record not found + description: Validation result content: application/json: schema: - $ref: '#/components/schemas/error' + $ref: '#/components/schemas/validationResultResponse' + /metadata/{identifier}: patch: - operationId: updateRecord + operationId: updateMetadata tags: - - record - summary: Update metadata for a record + - metadata + summary: Update metadata of a model parameters: - - $ref: '#/components/parameters/recordId' + - $ref: '#/components/parameters/identifier' requestBody: required: true content: @@ -131,14 +157,14 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /record/status/{id}: + /metadata/status/{identifier}: patch: - operationId: updateRecordStatus + operationId: updateMetadataStatus tags: - - record - summary: Update the publish/unpublish status of a record + - metadata + summary: Update the publish/unpublish status of a model parameters: - - $ref: '#/components/parameters/recordId' + - $ref: '#/components/parameters/identifier' requestBody: required: true content: @@ -164,31 +190,17 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /jobs/{jobId}: - get: - operationId: getJobStatus - tags: - - job - summary: Get the status and progress of a job - parameters: - - $ref: '#/components/parameters/jobId' - responses: - '200': - description: Job status - content: - application/json: - schema: - $ref: '#/components/schemas/jobStatusResponse' - '404': - description: Job not found - content: - application/json: - schema: - $ref: '#/components/schemas/error' components: parameters: + identifier: + name: identifier + in: path + required: true + description: The record identifier + schema: + type: string recordId: - name: id + name: recordId in: path required: true description: The record identifier @@ -226,6 +238,14 @@ components: type: object description: 3D record metadata (business validation applied downstream) additionalProperties: true + deletePayload: + type: object + required: + - id + properties: + id: + type: string + description: The record identifier to delete updatePayload: type: object description: Partial metadata fields to update diff --git a/openapi3.yaml b/openapi3.yaml index ae7a446..7a9aea1 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -7,12 +7,12 @@ info: name: MIT url: https://opensource.org/licenses/MIT paths: - /record: + /jobOperations/ingestion: post: - operationId: createRecord + operationId: createIngestion tags: - - record - summary: Start an ingestion process flow + - jobOperations + summary: Create a new ingestion job to invoke a new model ingestion flow description: Validates the request (light & fast) and creates a Jobnik ingestion job. requestBody: required: true @@ -33,35 +33,72 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /record/validate: + /jobOperations/delete: post: - operationId: validateRecord + operationId: createDelete tags: - - record - summary: Validate an ingestion request without creating a job - description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. + - jobOperations + summary: Create a new delete job to invoke a delete flow + description: Validates the record can be deleted and creates a Jobnik delete job. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/ingestionPayload' + $ref: '#/components/schemas/deletePayload' responses: 200: - description: Validation result + description: Delete job created content: application/json: schema: - $ref: '#/components/schemas/validationResultResponse' - /record/canDelete/{id}: + $ref: '#/components/schemas/jobResponse' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /jobStatus/{jobId}: get: - operationId: canDeleteRecord + operationId: getJobStatus tags: - - record - summary: Validate whether a record can be deleted - description: Runs the delete validation and returns the result without creating a job. + - jobStatus + summary: Get the status and progress of a job parameters: - - $ref: '#/components/parameters/recordId' + - $ref: '#/components/parameters/jobId' + responses: + 200: + description: Job status + content: + application/json: + schema: + $ref: '#/components/schemas/jobStatusResponse' + 404: + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /models/validate: + post: + operationId: validateModel + tags: + - models + summary: Validate an ingestion request without creating a job + description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' responses: 200: description: Validation result @@ -69,41 +106,30 @@ paths: application/json: schema: $ref: '#/components/schemas/validationResultResponse' - /record/{id}: - delete: - operationId: deleteRecord + /models/canDelete/{recordId}: + get: + operationId: canDeleteModel tags: - - record - summary: Start a delete process flow - description: Validates the record can be deleted and creates a Jobnik delete job. + - models + summary: Validate whether a record can be deleted + description: Runs the delete validation and returns the result without creating a job. parameters: - $ref: '#/components/parameters/recordId' responses: 200: - description: Delete job created - content: - application/json: - schema: - $ref: '#/components/schemas/jobResponse' - 400: - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/error' - 404: - description: Record not found + description: Validation result content: application/json: schema: - $ref: '#/components/schemas/error' + $ref: '#/components/schemas/validationResultResponse' + /metadata/{identifier}: patch: - operationId: updateRecord + operationId: updateMetadata tags: - - record - summary: Update metadata for a record + - metadata + summary: Update metadata of a model parameters: - - $ref: '#/components/parameters/recordId' + - $ref: '#/components/parameters/identifier' requestBody: required: true content: @@ -129,14 +155,14 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /record/status/{id}: + /metadata/status/{identifier}: patch: - operationId: updateRecordStatus + operationId: updateMetadataStatus tags: - - record - summary: Update the publish/unpublish status of a record + - metadata + summary: Update the publish/unpublish status of a model parameters: - - $ref: '#/components/parameters/recordId' + - $ref: '#/components/parameters/identifier' requestBody: required: true content: @@ -162,33 +188,19 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /jobs/{jobId}: - get: - operationId: getJobStatus - tags: - - job - summary: Get the status and progress of a job - parameters: - - $ref: '#/components/parameters/jobId' - responses: - 200: - description: Job status - content: - application/json: - schema: - $ref: '#/components/schemas/jobStatusResponse' - 404: - description: Job not found - content: - application/json: - schema: - $ref: '#/components/schemas/error' security: - {} components: parameters: + identifier: + name: identifier + in: path + required: true + description: The record identifier + schema: + type: string recordId: - name: id + name: recordId in: path required: true description: The record identifier @@ -226,6 +238,14 @@ components: type: object description: 3D record metadata (business validation applied downstream) additionalProperties: true + deletePayload: + type: object + required: + - id + properties: + id: + type: string + description: The record identifier to delete updatePayload: type: object description: Partial metadata fields to update diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 8a4bf69..690ebe1 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -6,8 +6,10 @@ import { jsLogger } from '@map-colonies/js-logger'; import { type InjectionObject, registerDependencies } from '@common/dependencyRegistration'; import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; -import { recordRouterFactory, RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; -import { jobRouterFactory, JOB_ROUTER_SYMBOL } from './job/routes/jobRouter'; +import { jobOperationsRouterFactory, JOB_OPERATIONS_ROUTER_SYMBOL } from './jobOperations/routes/jobOperationsRouter'; +import { jobStatusRouterFactory, JOB_STATUS_ROUTER_SYMBOL } from './jobStatus/routes/jobStatusRouter'; +import { modelsRouterFactory, MODELS_ROUTER_SYMBOL } from './models/routes/modelsRouter'; +import { metadataRouterFactory, METADATA_ROUTER_SYMBOL } from './metadata/routes/metadataRouter'; import { providerFactory } from './providers/getProvider'; import { getConfig } from './common/config'; @@ -33,8 +35,10 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, { token: SERVICES.PROVIDER, provider: { useFactory: providerFactory } }, - { token: RECORD_ROUTER_SYMBOL, provider: { useFactory: recordRouterFactory } }, - { token: JOB_ROUTER_SYMBOL, provider: { useFactory: jobRouterFactory } }, + { token: JOB_OPERATIONS_ROUTER_SYMBOL, provider: { useFactory: jobOperationsRouterFactory } }, + { token: JOB_STATUS_ROUTER_SYMBOL, provider: { useFactory: jobStatusRouterFactory } }, + { token: MODELS_ROUTER_SYMBOL, provider: { useFactory: modelsRouterFactory } }, + { token: METADATA_ROUTER_SYMBOL, provider: { useFactory: metadataRouterFactory } }, { token: 'onSignal', provider: { diff --git a/src/job/routes/jobRouter.ts b/src/job/routes/jobRouter.ts deleted file mode 100644 index 0c646d7..0000000 --- a/src/job/routes/jobRouter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { JobController } from '../controllers/jobController'; - -const jobRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(JobController); - - router.get('/:jobId', controller.getJobStatus); - - return router; -}; - -export const JOB_ROUTER_SYMBOL = Symbol('jobRouterFactory'); - -export { jobRouterFactory }; diff --git a/src/jobOperations/controllers/jobOperationsController.ts b/src/jobOperations/controllers/jobOperationsController.ts new file mode 100644 index 0000000..32683fb --- /dev/null +++ b/src/jobOperations/controllers/jobOperationsController.ts @@ -0,0 +1,54 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import { type Registry, Counter } from 'prom-client'; +import type { TypedRequestHandlers } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { RecordManager } from '../../record/models/recordManager'; + +@injectable() +export class JobOperationsController { + private readonly logContext: LogContext; + private readonly ingestionJobCounter: Counter; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(RecordManager) private readonly manager: RecordManager, + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry + ) { + this.logContext = { + fileName: __filename, + class: JobOperationsController.name, + }; + this.ingestionJobCounter = new Counter({ + name: 'ingestion_jobs_created', + help: 'number of ingestion jobs created', + registers: [this.metricsRegistry], + }); + } + + public createIngestion: TypedRequestHandlers['createIngestion'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.createIngestion.name }; + try { + const job = await this.manager.createIngestion(req.body); + this.ingestionJobCounter.inc(1); + return res.status(StatusCodes.CREATED).json(job); + } catch (err) { + this.logger.error({ msg: 'failed to create ingestion job', logContext, err }); + return next(err); + } + }; + + public createDelete: TypedRequestHandlers['createDelete'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.createDelete.name }; + const { id } = req.body; + try { + const job = await this.manager.deleteRecord(id); + return res.status(StatusCodes.OK).json(job); + } catch (err) { + this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId: id }); + return next(err); + } + }; +} diff --git a/src/jobOperations/routes/jobOperationsRouter.ts b/src/jobOperations/routes/jobOperationsRouter.ts new file mode 100644 index 0000000..c6d60a6 --- /dev/null +++ b/src/jobOperations/routes/jobOperationsRouter.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { JobOperationsController } from '../controllers/jobOperationsController'; + +const jobOperationsRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(JobOperationsController); + + router.post('/ingestion', controller.createIngestion); + router.post('/delete', controller.createDelete); + + return router; +}; + +export const JOB_OPERATIONS_ROUTER_SYMBOL = Symbol('jobOperationsRouterFactory'); + +export { jobOperationsRouterFactory }; diff --git a/src/job/controllers/jobController.ts b/src/jobStatus/controllers/jobStatusController.ts similarity index 88% rename from src/job/controllers/jobController.ts rename to src/jobStatus/controllers/jobStatusController.ts index 64148ce..968b570 100644 --- a/src/job/controllers/jobController.ts +++ b/src/jobStatus/controllers/jobStatusController.ts @@ -4,10 +4,10 @@ import { injectable, inject } from 'tsyringe'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; -import { JobManager } from '../models/jobManager'; +import { JobManager } from '../../job/models/jobManager'; @injectable() -export class JobController { +export class JobStatusController { private readonly logContext: LogContext; public constructor( @@ -16,7 +16,7 @@ export class JobController { ) { this.logContext = { fileName: __filename, - class: JobController.name, + class: JobStatusController.name, }; } diff --git a/src/jobStatus/routes/jobStatusRouter.ts b/src/jobStatus/routes/jobStatusRouter.ts new file mode 100644 index 0000000..9d3230a --- /dev/null +++ b/src/jobStatus/routes/jobStatusRouter.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { JobStatusController } from '../controllers/jobStatusController'; + +const jobStatusRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(JobStatusController); + + router.get('/:jobId', controller.getJobStatus); + + return router; +}; + +export const JOB_STATUS_ROUTER_SYMBOL = Symbol('jobStatusRouterFactory'); + +export { jobStatusRouterFactory }; diff --git a/src/metadata/controllers/metadataController.ts b/src/metadata/controllers/metadataController.ts new file mode 100644 index 0000000..cdf0b58 --- /dev/null +++ b/src/metadata/controllers/metadataController.ts @@ -0,0 +1,46 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import type { TypedRequestHandlers } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { RecordManager } from '../../record/models/recordManager'; + +@injectable() +export class MetadataController { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(RecordManager) private readonly manager: RecordManager + ) { + this.logContext = { + fileName: __filename, + class: MetadataController.name, + }; + } + + public updateMetadata: TypedRequestHandlers['updateMetadata'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.updateMetadata.name }; + const { identifier } = req.params; + try { + const ack = await this.manager.updateMetadata(identifier, req.body); + return res.status(StatusCodes.OK).json(ack); + } catch (err) { + this.logger.error({ msg: 'failed to update record metadata', logContext, err, recordId: identifier }); + return next(err); + } + }; + + public updateMetadataStatus: TypedRequestHandlers['updateMetadataStatus'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.updateMetadataStatus.name }; + const { identifier } = req.params; + try { + const ack = await this.manager.updateStatus(identifier, req.body); + return res.status(StatusCodes.OK).json(ack); + } catch (err) { + this.logger.error({ msg: 'failed to update record status', logContext, err, recordId: identifier }); + return next(err); + } + }; +} diff --git a/src/metadata/routes/metadataRouter.ts b/src/metadata/routes/metadataRouter.ts new file mode 100644 index 0000000..c8ca7ed --- /dev/null +++ b/src/metadata/routes/metadataRouter.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { MetadataController } from '../controllers/metadataController'; + +const metadataRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(MetadataController); + + router.patch('/status/:identifier', controller.updateMetadataStatus); + router.patch('/:identifier', controller.updateMetadata); + + return router; +}; + +export const METADATA_ROUTER_SYMBOL = Symbol('metadataRouterFactory'); + +export { metadataRouterFactory }; diff --git a/src/models/controllers/modelsController.ts b/src/models/controllers/modelsController.ts new file mode 100644 index 0000000..f001419 --- /dev/null +++ b/src/models/controllers/modelsController.ts @@ -0,0 +1,45 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import type { TypedRequestHandlers } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { RecordManager } from '../../record/models/recordManager'; + +@injectable() +export class ModelsController { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(RecordManager) private readonly manager: RecordManager + ) { + this.logContext = { + fileName: __filename, + class: ModelsController.name, + }; + } + + public validateModel: TypedRequestHandlers['validateModel'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.validateModel.name }; + try { + const result = await this.manager.validateIngestion(req.body); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + this.logger.error({ msg: 'failed to validate ingestion request', logContext, err }); + return next(err); + } + }; + + public canDeleteModel: TypedRequestHandlers['canDeleteModel'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.canDeleteModel.name }; + const { recordId } = req.params; + try { + const result = await this.manager.canDelete(recordId); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + this.logger.error({ msg: 'failed to validate record deletability', logContext, err, recordId }); + return next(err); + } + }; +} diff --git a/src/models/routes/modelsRouter.ts b/src/models/routes/modelsRouter.ts new file mode 100644 index 0000000..1716abb --- /dev/null +++ b/src/models/routes/modelsRouter.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { ModelsController } from '../controllers/modelsController'; + +const modelsRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(ModelsController); + + router.post('/validate', controller.validateModel); + router.get('/canDelete/:recordId', controller.canDeleteModel); + + return router; +}; + +export const MODELS_ROUTER_SYMBOL = Symbol('modelsRouterFactory'); + +export { modelsRouterFactory }; diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 25eb5b6..e4c5832 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,7 +4,7 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-express-types'; export type paths = { - '/record': { + '/jobOperations/ingestion': { parameters: { query?: never; header?: never; @@ -14,17 +14,17 @@ export type paths = { get?: never; put?: never; /** - * Start an ingestion process flow + * Create a new ingestion job to invoke a new model ingestion flow * @description Validates the request (light & fast) and creates a Jobnik ingestion job. */ - post: operations['createRecord']; + post: operations['createIngestion']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/record/validate': { + '/jobOperations/delete': { parameters: { query?: never; header?: never; @@ -34,28 +34,25 @@ export type paths = { get?: never; put?: never; /** - * Validate an ingestion request without creating a job - * @description Runs the full ingestion validation and returns the result without triggering a Jobnik job. + * Create a new delete job to invoke a delete flow + * @description Validates the record can be deleted and creates a Jobnik delete job. */ - post: operations['validateRecord']; + post: operations['createDelete']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/record/canDelete/{id}': { + '/jobStatus/{jobId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Validate whether a record can be deleted - * @description Runs the delete validation and returns the result without creating a job. - */ - get: operations['canDeleteRecord']; + /** Get the status and progress of a job */ + get: operations['getJobStatus']; put?: never; post?: never; delete?: never; @@ -64,7 +61,7 @@ export type paths = { patch?: never; trace?: never; }; - '/record/{id}': { + '/models/validate': { parameters: { query?: never; header?: never; @@ -73,19 +70,38 @@ export type paths = { }; get?: never; put?: never; - post?: never; /** - * Start a delete process flow - * @description Validates the record can be deleted and creates a Jobnik delete job. + * Validate an ingestion request without creating a job + * @description Runs the full ingestion validation and returns the result without triggering a Jobnik job. + */ + post: operations['validateModel']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/models/canDelete/{recordId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Validate whether a record can be deleted + * @description Runs the delete validation and returns the result without creating a job. */ - delete: operations['deleteRecord']; + get: operations['canDeleteModel']; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; - /** Update metadata for a record */ - patch: operations['updateRecord']; + patch?: never; trace?: never; }; - '/record/status/{id}': { + '/metadata/{identifier}': { parameters: { query?: never; header?: never; @@ -98,25 +114,25 @@ export type paths = { delete?: never; options?: never; head?: never; - /** Update the publish/unpublish status of a record */ - patch: operations['updateRecordStatus']; + /** Update metadata of a model */ + patch: operations['updateMetadata']; trace?: never; }; - '/jobs/{jobId}': { + '/metadata/status/{identifier}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get the status and progress of a job */ - get: operations['getJobStatus']; + get?: never; put?: never; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** Update the publish/unpublish status of a model */ + patch: operations['updateMetadataStatus']; trace?: never; }; }; @@ -136,6 +152,10 @@ export type components = { [key: string]: unknown; }; }; + deletePayload: { + /** @description The record identifier to delete */ + id: string; + }; /** @description Partial metadata fields to update */ updatePayload: { [key: string]: unknown; @@ -162,6 +182,8 @@ export type components = { }; responses: never; parameters: { + /** @description The record identifier */ + identifier: string; /** @description The record identifier */ recordId: string; /** @description The job identifier */ @@ -173,7 +195,7 @@ export type components = { }; export type $defs = Record; export interface operations { - createRecord: { + createIngestion: { parameters: { query?: never; header?: never; @@ -206,7 +228,7 @@ export interface operations { }; }; }; - validateRecord: { + createDelete: { parameters: { query?: never; header?: never; @@ -215,92 +237,125 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ingestionPayload']; + 'application/json': components['schemas']['deletePayload']; }; }; responses: { - /** @description Validation result */ + /** @description Delete job created */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['validationResultResponse']; + 'application/json': components['schemas']['jobResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; }; }; }; }; - canDeleteRecord: { + getJobStatus: { parameters: { query?: never; header?: never; path: { - /** @description The record identifier */ - id: components['parameters']['recordId']; + /** @description The job identifier */ + jobId: components['parameters']['jobId']; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Validation result */ + /** @description Job status */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['validationResultResponse']; + 'application/json': components['schemas']['jobStatusResponse']; + }; + }; + /** @description Job not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; }; }; }; }; - deleteRecord: { + validateModel: { parameters: { query?: never; header?: never; - path: { - /** @description The record identifier */ - id: components['parameters']['recordId']; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ingestionPayload']; + }; + }; responses: { - /** @description Delete job created */ + /** @description Validation result */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['jobResponse']; + 'application/json': components['schemas']['validationResultResponse']; }; }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['error']; - }; + }; + }; + canDeleteModel: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The record identifier */ + recordId: components['parameters']['recordId']; }; - /** @description Record not found */ - 404: { + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Validation result */ + 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['error']; + 'application/json': components['schemas']['validationResultResponse']; }; }; }; }; - updateRecord: { + updateMetadata: { parameters: { query?: never; header?: never; path: { /** @description The record identifier */ - id: components['parameters']['recordId']; + identifier: components['parameters']['identifier']; }; cookie?: never; }; @@ -339,13 +394,13 @@ export interface operations { }; }; }; - updateRecordStatus: { + updateMetadataStatus: { parameters: { query?: never; header?: never; path: { /** @description The record identifier */ - id: components['parameters']['recordId']; + identifier: components['parameters']['identifier']; }; cookie?: never; }; @@ -384,37 +439,5 @@ export interface operations { }; }; }; - getJobStatus: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The job identifier */ - jobId: components['parameters']['jobId']; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Job status */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['jobStatusResponse']; - }; - }; - /** @description Job not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['error']; - }; - }; - }; - }; } export type TypedRequestHandlers = ImportedTypedRequestHandlers; diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts deleted file mode 100644 index ecba02c..0000000 --- a/src/record/controllers/recordController.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { StatusCodes } from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import { type Registry, Counter } from 'prom-client'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; -import type { LogContext } from '@common/interfaces'; -import { RecordManager } from '../models/recordManager'; - -@injectable() -export class RecordController { - private readonly logContext: LogContext; - private readonly ingestionJobCounter: Counter; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(RecordManager) private readonly manager: RecordManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.logContext = { - fileName: __filename, - class: RecordController.name, - }; - this.ingestionJobCounter = new Counter({ - name: 'ingestion_jobs_created', - help: 'number of ingestion jobs created', - registers: [this.metricsRegistry], - }); - } - - public createRecord: TypedRequestHandlers['createRecord'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.createRecord.name }; - try { - const job = await this.manager.createIngestion(req.body); - this.ingestionJobCounter.inc(1); - return res.status(StatusCodes.CREATED).json(job); - } catch (err) { - this.logger.error({ msg: 'failed to create ingestion job', logContext, err }); - return next(err); - } - }; - - public validateRecord: TypedRequestHandlers['validateRecord'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.validateRecord.name }; - try { - const result = await this.manager.validateIngestion(req.body); - return res.status(StatusCodes.OK).json(result); - } catch (err) { - this.logger.error({ msg: 'failed to validate ingestion request', logContext, err }); - return next(err); - } - }; - - public canDeleteRecord: TypedRequestHandlers['canDeleteRecord'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.canDeleteRecord.name }; - const { id } = req.params; - try { - const result = await this.manager.canDelete(id); - return res.status(StatusCodes.OK).json(result); - } catch (err) { - this.logger.error({ msg: 'failed to validate record deletability', logContext, err, recordId: id }); - return next(err); - } - }; - - public deleteRecord: TypedRequestHandlers['deleteRecord'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.deleteRecord.name }; - const { id } = req.params; - try { - const job = await this.manager.deleteRecord(id); - return res.status(StatusCodes.OK).json(job); - } catch (err) { - this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId: id }); - return next(err); - } - }; - - public updateRecord: TypedRequestHandlers['updateRecord'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.updateRecord.name }; - const { id } = req.params; - try { - const ack = await this.manager.updateMetadata(id, req.body); - return res.status(StatusCodes.OK).json(ack); - } catch (err) { - this.logger.error({ msg: 'failed to update record metadata', logContext, err, recordId: id }); - return next(err); - } - }; - - public updateRecordStatus: TypedRequestHandlers['updateRecordStatus'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.updateRecordStatus.name }; - const { id } = req.params; - try { - const ack = await this.manager.updateStatus(id, req.body); - return res.status(StatusCodes.OK).json(ack); - } catch (err) { - this.logger.error({ msg: 'failed to update record status', logContext, err, recordId: id }); - return next(err); - } - }; -} diff --git a/src/record/routes/recordRouter.ts b/src/record/routes/recordRouter.ts deleted file mode 100644 index aa98f7c..0000000 --- a/src/record/routes/recordRouter.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { RecordController } from '../controllers/recordController'; - -const recordRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(RecordController); - - router.post('/', controller.createRecord); - router.post('/validate', controller.validateRecord); - router.get('/canDelete/:id', controller.canDeleteRecord); - router.patch('/status/:id', controller.updateRecordStatus); - router.delete('/:id', controller.deleteRecord); - router.patch('/:id', controller.updateRecord); - - return router; -}; - -export const RECORD_ROUTER_SYMBOL = Symbol('recordRouterFactory'); - -export { recordRouterFactory }; diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index eff4ee4..4c8e7fd 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -10,8 +10,10 @@ import { collectMetricsExpressMiddleware } from '@map-colonies/prometheus'; import { Registry } from 'prom-client'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; -import { RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; -import { JOB_ROUTER_SYMBOL } from './job/routes/jobRouter'; +import { JOB_OPERATIONS_ROUTER_SYMBOL } from './jobOperations/routes/jobOperationsRouter'; +import { JOB_STATUS_ROUTER_SYMBOL } from './jobStatus/routes/jobStatusRouter'; +import { MODELS_ROUTER_SYMBOL } from './models/routes/modelsRouter'; +import { METADATA_ROUTER_SYMBOL } from './metadata/routes/metadataRouter'; @injectable() export class ServerBuilder { @@ -21,8 +23,10 @@ export class ServerBuilder { @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(RECORD_ROUTER_SYMBOL) private readonly recordRouter: Router, - @inject(JOB_ROUTER_SYMBOL) private readonly jobRouter: Router + @inject(JOB_OPERATIONS_ROUTER_SYMBOL) private readonly jobOperationsRouter: Router, + @inject(JOB_STATUS_ROUTER_SYMBOL) private readonly jobStatusRouter: Router, + @inject(MODELS_ROUTER_SYMBOL) private readonly modelsRouter: Router, + @inject(METADATA_ROUTER_SYMBOL) private readonly metadataRouter: Router ) { this.serverInstance = express(); } @@ -45,8 +49,10 @@ export class ServerBuilder { } private buildRoutes(): void { - this.serverInstance.use('/record', this.recordRouter); - this.serverInstance.use('/jobs', this.jobRouter); + this.serverInstance.use('/jobOperations', this.jobOperationsRouter); + this.serverInstance.use('/jobStatus', this.jobStatusRouter); + this.serverInstance.use('/models', this.modelsRouter); + this.serverInstance.use('/metadata', this.metadataRouter); this.buildDocsRoutes(); } diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index 31e5427..acdd9de 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -29,7 +29,6 @@ const deletableRecord = { }; const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; -// findRecords by id (delete) → an existing deletable record; by productName (uniqueness) → none const catalogStub = { findRecords: vi.fn().mockImplementation((payload: { id?: string }) => (payload.id !== undefined ? [deletableRecord] : [])), getRecord: vi.fn().mockResolvedValue(deletableRecord), @@ -54,7 +53,7 @@ const validIngestionPayload = { metadata: validMetadata, }; -describe('record', function () { +describe('3d-ops-trigger', function () { let requestSender: RequestSender; beforeAll(async function () { @@ -78,23 +77,23 @@ describe('record', function () { requestSender = await createRequestSender('openapi3.yaml', app); }); - describe('POST /record', function () { + describe('POST /jobOperations/ingestion', function () { it('should return 201 and a job response for a valid ingestion request', async function () { - const response = await requestSender.createRecord({ requestBody: validIngestionPayload }); + const response = await requestSender.createIngestion({ requestBody: validIngestionPayload }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.CREATED); - const body = response.body as paths['/record']['post']['responses']['201']['content']['application/json']; + const body = response.body as paths['/jobOperations/ingestion']['post']['responses']['201']['content']['application/json']; expect(body.jobId).toBeTypeOf('string'); expect(body.status).toBeTypeOf('string'); }); it('should return 400 when a required field is missing', async function () { - const response = await requestSender.createRecord({ + const response = await requestSender.createIngestion({ // @ts-expect-error intentionally invalid: missing tilesetFilename and metadata - requestBody: { modelPath: '/shared/models/afula' }, + requestBody: { modelPath: '/app/models/afula' }, }); expect(response).toSatisfyApiSpec(); @@ -102,7 +101,7 @@ describe('record', function () { }); it('should return 400 when metadata fails business validation', async function () { - const response = await requestSender.createRecord({ + const response = await requestSender.createIngestion({ requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, }); @@ -111,65 +110,63 @@ describe('record', function () { }); }); - describe('DELETE /record/{id}', function () { + describe('POST /jobOperations/delete', function () { it('should return 200 and a job response', async function () { - const response = await requestSender.deleteRecord({ pathParams: { id: 'rec-1' } }); + const response = await requestSender.createDelete({ requestBody: { id: 'rec-1' } }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); - const body = response.body as paths['/record/{id}']['delete']['responses']['200']['content']['application/json']; + const body = response.body as paths['/jobOperations/delete']['post']['responses']['200']['content']['application/json']; expect(body.jobId).toBeTypeOf('string'); }); }); - describe('GET /record/canDelete/{id}', function () { - it('should return 200 with isValid true for a deletable record', async function () { - const response = await requestSender.canDeleteRecord({ pathParams: { id: 'rec-1' } }); + describe('GET /jobStatus/{jobId}', function () { + it('should return 200 with the job status and percentage', async function () { + const response = await requestSender.getJobStatus({ pathParams: { jobId: 'job-1' } }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); - const body = response.body; + const body = response.body as paths['/jobStatus/{jobId}']['get']['responses']['200']['content']['application/json']; - expect(body.isValid).toBe(true); + expect(body.status).toBeTypeOf('string'); + expect(body.percentage).toBe(42); }); }); - describe('PATCH /record/{id}', function () { - it('should return 200 and an ack for a metadata update', async function () { - const response = await requestSender.updateRecord({ pathParams: { id: 'rec-1' }, requestBody: { description: 'updated' } }); + describe('POST /models/validate', function () { + it('should return 200 with isValid true for a valid request without creating a job', async function () { + const response = await requestSender.validateModel({ requestBody: validIngestionPayload }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); - expect(response.body.message).toBeTypeOf('string'); - }); - }); - describe('PATCH /record/status/{id}', function () { - it('should return 200 and an ack for a valid status change', async function () { - const response = await requestSender.updateRecordStatus({ pathParams: { id: 'rec-1' }, requestBody: { status: 'PUBLISHED' } }); + const body = response.body; - expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.OK); + expect(body.isValid).toBe(true); }); - it('should return 400 for an invalid status value', async function () { - const response = await requestSender.updateRecordStatus({ - pathParams: { id: 'rec-1' }, - // @ts-expect-error intentionally invalid status enum value - requestBody: { status: 'NOT_A_STATUS' }, + it('should return 200 with isValid false and a message for an invalid request', async function () { + const response = await requestSender.validateModel({ + requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, }); expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body; + + expect(body.isValid).toBe(false); + expect(body.message).toBeTypeOf('string'); }); }); - describe('POST /record/validate', function () { - it('should return 200 with isValid true for a valid request without creating a job', async function () { - const response = await requestSender.validateRecord({ requestBody: validIngestionPayload }); + describe('GET /models/canDelete/{recordId}', function () { + it('should return 200 with isValid true for a deletable record', async function () { + const response = await requestSender.canDeleteModel({ pathParams: { recordId: 'rec-1' } }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); @@ -178,33 +175,35 @@ describe('record', function () { expect(body.isValid).toBe(true); }); + }); - it('should return 200 with isValid false and a message for an invalid request', async function () { - const response = await requestSender.validateRecord({ - requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, - }); + describe('PATCH /metadata/{identifier}', function () { + it('should return 200 and an ack for a metadata update', async function () { + const response = await requestSender.updateMetadata({ pathParams: { identifier: 'rec-1' }, requestBody: { description: 'updated' } }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); - - const body = response.body; - - expect(body.isValid).toBe(false); - expect(body.message).toBeTypeOf('string'); + expect(response.body.message).toBeTypeOf('string'); }); }); - describe('GET /jobs/{jobId}', function () { - it('should return 200 with the job status and percentage', async function () { - const response = await requestSender.getJobStatus({ pathParams: { jobId: 'job-1' } }); + describe('PATCH /metadata/status/{identifier}', function () { + it('should return 200 and an ack for a valid status change', async function () { + const response = await requestSender.updateMetadataStatus({ pathParams: { identifier: 'rec-1' }, requestBody: { status: 'PUBLISHED' } }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); + }); - const body = response.body as paths['/jobs/{jobId}']['get']['responses']['200']['content']['application/json']; + it('should return 400 for an invalid status value', async function () { + const response = await requestSender.updateMetadataStatus({ + pathParams: { identifier: 'rec-1' }, + // @ts-expect-error intentionally invalid status enum value + requestBody: { status: 'NOT_A_STATUS' }, + }); - expect(body.status).toBeTypeOf('string'); - expect(body.percentage).toBe(42); + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); }); }); }); From e7f6062ac211662fff72583045a19620fe753beb Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 16:39:14 +0300 Subject: [PATCH 12/14] refactor: consolidate model operations under the records group (MAPCO-11833) Mirror 3d-gateway's models group (renamed to our domain term "record"): all model operations live under one records tag, metadata and jobStatus stay separate. - records: POST /records, POST /records/validate, GET /records/canDelete/{recordId}, DELETE /records/{recordId} - metadata: PATCH /metadata/{identifier}, PATCH /metadata/status/{identifier} - jobStatus: GET /jobStatus/{jobId} Delete reverts to a REST DELETE. Merge the jobOperations and models modules into a single records module (recordManager moved under it); update routers, serverBuilder, containerConfig, OpenAPI, bundledApi and tests. --- bundledApi.yaml | 130 ++++++------ openapi3.yaml | 130 ++++++------ src/containerConfig.ts | 6 +- src/externalServices/jobnik/jobnikClient.ts | 2 +- .../routes/jobOperationsRouter.ts | 17 -- .../controllers/metadataController.ts | 2 +- src/models/controllers/modelsController.ts | 45 ----- src/models/routes/modelsRouter.ts | 17 -- src/openapi.d.ts | 185 +++++++++--------- .../controllers/recordsController.ts} | 43 +++- .../models/recordManager.ts | 0 src/records/routes/recordsRouter.ts | 19 ++ src/serverBuilder.ts | 9 +- src/validator/validationManager.ts | 2 +- tests/integration/record/record.spec.ts | 74 +++---- .../jobnik/jobnikClient.spec.ts | 2 +- .../models/recordManager.spec.ts | 2 +- .../unit/validator/validationManager.spec.ts | 2 +- 18 files changed, 308 insertions(+), 379 deletions(-) delete mode 100644 src/jobOperations/routes/jobOperationsRouter.ts delete mode 100644 src/models/controllers/modelsController.ts delete mode 100644 src/models/routes/modelsRouter.ts rename src/{jobOperations/controllers/jobOperationsController.ts => records/controllers/recordsController.ts} (52%) rename src/{record => records}/models/recordManager.ts (100%) create mode 100644 src/records/routes/recordsRouter.ts rename tests/unit/{record => records}/models/recordManager.spec.ts (99%) diff --git a/bundledApi.yaml b/bundledApi.yaml index edcb350..9d62ccf 100644 --- a/bundledApi.yaml +++ b/bundledApi.yaml @@ -9,12 +9,12 @@ info: security: - {} paths: - /jobOperations/ingestion: + /records: post: - operationId: createIngestion + operationId: createRecord tags: - - jobOperations - summary: Create a new ingestion job to invoke a new model ingestion flow + - records + summary: Invoke a new model ingestion flow description: Validates the request (light & fast) and creates a Jobnik ingestion job. requestBody: required: true @@ -35,72 +35,35 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /jobOperations/delete: + /records/validate: post: - operationId: createDelete + operationId: validateRecord tags: - - jobOperations - summary: Create a new delete job to invoke a delete flow - description: Validates the record can be deleted and creates a Jobnik delete job. + - records + summary: Invoke a new model ingestion validation + description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/deletePayload' + $ref: '#/components/schemas/ingestionPayload' responses: '200': - description: Delete job created - content: - application/json: - schema: - $ref: '#/components/schemas/jobResponse' - '400': - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/error' - '404': - description: Record not found + description: Validation result content: application/json: schema: - $ref: '#/components/schemas/error' - /jobStatus/{jobId}: + $ref: '#/components/schemas/validationResultResponse' + /records/canDelete/{recordId}: get: - operationId: getJobStatus + operationId: canDeleteRecord tags: - - jobStatus - summary: Get the status and progress of a job + - records + summary: Validate if a record can be removed + description: Runs the delete validation and returns the result without creating a job. parameters: - - $ref: '#/components/parameters/jobId' - responses: - '200': - description: Job status - content: - application/json: - schema: - $ref: '#/components/schemas/jobStatusResponse' - '404': - description: Job not found - content: - application/json: - schema: - $ref: '#/components/schemas/error' - /models/validate: - post: - operationId: validateModel - tags: - - models - summary: Validate an ingestion request without creating a job - description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ingestionPayload' + - $ref: '#/components/parameters/recordId' responses: '200': description: Validation result @@ -108,22 +71,34 @@ paths: application/json: schema: $ref: '#/components/schemas/validationResultResponse' - /models/canDelete/{recordId}: - get: - operationId: canDeleteModel + /records/{recordId}: + delete: + operationId: deleteRecord tags: - - models - summary: Validate whether a record can be deleted - description: Runs the delete validation and returns the result without creating a job. + - records + summary: Delete a model by record id + description: Validates the record can be deleted and creates a Jobnik delete job. parameters: - $ref: '#/components/parameters/recordId' responses: '200': - description: Validation result + description: Delete job created content: application/json: schema: - $ref: '#/components/schemas/validationResultResponse' + $ref: '#/components/schemas/jobResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '404': + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' /metadata/{identifier}: patch: operationId: updateMetadata @@ -190,6 +165,27 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + /jobStatus/{jobId}: + get: + operationId: getJobStatus + tags: + - jobStatus + summary: Get the status and progress of a job + parameters: + - $ref: '#/components/parameters/jobId' + responses: + '200': + description: Job status + content: + application/json: + schema: + $ref: '#/components/schemas/jobStatusResponse' + '404': + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' components: parameters: identifier: @@ -238,14 +234,6 @@ components: type: object description: 3D record metadata (business validation applied downstream) additionalProperties: true - deletePayload: - type: object - required: - - id - properties: - id: - type: string - description: The record identifier to delete updatePayload: type: object description: Partial metadata fields to update diff --git a/openapi3.yaml b/openapi3.yaml index 7a9aea1..491b9d1 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -7,12 +7,12 @@ info: name: MIT url: https://opensource.org/licenses/MIT paths: - /jobOperations/ingestion: + /records: post: - operationId: createIngestion + operationId: createRecord tags: - - jobOperations - summary: Create a new ingestion job to invoke a new model ingestion flow + - records + summary: Invoke a new model ingestion flow description: Validates the request (light & fast) and creates a Jobnik ingestion job. requestBody: required: true @@ -33,72 +33,35 @@ paths: application/json: schema: $ref: '#/components/schemas/error' - /jobOperations/delete: + /records/validate: post: - operationId: createDelete + operationId: validateRecord tags: - - jobOperations - summary: Create a new delete job to invoke a delete flow - description: Validates the record can be deleted and creates a Jobnik delete job. + - records + summary: Invoke a new model ingestion validation + description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/deletePayload' + $ref: '#/components/schemas/ingestionPayload' responses: 200: - description: Delete job created - content: - application/json: - schema: - $ref: '#/components/schemas/jobResponse' - 400: - description: Bad Request - content: - application/json: - schema: - $ref: '#/components/schemas/error' - 404: - description: Record not found + description: Validation result content: application/json: schema: - $ref: '#/components/schemas/error' - /jobStatus/{jobId}: + $ref: '#/components/schemas/validationResultResponse' + /records/canDelete/{recordId}: get: - operationId: getJobStatus + operationId: canDeleteRecord tags: - - jobStatus - summary: Get the status and progress of a job + - records + summary: Validate if a record can be removed + description: Runs the delete validation and returns the result without creating a job. parameters: - - $ref: '#/components/parameters/jobId' - responses: - 200: - description: Job status - content: - application/json: - schema: - $ref: '#/components/schemas/jobStatusResponse' - 404: - description: Job not found - content: - application/json: - schema: - $ref: '#/components/schemas/error' - /models/validate: - post: - operationId: validateModel - tags: - - models - summary: Validate an ingestion request without creating a job - description: Runs the full ingestion validation and returns the result without triggering a Jobnik job. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ingestionPayload' + - $ref: '#/components/parameters/recordId' responses: 200: description: Validation result @@ -106,22 +69,34 @@ paths: application/json: schema: $ref: '#/components/schemas/validationResultResponse' - /models/canDelete/{recordId}: - get: - operationId: canDeleteModel + /records/{recordId}: + delete: + operationId: deleteRecord tags: - - models - summary: Validate whether a record can be deleted - description: Runs the delete validation and returns the result without creating a job. + - records + summary: Delete a model by record id + description: Validates the record can be deleted and creates a Jobnik delete job. parameters: - $ref: '#/components/parameters/recordId' responses: 200: - description: Validation result + description: Delete job created content: application/json: schema: - $ref: '#/components/schemas/validationResultResponse' + $ref: '#/components/schemas/jobResponse' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' /metadata/{identifier}: patch: operationId: updateMetadata @@ -188,6 +163,27 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + /jobStatus/{jobId}: + get: + operationId: getJobStatus + tags: + - jobStatus + summary: Get the status and progress of a job + parameters: + - $ref: '#/components/parameters/jobId' + responses: + 200: + description: Job status + content: + application/json: + schema: + $ref: '#/components/schemas/jobStatusResponse' + 404: + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' security: - {} components: @@ -238,14 +234,6 @@ components: type: object description: 3D record metadata (business validation applied downstream) additionalProperties: true - deletePayload: - type: object - required: - - id - properties: - id: - type: string - description: The record identifier to delete updatePayload: type: object description: Partial metadata fields to update diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 690ebe1..75be200 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -6,9 +6,8 @@ import { jsLogger } from '@map-colonies/js-logger'; import { type InjectionObject, registerDependencies } from '@common/dependencyRegistration'; import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; -import { jobOperationsRouterFactory, JOB_OPERATIONS_ROUTER_SYMBOL } from './jobOperations/routes/jobOperationsRouter'; +import { recordsRouterFactory, RECORDS_ROUTER_SYMBOL } from './records/routes/recordsRouter'; import { jobStatusRouterFactory, JOB_STATUS_ROUTER_SYMBOL } from './jobStatus/routes/jobStatusRouter'; -import { modelsRouterFactory, MODELS_ROUTER_SYMBOL } from './models/routes/modelsRouter'; import { metadataRouterFactory, METADATA_ROUTER_SYMBOL } from './metadata/routes/metadataRouter'; import { providerFactory } from './providers/getProvider'; import { getConfig } from './common/config'; @@ -35,9 +34,8 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, { token: SERVICES.PROVIDER, provider: { useFactory: providerFactory } }, - { token: JOB_OPERATIONS_ROUTER_SYMBOL, provider: { useFactory: jobOperationsRouterFactory } }, + { token: RECORDS_ROUTER_SYMBOL, provider: { useFactory: recordsRouterFactory } }, { token: JOB_STATUS_ROUTER_SYMBOL, provider: { useFactory: jobStatusRouterFactory } }, - { token: MODELS_ROUTER_SYMBOL, provider: { useFactory: modelsRouterFactory } }, { token: METADATA_ROUTER_SYMBOL, provider: { useFactory: metadataRouterFactory } }, { token: 'onSignal', diff --git a/src/externalServices/jobnik/jobnikClient.ts b/src/externalServices/jobnik/jobnikClient.ts index 0a9104e..63caf44 100644 --- a/src/externalServices/jobnik/jobnikClient.ts +++ b/src/externalServices/jobnik/jobnikClient.ts @@ -9,7 +9,7 @@ import { is3tz } from '@common/util'; import { AppError } from '@common/appError'; import type { ConfigType, JobManagerConfig } from '@common/config'; import type { LogContext } from '@common/interfaces'; -import type { IngestionPayload, JobResponse } from '../../record/models/recordManager'; +import type { IngestionPayload, JobResponse } from '../../records/models/recordManager'; import type { Record3D } from '../catalog/interfaces'; interface StageDescriptor { diff --git a/src/jobOperations/routes/jobOperationsRouter.ts b/src/jobOperations/routes/jobOperationsRouter.ts deleted file mode 100644 index c6d60a6..0000000 --- a/src/jobOperations/routes/jobOperationsRouter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { JobOperationsController } from '../controllers/jobOperationsController'; - -const jobOperationsRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(JobOperationsController); - - router.post('/ingestion', controller.createIngestion); - router.post('/delete', controller.createDelete); - - return router; -}; - -export const JOB_OPERATIONS_ROUTER_SYMBOL = Symbol('jobOperationsRouterFactory'); - -export { jobOperationsRouterFactory }; diff --git a/src/metadata/controllers/metadataController.ts b/src/metadata/controllers/metadataController.ts index cdf0b58..c7da02e 100644 --- a/src/metadata/controllers/metadataController.ts +++ b/src/metadata/controllers/metadataController.ts @@ -4,7 +4,7 @@ import { injectable, inject } from 'tsyringe'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; -import { RecordManager } from '../../record/models/recordManager'; +import { RecordManager } from '../../records/models/recordManager'; @injectable() export class MetadataController { diff --git a/src/models/controllers/modelsController.ts b/src/models/controllers/modelsController.ts deleted file mode 100644 index f001419..0000000 --- a/src/models/controllers/modelsController.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { StatusCodes } from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; -import type { LogContext } from '@common/interfaces'; -import { RecordManager } from '../../record/models/recordManager'; - -@injectable() -export class ModelsController { - private readonly logContext: LogContext; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(RecordManager) private readonly manager: RecordManager - ) { - this.logContext = { - fileName: __filename, - class: ModelsController.name, - }; - } - - public validateModel: TypedRequestHandlers['validateModel'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.validateModel.name }; - try { - const result = await this.manager.validateIngestion(req.body); - return res.status(StatusCodes.OK).json(result); - } catch (err) { - this.logger.error({ msg: 'failed to validate ingestion request', logContext, err }); - return next(err); - } - }; - - public canDeleteModel: TypedRequestHandlers['canDeleteModel'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.canDeleteModel.name }; - const { recordId } = req.params; - try { - const result = await this.manager.canDelete(recordId); - return res.status(StatusCodes.OK).json(result); - } catch (err) { - this.logger.error({ msg: 'failed to validate record deletability', logContext, err, recordId }); - return next(err); - } - }; -} diff --git a/src/models/routes/modelsRouter.ts b/src/models/routes/modelsRouter.ts deleted file mode 100644 index 1716abb..0000000 --- a/src/models/routes/modelsRouter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { ModelsController } from '../controllers/modelsController'; - -const modelsRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(ModelsController); - - router.post('/validate', controller.validateModel); - router.get('/canDelete/:recordId', controller.canDeleteModel); - - return router; -}; - -export const MODELS_ROUTER_SYMBOL = Symbol('modelsRouterFactory'); - -export { modelsRouterFactory }; diff --git a/src/openapi.d.ts b/src/openapi.d.ts index e4c5832..07455f1 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,7 +4,7 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-express-types'; export type paths = { - '/jobOperations/ingestion': { + '/records': { parameters: { query?: never; header?: never; @@ -14,17 +14,17 @@ export type paths = { get?: never; put?: never; /** - * Create a new ingestion job to invoke a new model ingestion flow + * Invoke a new model ingestion flow * @description Validates the request (light & fast) and creates a Jobnik ingestion job. */ - post: operations['createIngestion']; + post: operations['createRecord']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/jobOperations/delete': { + '/records/validate': { parameters: { query?: never; header?: never; @@ -34,25 +34,28 @@ export type paths = { get?: never; put?: never; /** - * Create a new delete job to invoke a delete flow - * @description Validates the record can be deleted and creates a Jobnik delete job. + * Invoke a new model ingestion validation + * @description Runs the full ingestion validation and returns the result without triggering a Jobnik job. */ - post: operations['createDelete']; + post: operations['validateRecord']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/jobStatus/{jobId}': { + '/records/canDelete/{recordId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get the status and progress of a job */ - get: operations['getJobStatus']; + /** + * Validate if a record can be removed + * @description Runs the delete validation and returns the result without creating a job. + */ + get: operations['canDeleteRecord']; put?: never; post?: never; delete?: never; @@ -61,7 +64,7 @@ export type paths = { patch?: never; trace?: never; }; - '/models/validate': { + '/records/{recordId}': { parameters: { query?: never; header?: never; @@ -70,38 +73,35 @@ export type paths = { }; get?: never; put?: never; + post?: never; /** - * Validate an ingestion request without creating a job - * @description Runs the full ingestion validation and returns the result without triggering a Jobnik job. + * Delete a model by record id + * @description Validates the record can be deleted and creates a Jobnik delete job. */ - post: operations['validateModel']; - delete?: never; + delete: operations['deleteRecord']; options?: never; head?: never; patch?: never; trace?: never; }; - '/models/canDelete/{recordId}': { + '/metadata/{identifier}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Validate whether a record can be deleted - * @description Runs the delete validation and returns the result without creating a job. - */ - get: operations['canDeleteModel']; + get?: never; put?: never; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** Update metadata of a model */ + patch: operations['updateMetadata']; trace?: never; }; - '/metadata/{identifier}': { + '/metadata/status/{identifier}': { parameters: { query?: never; header?: never; @@ -114,25 +114,25 @@ export type paths = { delete?: never; options?: never; head?: never; - /** Update metadata of a model */ - patch: operations['updateMetadata']; + /** Update the publish/unpublish status of a model */ + patch: operations['updateMetadataStatus']; trace?: never; }; - '/metadata/status/{identifier}': { + '/jobStatus/{jobId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get the status and progress of a job */ + get: operations['getJobStatus']; put?: never; post?: never; delete?: never; options?: never; head?: never; - /** Update the publish/unpublish status of a model */ - patch: operations['updateMetadataStatus']; + patch?: never; trace?: never; }; }; @@ -152,10 +152,6 @@ export type components = { [key: string]: unknown; }; }; - deletePayload: { - /** @description The record identifier to delete */ - id: string; - }; /** @description Partial metadata fields to update */ updatePayload: { [key: string]: unknown; @@ -195,7 +191,7 @@ export type components = { }; export type $defs = Record; export interface operations { - createIngestion: { + createRecord: { parameters: { query?: never; header?: never; @@ -228,7 +224,7 @@ export interface operations { }; }; }; - createDelete: { + validateRecord: { parameters: { query?: never; header?: never; @@ -237,83 +233,32 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['deletePayload']; + 'application/json': components['schemas']['ingestionPayload']; }; }; responses: { - /** @description Delete job created */ + /** @description Validation result */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['jobResponse']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['error']; - }; - }; - /** @description Record not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['error']; + 'application/json': components['schemas']['validationResultResponse']; }; }; }; }; - getJobStatus: { + canDeleteRecord: { parameters: { query?: never; header?: never; path: { - /** @description The job identifier */ - jobId: components['parameters']['jobId']; + /** @description The record identifier */ + recordId: components['parameters']['recordId']; }; cookie?: never; }; requestBody?: never; - responses: { - /** @description Job status */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['jobStatusResponse']; - }; - }; - /** @description Job not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['error']; - }; - }; - }; - }; - validateModel: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['ingestionPayload']; - }; - }; responses: { /** @description Validation result */ 200: { @@ -326,7 +271,7 @@ export interface operations { }; }; }; - canDeleteModel: { + deleteRecord: { parameters: { query?: never; header?: never; @@ -338,13 +283,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Validation result */ + /** @description Delete job created */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['validationResultResponse']; + 'application/json': components['schemas']['jobResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; }; }; }; @@ -439,5 +402,37 @@ export interface operations { }; }; }; + getJobStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The job identifier */ + jobId: components['parameters']['jobId']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Job status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['jobStatusResponse']; + }; + }; + /** @description Job not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + }; + }; } export type TypedRequestHandlers = ImportedTypedRequestHandlers; diff --git a/src/jobOperations/controllers/jobOperationsController.ts b/src/records/controllers/recordsController.ts similarity index 52% rename from src/jobOperations/controllers/jobOperationsController.ts rename to src/records/controllers/recordsController.ts index 32683fb..7bca530 100644 --- a/src/jobOperations/controllers/jobOperationsController.ts +++ b/src/records/controllers/recordsController.ts @@ -5,10 +5,10 @@ import { type Registry, Counter } from 'prom-client'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; -import { RecordManager } from '../../record/models/recordManager'; +import { RecordManager } from '../models/recordManager'; @injectable() -export class JobOperationsController { +export class RecordsController { private readonly logContext: LogContext; private readonly ingestionJobCounter: Counter; @@ -19,7 +19,7 @@ export class JobOperationsController { ) { this.logContext = { fileName: __filename, - class: JobOperationsController.name, + class: RecordsController.name, }; this.ingestionJobCounter = new Counter({ name: 'ingestion_jobs_created', @@ -28,8 +28,8 @@ export class JobOperationsController { }); } - public createIngestion: TypedRequestHandlers['createIngestion'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.createIngestion.name }; + public createRecord: TypedRequestHandlers['createRecord'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.createRecord.name }; try { const job = await this.manager.createIngestion(req.body); this.ingestionJobCounter.inc(1); @@ -40,14 +40,37 @@ export class JobOperationsController { } }; - public createDelete: TypedRequestHandlers['createDelete'] = async (req, res, next) => { - const logContext = { ...this.logContext, function: this.createDelete.name }; - const { id } = req.body; + public validateRecord: TypedRequestHandlers['validateRecord'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.validateRecord.name }; try { - const job = await this.manager.deleteRecord(id); + const result = await this.manager.validateIngestion(req.body); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + this.logger.error({ msg: 'failed to validate ingestion request', logContext, err }); + return next(err); + } + }; + + public canDeleteRecord: TypedRequestHandlers['canDeleteRecord'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.canDeleteRecord.name }; + const { recordId } = req.params; + try { + const result = await this.manager.canDelete(recordId); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + this.logger.error({ msg: 'failed to validate record deletability', logContext, err, recordId }); + return next(err); + } + }; + + public deleteRecord: TypedRequestHandlers['deleteRecord'] = async (req, res, next) => { + const logContext = { ...this.logContext, function: this.deleteRecord.name }; + const { recordId } = req.params; + try { + const job = await this.manager.deleteRecord(recordId); return res.status(StatusCodes.OK).json(job); } catch (err) { - this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId: id }); + this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId }); return next(err); } }; diff --git a/src/record/models/recordManager.ts b/src/records/models/recordManager.ts similarity index 100% rename from src/record/models/recordManager.ts rename to src/records/models/recordManager.ts diff --git a/src/records/routes/recordsRouter.ts b/src/records/routes/recordsRouter.ts new file mode 100644 index 0000000..c22f629 --- /dev/null +++ b/src/records/routes/recordsRouter.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { RecordsController } from '../controllers/recordsController'; + +const recordsRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(RecordsController); + + router.post('/', controller.createRecord); + router.post('/validate', controller.validateRecord); + router.get('/canDelete/:recordId', controller.canDeleteRecord); + router.delete('/:recordId', controller.deleteRecord); + + return router; +}; + +export const RECORDS_ROUTER_SYMBOL = Symbol('recordsRouterFactory'); + +export { recordsRouterFactory }; diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 4c8e7fd..900566d 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -10,9 +10,8 @@ import { collectMetricsExpressMiddleware } from '@map-colonies/prometheus'; import { Registry } from 'prom-client'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; -import { JOB_OPERATIONS_ROUTER_SYMBOL } from './jobOperations/routes/jobOperationsRouter'; +import { RECORDS_ROUTER_SYMBOL } from './records/routes/recordsRouter'; import { JOB_STATUS_ROUTER_SYMBOL } from './jobStatus/routes/jobStatusRouter'; -import { MODELS_ROUTER_SYMBOL } from './models/routes/modelsRouter'; import { METADATA_ROUTER_SYMBOL } from './metadata/routes/metadataRouter'; @injectable() @@ -23,9 +22,8 @@ export class ServerBuilder { @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(JOB_OPERATIONS_ROUTER_SYMBOL) private readonly jobOperationsRouter: Router, + @inject(RECORDS_ROUTER_SYMBOL) private readonly recordsRouter: Router, @inject(JOB_STATUS_ROUTER_SYMBOL) private readonly jobStatusRouter: Router, - @inject(MODELS_ROUTER_SYMBOL) private readonly modelsRouter: Router, @inject(METADATA_ROUTER_SYMBOL) private readonly metadataRouter: Router ) { this.serverInstance = express(); @@ -49,9 +47,8 @@ export class ServerBuilder { } private buildRoutes(): void { - this.serverInstance.use('/jobOperations', this.jobOperationsRouter); + this.serverInstance.use('/records', this.recordsRouter); this.serverInstance.use('/jobStatus', this.jobStatusRouter); - this.serverInstance.use('/models', this.modelsRouter); this.serverInstance.use('/metadata', this.metadataRouter); this.buildDocsRoutes(); } diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 3805e3e..94e4445 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -17,7 +17,7 @@ import { ExtractableCall } from '../externalServices/extractableManagement/extra import { TilesetReader } from '../tileset/tilesetReader'; import type { Record3D } from '../externalServices/catalog/interfaces'; import type { Provider } from '../providers/interfaces'; -import type { IngestionPayload } from '../record/models/recordManager'; +import type { IngestionPayload } from '../records/models/recordManager'; type UpdatePayload = components['schemas']['updatePayload']; diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index acdd9de..64e7ad5 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -77,21 +77,21 @@ describe('3d-ops-trigger', function () { requestSender = await createRequestSender('openapi3.yaml', app); }); - describe('POST /jobOperations/ingestion', function () { + describe('POST /records', function () { it('should return 201 and a job response for a valid ingestion request', async function () { - const response = await requestSender.createIngestion({ requestBody: validIngestionPayload }); + const response = await requestSender.createRecord({ requestBody: validIngestionPayload }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.CREATED); - const body = response.body as paths['/jobOperations/ingestion']['post']['responses']['201']['content']['application/json']; + const body = response.body as paths['/records']['post']['responses']['201']['content']['application/json']; expect(body.jobId).toBeTypeOf('string'); expect(body.status).toBeTypeOf('string'); }); it('should return 400 when a required field is missing', async function () { - const response = await requestSender.createIngestion({ + const response = await requestSender.createRecord({ // @ts-expect-error intentionally invalid: missing tilesetFilename and metadata requestBody: { modelPath: '/app/models/afula' }, }); @@ -101,7 +101,7 @@ describe('3d-ops-trigger', function () { }); it('should return 400 when metadata fails business validation', async function () { - const response = await requestSender.createIngestion({ + const response = await requestSender.createRecord({ requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, }); @@ -110,36 +110,9 @@ describe('3d-ops-trigger', function () { }); }); - describe('POST /jobOperations/delete', function () { - it('should return 200 and a job response', async function () { - const response = await requestSender.createDelete({ requestBody: { id: 'rec-1' } }); - - expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.OK); - - const body = response.body as paths['/jobOperations/delete']['post']['responses']['200']['content']['application/json']; - - expect(body.jobId).toBeTypeOf('string'); - }); - }); - - describe('GET /jobStatus/{jobId}', function () { - it('should return 200 with the job status and percentage', async function () { - const response = await requestSender.getJobStatus({ pathParams: { jobId: 'job-1' } }); - - expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.OK); - - const body = response.body as paths['/jobStatus/{jobId}']['get']['responses']['200']['content']['application/json']; - - expect(body.status).toBeTypeOf('string'); - expect(body.percentage).toBe(42); - }); - }); - - describe('POST /models/validate', function () { + describe('POST /records/validate', function () { it('should return 200 with isValid true for a valid request without creating a job', async function () { - const response = await requestSender.validateModel({ requestBody: validIngestionPayload }); + const response = await requestSender.validateRecord({ requestBody: validIngestionPayload }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); @@ -150,7 +123,7 @@ describe('3d-ops-trigger', function () { }); it('should return 200 with isValid false and a message for an invalid request', async function () { - const response = await requestSender.validateModel({ + const response = await requestSender.validateRecord({ requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, }); @@ -164,9 +137,9 @@ describe('3d-ops-trigger', function () { }); }); - describe('GET /models/canDelete/{recordId}', function () { + describe('GET /records/canDelete/{recordId}', function () { it('should return 200 with isValid true for a deletable record', async function () { - const response = await requestSender.canDeleteModel({ pathParams: { recordId: 'rec-1' } }); + const response = await requestSender.canDeleteRecord({ pathParams: { recordId: 'rec-1' } }); expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.OK); @@ -177,6 +150,19 @@ describe('3d-ops-trigger', function () { }); }); + describe('DELETE /records/{recordId}', function () { + it('should return 200 and a job response', async function () { + const response = await requestSender.deleteRecord({ pathParams: { recordId: 'rec-1' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body as paths['/records/{recordId}']['delete']['responses']['200']['content']['application/json']; + + expect(body.jobId).toBeTypeOf('string'); + }); + }); + describe('PATCH /metadata/{identifier}', function () { it('should return 200 and an ack for a metadata update', async function () { const response = await requestSender.updateMetadata({ pathParams: { identifier: 'rec-1' }, requestBody: { description: 'updated' } }); @@ -206,4 +192,18 @@ describe('3d-ops-trigger', function () { expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); }); }); + + describe('GET /jobStatus/{jobId}', function () { + it('should return 200 with the job status and percentage', async function () { + const response = await requestSender.getJobStatus({ pathParams: { jobId: 'job-1' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body as paths['/jobStatus/{jobId}']['get']['responses']['200']['content']['application/json']; + + expect(body.status).toBeTypeOf('string'); + expect(body.percentage).toBe(42); + }); + }); }); diff --git a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts index 395cc7e..3c7fb5a 100644 --- a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts +++ b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts @@ -4,7 +4,7 @@ import { Registry } from 'prom-client'; import { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import { STAGE_TYPES } from '@src/common/constants'; import type { ConfigType } from '@src/common/config'; -import type { IngestionPayload } from '@src/record/models/recordManager'; +import type { IngestionPayload } from '@src/records/models/recordManager'; const jobManagerConfig = { url: 'http://job-manager', diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/records/models/recordManager.spec.ts similarity index 99% rename from tests/unit/record/models/recordManager.spec.ts rename to tests/unit/records/models/recordManager.spec.ts index 59cc0d7..ef7f88c 100644 --- a/tests/unit/record/models/recordManager.spec.ts +++ b/tests/unit/records/models/recordManager.spec.ts @@ -1,6 +1,6 @@ import { jsLogger } from '@map-colonies/js-logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; +import { RecordManager, type IngestionPayload } from '@src/records/models/recordManager'; import type { ValidationManager } from '@src/validator/validationManager'; import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index 91f0509..f088ca6 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -28,7 +28,7 @@ import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import type { TilesetReader } from '@src/tileset/tilesetReader'; import type { ExtractableCall } from '@src/externalServices/extractableManagement/extractableCall'; import type { Provider } from '@src/providers/interfaces'; -import type { IngestionPayload } from '@src/record/models/recordManager'; +import type { IngestionPayload } from '@src/records/models/recordManager'; import { buildValidMetadata } from '@tests/helpers/metadata'; const regionTilesetJson = readFileSync(join(__dirname, '../../helpers/tilesets/folder/tileset.json'), 'utf-8'); From ddb35c84cac40e1db29610ea3f9a90b1a93b16f8 Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Wed, 30 Sep 2026 16:55:25 +0300 Subject: [PATCH 13/14] feat: mint modelId for ingestion jobs and harden the create flow (MAPCO-11833) Mint a modelId (uuid) at ingestion time, pass it into the Jobnik job data so the finalizer has a stable catalog id, and tag the trace span with it. Wrap the create flow in a try/catch that passes AppError through and maps unexpected errors to a 500, mirroring 3d-gateway's createModel. productSource and PV-path rewriting are intentionally omitted: productSource does not exist in the v2 metadata schema, and path rewriting is store-trigger mount-specific (revisit only if the Jobnik worker contract requires it). --- src/externalServices/jobnik/jobnikClient.ts | 4 +-- src/records/models/recordManager.ts | 28 ++++++++++++++----- .../jobnik/jobnikClient.spec.ts | 9 ++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/externalServices/jobnik/jobnikClient.ts b/src/externalServices/jobnik/jobnikClient.ts index 63caf44..52d11bb 100644 --- a/src/externalServices/jobnik/jobnikClient.ts +++ b/src/externalServices/jobnik/jobnikClient.ts @@ -89,13 +89,13 @@ export class JobnikClient { return { status: data.status, percentage: data.percentage }; } - public async createIngestionJob(payload: IngestionPayload): Promise { + public async createIngestionJob(payload: IngestionPayload, modelId: string): Promise { const logContext = { ...this.logContext, function: this.createIngestionJob.name }; const isArchive = is3tz(payload.modelPath); const job = await this.producer.createJob({ name: this.jobManager.ingestion.jobType, - data: { modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename, metadata: payload.metadata }, + data: { modelId, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename, metadata: payload.metadata }, }); const stages: StageDescriptor[] = [ diff --git a/src/records/models/recordManager.ts b/src/records/models/recordManager.ts index d7cb86d..8ecb245 100644 --- a/src/records/models/recordManager.ts +++ b/src/records/models/recordManager.ts @@ -1,5 +1,8 @@ +import { randomUUID } from 'node:crypto'; import type { Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; +import { trace } from '@opentelemetry/api'; +import { StatusCodes } from 'http-status-codes'; import type { Polygon } from 'geojson'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; @@ -37,15 +40,26 @@ export class RecordManager { public async createIngestion(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.createIngestion.name }; - this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); - await this.validator.validateIngestion(payload); + const modelId = randomUUID(); + this.logger.info({ msg: 'creating ingestion job', logContext, modelId, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); + trace.getActiveSpan()?.setAttribute('catalogId', modelId); - const metadata = payload.metadata as Record; - if (metadata.footprint !== undefined) { - metadata.footprint = convertPolygonTo2DPolygon(metadata.footprint as Polygon); - } + try { + await this.validator.validateIngestion(payload); + + const metadata = payload.metadata as Record; + if (metadata.footprint !== undefined) { + metadata.footprint = convertPolygonTo2DPolygon(metadata.footprint as Polygon); + } - return this.jobnik.createIngestionJob(payload); + return await this.jobnik.createIngestionJob(payload, modelId); + } catch (err) { + if (err instanceof AppError) { + throw err; + } + this.logger.error({ msg: 'unexpected error while creating ingestion job', logContext, modelId, err }); + throw new AppError('error', StatusCodes.INTERNAL_SERVER_ERROR, String(err), true); + } } public async validateIngestion(payload: IngestionPayload): Promise { diff --git a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts index 3c7fb5a..95378a3 100644 --- a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts +++ b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts @@ -56,9 +56,12 @@ describe('JobnikClient', function () { }); it('should create the ingestion job and a validation stage for a folder input', async function () { - const result = await client.createIngestionJob(buildPayload('/shared/models/afula')); + const result = await client.createIngestionJob(buildPayload('/shared/models/afula'), 'model-1'); - expect(producerMock.createJob).toHaveBeenCalledWith(expect.objectContaining({ name: jobManagerConfig.ingestion.jobType })); + const createJobArg = (producerMock.createJob.mock.calls as [{ name: string; data: { modelId: string } }][])[0][0]; + + expect(createJobArg.name).toBe(jobManagerConfig.ingestion.jobType); + expect(createJobArg.data.modelId).toBe('model-1'); const folderStageTypes = (producerMock.createStage.mock.calls as [string, { type: string }][]).map((call) => call[1].type); @@ -79,7 +82,7 @@ describe('JobnikClient', function () { }); it('should also create the data-extraction and clear-data stages for a 3tz input', async function () { - await client.createIngestionJob(buildPayload('/shared/models/afula.3tz')); + await client.createIngestionJob(buildPayload('/shared/models/afula.3tz'), 'model-1'); const stageTypes = (producerMock.createStage.mock.calls as [string, { type: string }][]).map((call) => call[1].type); From 31ccdaeeee92a242ed5ca601b2482cbf0e23cb4e Mon Sep 17 00:00:00 2001 From: TULCHINSKI LIRAN Date: Thu, 1 Oct 2026 11:44:52 +0300 Subject: [PATCH 14/14] refactor: centralize DTOs/enums and split the validator (MAPCO-11833) - move record/job DTO aliases into @common/interfaces, dropping the duplicate UpdatePayload and the local JobStatusResponse - consume RecordStatus/ProductType from @map-colonies/types instead of hardcoded status/productType literals (adds @map-colonies/types + mime-types) - split ValidationManager into IngestionValidator, MetadataValidator and TilesetValidator, with shared errors.ts and a classification helper - dedupe CatalogCall error handling behind a single request helper --- package-lock.json | 157 ++++++--- package.json | 2 + src/common/interfaces.ts | 10 + src/externalServices/catalog/catalogCall.ts | 78 +++-- src/externalServices/jobnik/jobnikClient.ts | 8 +- src/job/models/jobManager.ts | 5 +- src/records/models/recordManager.ts | 45 +-- src/validator/classification.ts | 24 ++ src/validator/errors.ts | 18 + src/validator/ingestionValidator.ts | 138 ++++++++ src/validator/metadataValidator.ts | 127 +++++++ src/validator/tilesetValidator.ts | 98 ++++++ src/validator/validationManager.ts | 327 ------------------ .../jobnik/jobnikClient.spec.ts | 2 +- .../unit/records/models/recordManager.spec.ts | 14 +- ...ger.spec.ts => ingestionValidator.spec.ts} | 127 +------ .../unit/validator/metadataValidator.spec.ts | 126 +++++++ 17 files changed, 745 insertions(+), 561 deletions(-) create mode 100644 src/validator/classification.ts create mode 100644 src/validator/errors.ts create mode 100644 src/validator/ingestionValidator.ts create mode 100644 src/validator/metadataValidator.ts create mode 100644 src/validator/tilesetValidator.ts delete mode 100644 src/validator/validationManager.ts rename tests/unit/validator/{validationManager.spec.ts => ingestionValidator.spec.ts} (62%) create mode 100644 tests/unit/validator/metadataValidator.spec.ts diff --git a/package-lock.json b/package-lock.json index 08ceef5..d48d080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@map-colonies/schemas": "^1.26.0", "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", + "@map-colonies/types": "^1.4.0", "@opentelemetry/api": "^1.9.1", "@turf/turf": "^7.4.0", "axios": "^1.20.0", @@ -30,6 +31,7 @@ "express": "^4.21.2", "express-openapi-validator": "^5.6.2", "http-status-codes": "^2.3.0", + "mime-types": "^2.1.35", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "tsyringe": "^4.8.0", @@ -1869,6 +1871,19 @@ "node": ">=24" } }, + "node_modules/@map-colonies/types": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@map-colonies/types/-/types-1.10.0.tgz", + "integrity": "sha512-FwVrBAYzjnpmd9Jnq7Mex7axl8KR6o8JjKVGu3kDCGHz26Ru9qSCpzkA+zUVV8fKNxWVZSmDi7IiHEe9WsOalw==", + "license": "ISC", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/mime-types": "^2.1.1", + "copyfiles": "^2.4.1", + "epsg-index": "^1.3.0", + "ts-morph": "^18.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1905,7 +1920,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1919,7 +1933,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1929,7 +1942,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4763,6 +4775,63 @@ "dev": true, "license": "MIT" }, + "node_modules/@ts-morph/common": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.19.0.tgz", + "integrity": "sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.12", + "minimatch": "^7.4.3", + "mkdirp": "^2.1.6", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz", + "integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@ts-morph/common/node_modules/mkdirp": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", + "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@turf/along": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.4.0.tgz", @@ -7736,6 +7805,12 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "license": "MIT" + }, "node_modules/@types/multer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", @@ -9083,7 +9158,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -9418,6 +9492,12 @@ "node": ">=8" } }, + "node_modules/code-block-writer": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", + "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9542,7 +9622,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9681,7 +9760,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", - "dev": true, "license": "MIT", "dependencies": { "glob": "^7.0.5", @@ -9701,14 +9779,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/copyfiles/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9719,7 +9795,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -9731,7 +9806,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9744,7 +9818,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9759,7 +9832,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9772,7 +9844,6 @@ "version": "16.2.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^7.0.2", @@ -9791,7 +9862,6 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -9815,7 +9885,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cosmiconfig": { @@ -10111,6 +10180,15 @@ "node": ">=6" } }, + "node_modules/epsg-index": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/epsg-index/-/epsg-index-1.3.0.tgz", + "integrity": "sha512-pWJ7lkMbH0Au+MJyBV4GxiX+KmOknPc6c5FqDAYD61Ju3LblR60VGmOlteBhK9Dm3oy++Jxa2kUimONeSvsKRA==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -10768,7 +10846,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -10785,7 +10862,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -10834,7 +10910,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -10882,7 +10957,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -11073,7 +11147,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11240,7 +11313,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -11274,14 +11346,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -11292,7 +11362,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11593,7 +11662,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -11681,7 +11749,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11700,7 +11767,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -11726,7 +11792,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -11774,7 +11839,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -12498,7 +12562,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -12517,7 +12580,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -12531,7 +12593,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -12634,7 +12695,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -12826,7 +12886,6 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", "integrity": "sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==", - "dev": true, "license": "ISC", "dependencies": { "inherits": "^2.0.1", @@ -12837,7 +12896,6 @@ "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -12850,7 +12908,6 @@ "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true, "license": "MIT" }, "node_modules/normalize-path": { @@ -13596,6 +13653,12 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13610,7 +13673,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14051,7 +14113,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/process-warning": { @@ -14178,7 +14239,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -14407,7 +14467,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -14496,7 +14555,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -15216,7 +15274,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, "license": "MIT", "dependencies": { "readable-stream": "~2.3.6", @@ -15227,14 +15284,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/through2/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -15250,14 +15305,12 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/through2/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -15317,7 +15370,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -15417,6 +15469,16 @@ } } }, + "node_modules/ts-morph": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-18.0.0.tgz", + "integrity": "sha512-Kg5u0mk19PIIe4islUI/HWRvm9bC1lHejK4S0oh1zaZ77TMZAEmQC0sHQYiu2RgCQFZKXz1fMVi/7nOOeirznA==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.19.0", + "code-block-writer": "^12.0.0" + } + }, "node_modules/tsc-alias": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.1.tgz", @@ -15644,7 +15706,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" diff --git a/package.json b/package.json index de2aeb0..33030e1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@map-colonies/schemas": "^1.26.0", "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", + "@map-colonies/types": "^1.4.0", "@opentelemetry/api": "^1.9.1", "@turf/turf": "^7.4.0", "axios": "^1.20.0", @@ -56,6 +57,7 @@ "express": "^4.21.2", "express-openapi-validator": "^5.6.2", "http-status-codes": "^2.3.0", + "mime-types": "^2.1.35", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "tsyringe": "^4.8.0", diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 6220fde..879c230 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,3 +1,13 @@ +import type { components } from '@openapi'; + +export type IngestionPayload = components['schemas']['ingestionPayload']; +export type UpdatePayload = components['schemas']['updatePayload']; +export type StatusPayload = components['schemas']['statusPayload']; +export type JobResponse = components['schemas']['jobResponse']; +export type AckResponse = components['schemas']['ackResponse']; +export type ValidationResultResponse = components['schemas']['validationResultResponse']; +export type JobStatusResponse = components['schemas']['jobStatusResponse']; + export interface IConfig { get: (setting: string) => T; has: (setting: string) => boolean; diff --git a/src/externalServices/catalog/catalogCall.ts b/src/externalServices/catalog/catalogCall.ts index 8e8c585..90696d0 100644 --- a/src/externalServices/catalog/catalogCall.ts +++ b/src/externalServices/catalog/catalogCall.ts @@ -27,7 +27,7 @@ export class CatalogCall { public async findRecords(payload: IFindRecordsPayload): Promise { const logContext = { ...this.logContext, function: this.findRecords.name }; this.logger.debug({ msg: `Searching in catalog ${this.catalog}/metadata/find`, logContext, payload }); - try { + return this.run(logContext, 'Something went wrong in catalog when trying to find records', 'Problem with catalog find', {}, async () => { const response = await axios.post(`${this.catalog}/metadata/find`, payload); if (response.status !== StatusCodes.OK.valueOf()) { this.logger.error({ msg: `Catalog returned unexpected status: ${response.status}`, logContext }); @@ -41,51 +41,71 @@ export class CatalogCall { this.logger.debug({ msg: `Found ${records.length} record(s) in catalog`, logContext }); return records; - } catch (err) { - if (err instanceof AppError) { - throw err; - } - - this.logger.error({ msg: 'Something went wrong in catalog when trying to find records', logContext, err }); - throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog find', true); - } + }); } public async getRecord(identifier: string): Promise { const logContext = { ...this.logContext, function: this.getRecord.name }; this.logger.debug({ msg: `Getting record ${identifier} from catalog`, logContext }); - try { - const response = await axios.get(`${this.catalog}/metadata/${identifier}`, { - validateStatus: (status) => status === StatusCodes.OK.valueOf() || status === StatusCodes.NOT_FOUND.valueOf(), - }); - return response.status === StatusCodes.NOT_FOUND.valueOf() ? undefined : response.data; - } catch (err) { - this.logger.error({ msg: 'Something went wrong in catalog when getting a record', logContext, identifier, err }); - throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during record lookup', true); - } + return this.run( + logContext, + 'Something went wrong in catalog when getting a record', + 'Problem with catalog during record lookup', + { identifier }, + async () => { + const response = await axios.get(`${this.catalog}/metadata/${identifier}`, { + validateStatus: (status) => status === StatusCodes.OK.valueOf() || status === StatusCodes.NOT_FOUND.valueOf(), + }); + return response.status === StatusCodes.NOT_FOUND.valueOf() ? undefined : response.data; + } + ); } public async patchMetadata(identifier: string, payload: CatalogUpdatePayload): Promise { const logContext = { ...this.logContext, function: this.patchMetadata.name }; this.logger.debug({ msg: `Updating metadata for record ${identifier} in catalog`, logContext }); - try { - const response = await axios.patch(`${this.catalog}/metadata/${identifier}`, payload); - return response.data; - } catch (err) { - this.logger.error({ msg: 'Something went wrong in catalog when updating metadata', logContext, identifier, err }); - throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during metadata update', true); - } + return this.run( + logContext, + 'Something went wrong in catalog when updating metadata', + 'Problem with catalog during metadata update', + { identifier }, + async () => { + const response = await axios.patch(`${this.catalog}/metadata/${identifier}`, payload); + return response.data; + } + ); } public async changeStatus(identifier: string, payload: CatalogStatusPayload): Promise { const logContext = { ...this.logContext, function: this.changeStatus.name }; this.logger.debug({ msg: `Changing status for record ${identifier} in catalog`, logContext }); + return this.run( + logContext, + 'Something went wrong in catalog when changing status', + 'Problem with catalog during status change', + { identifier }, + async () => { + const response = await axios.patch(`${this.catalog}/metadata/status/${identifier}`, payload); + return response.data; + } + ); + } + + private async run( + logContext: LogContext, + errorLog: string, + failMessage: string, + details: Record, + fn: () => Promise + ): Promise { try { - const response = await axios.patch(`${this.catalog}/metadata/status/${identifier}`, payload); - return response.data; + return await fn(); } catch (err) { - this.logger.error({ msg: 'Something went wrong in catalog when changing status', logContext, identifier, err }); - throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during status change', true); + if (err instanceof AppError) { + throw err; + } + this.logger.error({ msg: errorLog, logContext, ...details, err }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, failMessage, true); } } } diff --git a/src/externalServices/jobnik/jobnikClient.ts b/src/externalServices/jobnik/jobnikClient.ts index 52d11bb..52c1d69 100644 --- a/src/externalServices/jobnik/jobnikClient.ts +++ b/src/externalServices/jobnik/jobnikClient.ts @@ -8,8 +8,7 @@ import { IN_FLIGHT_JOB_STATUSES, SERVICES, STAGE_TYPES } from '@common/constants import { is3tz } from '@common/util'; import { AppError } from '@common/appError'; import type { ConfigType, JobManagerConfig } from '@common/config'; -import type { LogContext } from '@common/interfaces'; -import type { IngestionPayload, JobResponse } from '../../records/models/recordManager'; +import type { IngestionPayload, JobResponse, JobStatusResponse, LogContext } from '@common/interfaces'; import type { Record3D } from '../catalog/interfaces'; interface StageDescriptor { @@ -19,11 +18,6 @@ interface StageDescriptor { only3tz?: boolean; } -export interface JobStatusResponse { - status: string; - percentage?: number; -} - @singleton() export class JobnikClient { private readonly logContext: LogContext; diff --git a/src/job/models/jobManager.ts b/src/job/models/jobManager.ts index f2767ce..21949b3 100644 --- a/src/job/models/jobManager.ts +++ b/src/job/models/jobManager.ts @@ -1,12 +1,9 @@ import type { Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; -import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; -import type { LogContext } from '@common/interfaces'; +import type { JobStatusResponse, LogContext } from '@common/interfaces'; import { JobnikClient } from '../../externalServices/jobnik/jobnikClient'; -export type JobStatusResponse = components['schemas']['jobStatusResponse']; - @injectable() export class JobManager { private readonly logContext: LogContext; diff --git a/src/records/models/recordManager.ts b/src/records/models/recordManager.ts index 8ecb245..8fb11c8 100644 --- a/src/records/models/recordManager.ts +++ b/src/records/models/recordManager.ts @@ -4,31 +4,32 @@ import { inject, injectable } from 'tsyringe'; import { trace } from '@opentelemetry/api'; import { StatusCodes } from 'http-status-codes'; import type { Polygon } from 'geojson'; -import type { components } from '@openapi'; +import { RecordStatus } from '@map-colonies/types'; import { SERVICES } from '@common/constants'; -import type { LogContext } from '@common/interfaces'; +import type { + AckResponse, + IngestionPayload, + JobResponse, + LogContext, + StatusPayload, + UpdatePayload, + ValidationResultResponse, +} from '@common/interfaces'; import { AppError } from '@common/appError'; import { convertPolygonTo2DPolygon } from '@common/util'; -import { ValidationManager } from '../../validator/validationManager'; +import { IngestionValidator } from '../../validator/ingestionValidator'; +import { MetadataValidator } from '../../validator/metadataValidator'; import { JobnikClient } from '../../externalServices/jobnik/jobnikClient'; import { CatalogCall } from '../../externalServices/catalog/catalogCall'; -const RECORD_STATUS_BEING_DELETED = 'BEING_DELETED'; - -export type IngestionPayload = components['schemas']['ingestionPayload']; -export type UpdatePayload = components['schemas']['updatePayload']; -export type StatusPayload = components['schemas']['statusPayload']; -export type JobResponse = components['schemas']['jobResponse']; -export type AckResponse = components['schemas']['ackResponse']; -export type ValidationResultResponse = components['schemas']['validationResultResponse']; - @injectable() export class RecordManager { private readonly logContext: LogContext; public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(ValidationManager) private readonly validator: ValidationManager, + @inject(IngestionValidator) private readonly ingestionValidator: IngestionValidator, + @inject(MetadataValidator) private readonly metadataValidator: MetadataValidator, @inject(JobnikClient) private readonly jobnik: JobnikClient, @inject(CatalogCall) private readonly catalog: CatalogCall ) { @@ -45,7 +46,7 @@ export class RecordManager { trace.getActiveSpan()?.setAttribute('catalogId', modelId); try { - await this.validator.validateIngestion(payload); + await this.ingestionValidator.validateIngestion(payload); const metadata = payload.metadata as Record; if (metadata.footprint !== undefined) { @@ -66,7 +67,7 @@ export class RecordManager { const logContext = { ...this.logContext, function: this.validateIngestion.name }; this.logger.info({ msg: 'validating ingestion request', logContext, modelPath: payload.modelPath }); try { - await this.validator.validateIngestion(payload); + await this.ingestionValidator.validateIngestion(payload); return { isValid: true }; } catch (err) { if (err instanceof AppError) { @@ -80,7 +81,7 @@ export class RecordManager { const logContext = { ...this.logContext, function: this.canDelete.name }; this.logger.info({ msg: 'validating record deletability', logContext, recordId: id }); try { - await this.validator.validateDelete(id); + await this.metadataValidator.validateDelete(id); return { isValid: true }; } catch (err) { if (err instanceof AppError) { @@ -93,17 +94,17 @@ export class RecordManager { public async deleteRecord(id: string): Promise { const logContext = { ...this.logContext, function: this.deleteRecord.name }; this.logger.info({ msg: 'creating delete job', logContext, recordId: id }); - const record = await this.validator.validateDelete(id); + const record = await this.metadataValidator.validateDelete(id); const job = await this.jobnik.createDeleteJob(record); - await this.catalog.changeStatus(id, { productStatus: RECORD_STATUS_BEING_DELETED }); + await this.catalog.changeStatus(id, { productStatus: RecordStatus.BEING_DELETED }); return job; } public async updateMetadata(id: string, update: UpdatePayload): Promise { const logContext = { ...this.logContext, function: this.updateMetadata.name }; this.logger.info({ msg: 'updating record metadata', logContext, recordId: id }); - const record = await this.validator.validateUpdate(id, update); - await this.validator.ensureRecordAbsentFromExtractable(record); + const record = await this.metadataValidator.validateUpdate(id, update); + await this.metadataValidator.ensureRecordAbsentFromExtractable(record); const payload: Record = { ...update }; if (payload.footprint !== undefined) { @@ -117,8 +118,8 @@ export class RecordManager { public async updateStatus(id: string, payload: StatusPayload): Promise { const logContext = { ...this.logContext, function: this.updateStatus.name }; this.logger.info({ msg: 'updating record status', logContext, recordId: id, status: payload.status }); - const record = await this.validator.validateStatusChange(id); - await this.validator.ensureRecordAbsentFromExtractable(record); + const record = await this.metadataValidator.validateStatusChange(id); + await this.metadataValidator.ensureRecordAbsentFromExtractable(record); await this.catalog.changeStatus(id, { productStatus: payload.status }); diff --git a/src/validator/classification.ts b/src/validator/classification.ts new file mode 100644 index 0000000..91db638 --- /dev/null +++ b/src/validator/classification.ts @@ -0,0 +1,24 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import type { LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import type { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; + +export const validateClassification = async ( + lookupTables: LookupTablesCall, + logger: Logger, + logContext: LogContext, + classification: string +): Promise => { + const classifications = await lookupTables.getClassifications(); + logger.debug({ msg: 'classification validation', logContext, classifications }); + + if (!classifications.includes(classification)) { + throw new AppError( + 'badRequest', + StatusCodes.BAD_REQUEST, + `classification is not a valid value. Optional values: ${classifications.join()}`, + true + ); + } +}; diff --git a/src/validator/errors.ts b/src/validator/errors.ts new file mode 100644 index 0000000..5817937 --- /dev/null +++ b/src/validator/errors.ts @@ -0,0 +1,18 @@ +export const ERROR_METADATA_DATE = 'imagingTimeBeginUTC must not be later than imagingTimeEndUTC'; +export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC are required'; +export const ERROR_METADATA_INVALID_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC must be valid dates'; +export const ERROR_METADATA_FOOTPRINT = 'Invalid footprint! Must be a GeoJSON Polygon or MultiPolygon with all-2D or all-3D coordinates'; +export const ERROR_METADATA_PRODUCT_NAME_UNIQUE = 'product name is not unique!'; +export const ERROR_PRODUCT_NAME_IN_FLIGHT = 'product name already has an in-flight ingestion job'; +export const ERROR_DELETE_RECORD_NOT_FOUND = "recordId doesn't match exactly one existing record"; +export const ERROR_DELETE_PRODUCT_TYPE = 'Cannot delete a record whose productType is "QuantizedMeshDTMBest"'; +export const ERROR_DELETE_STATUS = 'Cannot delete a record whose productStatus is not "UNPUBLISHED"'; +export const ERROR_FILE_NOT_FOUND = 'The model files do not exist in the agreed storage'; +export const ERROR_TILESET_INVALID = 'The tileset file is not a valid 3DTiles tileset'; +export const ERROR_FOOTPRINT_FAR_FROM_MODEL = "Wrong footprint! footprint's coordinates is not even close to the model!"; +export const ERROR_INTERSECTION_FAILED = 'An error caused during the validation of the intersection'; +export const ERROR_RECORD_NOT_FOUND = "record with the given identifier doesn't exist"; +export const ERROR_RECORD_BEING_DELETED = 'cannot change a record that is being deleted'; +export const ERROR_EXTRACTABLE_CONFLICT = 'the record exists in the extractable-management service and cannot be changed here'; +export const ERROR_MODEL_PATH_INVALID = 'Unknown model path! The model is not under the agreed base path'; +export const ERROR_PRODUCT_ID_EXISTS = 'a record with this productId already exists in the catalog'; diff --git a/src/validator/ingestionValidator.ts b/src/validator/ingestionValidator.ts new file mode 100644 index 0000000..7c4b0c7 --- /dev/null +++ b/src/validator/ingestionValidator.ts @@ -0,0 +1,138 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'tsyringe'; +import { new3DLayerMetadataSchema, geometrySchema } from '@map-colonies/3d-shared'; +import { SERVICES } from '@common/constants'; +import type { IngestionPayload, LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import { buildModelFilePath } from '@common/util'; +import type { ConfigType } from '@common/config'; +import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; +import { CatalogCall } from '../externalServices/catalog/catalogCall'; +import { JobnikClient } from '../externalServices/jobnik/jobnikClient'; +import type { Provider } from '../providers/interfaces'; +import { TilesetValidator } from './tilesetValidator'; +import { validateClassification } from './classification'; +import { + ERROR_FILE_NOT_FOUND, + ERROR_METADATA_DATE, + ERROR_METADATA_FOOTPRINT, + ERROR_METADATA_INVALID_DATE, + ERROR_METADATA_MISSING_DATE, + ERROR_METADATA_PRODUCT_NAME_UNIQUE, + ERROR_MODEL_PATH_INVALID, + ERROR_PRODUCT_ID_EXISTS, + ERROR_PRODUCT_NAME_IN_FLIGHT, +} from './errors'; + +@injectable() +export class IngestionValidator { + private readonly logContext: LogContext; + private readonly basePath: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, + @inject(CatalogCall) private readonly catalog: CatalogCall, + @inject(JobnikClient) private readonly jobnik: JobnikClient, + @inject(SERVICES.PROVIDER) private readonly provider: Provider, + @inject(TilesetValidator) private readonly tileset: TilesetValidator + ) { + this.basePath = this.config.get('validation.basePath'); + this.logContext = { + fileName: __filename, + class: IngestionValidator.name, + }; + } + + public async validateIngestion(payload: IngestionPayload): Promise { + const logContext = { ...this.logContext, function: this.validateIngestion.name }; + this.logger.info({ msg: 'ingestion validation start', logContext }); + + const metadata = payload.metadata; + const parsed = new3DLayerMetadataSchema.safeParse(metadata); + if (!parsed.success) { + const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } + + const footprintResult = geometrySchema.safeParse(metadata.footprint); + if (!footprintResult.success) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); + } + + this.validateDates(metadata.imagingTimeBeginUTC, metadata.imagingTimeEndUTC); + this.validateModelPath(payload.modelPath); + + await validateClassification(this.lookupTables, this.logger, logContext, parsed.data.classification); + await this.validateProductNameUnique(parsed.data.productName); + await this.validateProductNameNotInFlight(parsed.data.productName); + await this.validateProductIdUnique(parsed.data.productId); + await this.validateFileExists(payload.modelPath, payload.tilesetFilename); + await this.tileset.validateTileset(payload, footprintResult.data); + } + + private validateModelPath(modelPath: string): void { + if (!modelPath.startsWith(this.basePath)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, `${ERROR_MODEL_PATH_INVALID} (basePath: ${this.basePath})`, true); + } + } + + private async validateProductIdUnique(productId: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductIdUnique.name }; + const records = await this.catalog.findRecords({ productId }); + this.logger.debug({ msg: 'product id uniqueness validation', logContext, productId, matches: records.length }); + + if (records.length > 0) { + throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_PRODUCT_ID_EXISTS, true); + } + } + + private async validateFileExists(modelPath: string, tilesetFilename: string): Promise { + const logContext = { ...this.logContext, function: this.validateFileExists.name }; + const path = buildModelFilePath(modelPath, tilesetFilename); + const exists = await this.provider.fileExists(path); + this.logger.debug({ msg: 'file existence validation', logContext, path, exists }); + + if (!exists) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_FILE_NOT_FOUND, true); + } + } + + private async validateProductNameUnique(productName: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductNameUnique.name }; + const records = await this.catalog.findRecords({ productName }); + this.logger.debug({ msg: 'product name uniqueness validation', logContext, productName, matches: records.length }); + + if (records.length > 0) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); + } + } + + private async validateProductNameNotInFlight(productName: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductNameNotInFlight.name }; + const inFlight = await this.jobnik.hasInFlightIngestionJob(productName); + this.logger.debug({ msg: 'in-flight duplicate validation', logContext, productName, inFlight }); + + if (inFlight) { + throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_PRODUCT_NAME_IN_FLIGHT, true); + } + } + + private validateDates(start: unknown, end: unknown): void { + if (start === undefined || end === undefined) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_MISSING_DATE, true); + } + + const startTime = new Date(start as string).getTime(); + const endTime = new Date(end as string).getTime(); + if (Number.isNaN(startTime) || Number.isNaN(endTime)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_INVALID_DATE, true); + } + + if (startTime > endTime) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_DATE, true); + } + } +} diff --git a/src/validator/metadataValidator.ts b/src/validator/metadataValidator.ts new file mode 100644 index 0000000..35cb207 --- /dev/null +++ b/src/validator/metadataValidator.ts @@ -0,0 +1,127 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'tsyringe'; +import { new3DLayerMetadataSchema, geometrySchema } from '@map-colonies/3d-shared'; +import { ProductType, RecordStatus } from '@map-colonies/types'; +import { SERVICES } from '@common/constants'; +import type { LogContext, UpdatePayload } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; +import { CatalogCall } from '../externalServices/catalog/catalogCall'; +import { ExtractableCall } from '../externalServices/extractableManagement/extractableCall'; +import type { Record3D } from '../externalServices/catalog/interfaces'; +import { validateClassification } from './classification'; +import { + ERROR_DELETE_PRODUCT_TYPE, + ERROR_DELETE_RECORD_NOT_FOUND, + ERROR_DELETE_STATUS, + ERROR_EXTRACTABLE_CONFLICT, + ERROR_METADATA_FOOTPRINT, + ERROR_METADATA_PRODUCT_NAME_UNIQUE, + ERROR_RECORD_BEING_DELETED, + ERROR_RECORD_NOT_FOUND, +} from './errors'; + +const BLOCKED_DELETE_PRODUCT_TYPE = ProductType.QUANTIZED_MESH_DTM_BEST; + +@injectable() +export class MetadataValidator { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, + @inject(CatalogCall) private readonly catalog: CatalogCall, + @inject(ExtractableCall) private readonly extractable: ExtractableCall + ) { + this.logContext = { + fileName: __filename, + class: MetadataValidator.name, + }; + } + + public async validateDelete(recordId: string): Promise { + const logContext = { ...this.logContext, function: this.validateDelete.name }; + const records = await this.catalog.findRecords({ id: recordId }); + this.logger.debug({ msg: 'delete validation', logContext, recordId, matches: records.length }); + + const [record] = records; + if (records.length !== 1 || record === undefined) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_RECORD_NOT_FOUND, true); + } + + if (record.productType === BLOCKED_DELETE_PRODUCT_TYPE) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_PRODUCT_TYPE, true); + } + + if (record.productStatus !== RecordStatus.UNPUBLISHED) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_STATUS, true); + } + + return record; + } + + public async validateUpdate(identifier: string, payload: UpdatePayload): Promise { + const logContext = { ...this.logContext, function: this.validateUpdate.name }; + const record = await this.catalog.getRecord(identifier); + if (record === undefined) { + throw new AppError('notFound', StatusCodes.NOT_FOUND, ERROR_RECORD_NOT_FOUND, true); + } + if (record.productStatus === RecordStatus.BEING_DELETED) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_RECORD_BEING_DELETED, true); + } + + const parsed = new3DLayerMetadataSchema.partial().safeParse(payload); + if (!parsed.success) { + const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } + + const footprint = (payload as { footprint?: unknown }).footprint; + if (footprint !== undefined && !geometrySchema.safeParse(footprint).success) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); + } + + const data = parsed.data; + if (data.classification !== undefined) { + await validateClassification(this.lookupTables, this.logger, logContext, data.classification); + } + if (data.productName !== undefined) { + await this.validateProductNameUniqueExcept(data.productName, identifier); + } + + return record; + } + + public async validateStatusChange(identifier: string): Promise { + const record = await this.catalog.getRecord(identifier); + if (record === undefined) { + throw new AppError('notFound', StatusCodes.NOT_FOUND, ERROR_RECORD_NOT_FOUND, true); + } + if (record.productStatus === RecordStatus.BEING_DELETED) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_RECORD_BEING_DELETED, true); + } + + return record; + } + + public async ensureRecordAbsentFromExtractable(record: Record3D): Promise { + const logContext = { ...this.logContext, function: this.ensureRecordAbsentFromExtractable.name }; + if (record.productName === undefined) { + return; + } + + const exists = await this.extractable.isExtractableRecordExists(record.productName); + this.logger.debug({ msg: 'extractable existence validation', logContext, productName: record.productName, exists }); + if (exists) { + throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_EXTRACTABLE_CONFLICT, true); + } + } + + private async validateProductNameUniqueExcept(productName: string, identifier: string): Promise { + const records = await this.catalog.findRecords({ productName }); + if (records.some((existing) => existing.id !== identifier)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); + } + } +} diff --git a/src/validator/tilesetValidator.ts b/src/validator/tilesetValidator.ts new file mode 100644 index 0000000..fed1fad --- /dev/null +++ b/src/validator/tilesetValidator.ts @@ -0,0 +1,98 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'tsyringe'; +import { calculatePolygonFromTileset, type TileSetJson } from '@map-colonies/3d-shared'; +import { area, feature, featureCollection, intersect, union } from '@turf/turf'; +import type { Feature, MultiPolygon, Polygon } from 'geojson'; +import { SERVICES } from '@common/constants'; +import type { IngestionPayload, LogContext, ValidationResponse } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import type { ConfigType } from '@common/config'; +import { TilesetReader } from '../tileset/tilesetReader'; +import { ERROR_FOOTPRINT_FAR_FROM_MODEL, ERROR_INTERSECTION_FAILED, ERROR_TILESET_INVALID } from './errors'; + +const FULL_COVERAGE_PERCENT = 100; + +@injectable() +export class TilesetValidator { + private readonly logContext: LogContext; + private readonly percentageLimit: number; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(TilesetReader) private readonly tilesetReader: TilesetReader + ) { + this.percentageLimit = this.config.get('validation.percentageLimit'); + this.logContext = { + fileName: __filename, + class: TilesetValidator.name, + }; + } + + public async validateTileset(payload: IngestionPayload, footprint: Polygon | MultiPolygon): Promise { + const content = await this.tilesetReader.readTilesetJson(payload.modelPath, payload.tilesetFilename); + + const { modelPolygon, response: polygonResponse } = this.getTilesetModelPolygon(content); + if (!polygonResponse.isValid || modelPolygon === undefined) { + this.rejectValidation(polygonResponse); + } + + const intersectionResponse = this.isFootprintAndModelIntersects(footprint, modelPolygon); + if (!intersectionResponse.isValid) { + this.rejectValidation(intersectionResponse); + } + } + + private getTilesetModelPolygon(content: string): { modelPolygon?: Polygon; response: ValidationResponse } { + const logContext = { ...this.logContext, function: this.getTilesetModelPolygon.name }; + try { + const tilesetJson = JSON.parse(content) as TileSetJson; + const modelPolygon = calculatePolygonFromTileset(tilesetJson); + this.logger.debug({ msg: 'extracted tileset model polygon', logContext, modelPolygon }); + + return { modelPolygon, response: { isValid: true } }; + } catch (err) { + const message = err instanceof Error ? err.message : ERROR_TILESET_INVALID; + this.logger.error({ msg: 'tileset polygon extraction failed', logContext, err }); + return { response: { isValid: false, message } }; + } + } + + private isFootprintAndModelIntersects(footprint: Polygon | MultiPolygon, modelPolygon: Polygon): ValidationResponse { + const logContext = { ...this.logContext, function: this.isFootprintAndModelIntersects.name }; + try { + const footprintFeature = feature(footprint); + const modelFeature: Feature = feature(modelPolygon); + + const intersection = intersect(featureCollection([footprintFeature, modelFeature])); + if (intersection === null) { + return { isValid: false, message: ERROR_FOOTPRINT_FAR_FROM_MODEL }; + } + + const combined = union(featureCollection([footprintFeature, modelFeature])); + const combinedArea = combined === null ? 0 : area(combined); + const coverage = combinedArea === 0 ? 0 : (FULL_COVERAGE_PERCENT * area(footprintFeature)) / combinedArea; + this.logger.debug({ msg: 'calculated footprint coverage of the model', logContext, coverage, percentageLimit: this.percentageLimit }); + + if (coverage < this.percentageLimit) { + return { + isValid: false, + message: `The footprint intersectection with the model doesn't reach minimum required threshhold, the coverage is: ${coverage}% when the minimum coverage is ${this.percentageLimit}%`, + }; + } + return { isValid: true }; + } catch (err) { + this.logger.error({ msg: ERROR_INTERSECTION_FAILED, logContext, err }); + return { isValid: false, message: ERROR_INTERSECTION_FAILED }; + } + } + + private rejectValidation(response: ValidationResponse): never { + const logContext = { ...this.logContext, function: this.rejectValidation.name }; + const message = response.message ?? ERROR_TILESET_INVALID; + this.logger.warn({ msg: 'ingestion validation failed', logContext, isValid: response.isValid, message }); + + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } +} diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts deleted file mode 100644 index 94e4445..0000000 --- a/src/validator/validationManager.ts +++ /dev/null @@ -1,327 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { StatusCodes } from 'http-status-codes'; -import { inject, injectable } from 'tsyringe'; -import { new3DLayerMetadataSchema, geometrySchema, calculatePolygonFromTileset, type TileSetJson } from '@map-colonies/3d-shared'; -import { area, feature, featureCollection, intersect, union } from '@turf/turf'; -import type { Feature, MultiPolygon, Polygon } from 'geojson'; -import type { components } from '@openapi'; -import { SERVICES } from '@common/constants'; -import type { LogContext, ValidationResponse } from '@common/interfaces'; -import { AppError } from '@common/appError'; -import { buildModelFilePath } from '@common/util'; -import type { ConfigType } from '@common/config'; -import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; -import { CatalogCall } from '../externalServices/catalog/catalogCall'; -import { JobnikClient } from '../externalServices/jobnik/jobnikClient'; -import { ExtractableCall } from '../externalServices/extractableManagement/extractableCall'; -import { TilesetReader } from '../tileset/tilesetReader'; -import type { Record3D } from '../externalServices/catalog/interfaces'; -import type { Provider } from '../providers/interfaces'; -import type { IngestionPayload } from '../records/models/recordManager'; - -type UpdatePayload = components['schemas']['updatePayload']; - -const FULL_COVERAGE_PERCENT = 100; - -const BLOCKED_DELETE_PRODUCT_TYPE = 'QuantizedMeshDTMBest'; -const RECORD_STATUS_UNPUBLISHED = 'UNPUBLISHED'; -const RECORD_STATUS_BEING_DELETED = 'BEING_DELETED'; - -export const ERROR_METADATA_DATE = 'imagingTimeBeginUTC must not be later than imagingTimeEndUTC'; -export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC are required'; -export const ERROR_METADATA_INVALID_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC must be valid dates'; -export const ERROR_METADATA_FOOTPRINT = 'Invalid footprint! Must be a GeoJSON Polygon or MultiPolygon with all-2D or all-3D coordinates'; -export const ERROR_METADATA_PRODUCT_NAME_UNIQUE = 'product name is not unique!'; -export const ERROR_PRODUCT_NAME_IN_FLIGHT = 'product name already has an in-flight ingestion job'; -export const ERROR_DELETE_RECORD_NOT_FOUND = "recordId doesn't match exactly one existing record"; -export const ERROR_DELETE_PRODUCT_TYPE = 'Cannot delete a record whose productType is "QuantizedMeshDTMBest"'; -export const ERROR_DELETE_STATUS = 'Cannot delete a record whose productStatus is not "UNPUBLISHED"'; -export const ERROR_FILE_NOT_FOUND = 'The model files do not exist in the agreed storage'; -export const ERROR_TILESET_INVALID = 'The tileset file is not a valid 3DTiles tileset'; -export const ERROR_FOOTPRINT_FAR_FROM_MODEL = "Wrong footprint! footprint's coordinates is not even close to the model!"; -export const ERROR_INTERSECTION_FAILED = 'An error caused during the validation of the intersection'; -export const ERROR_RECORD_NOT_FOUND = "record with the given identifier doesn't exist"; -export const ERROR_RECORD_BEING_DELETED = 'cannot change a record that is being deleted'; -export const ERROR_EXTRACTABLE_CONFLICT = 'the record exists in the extractable-management service and cannot be changed here'; -export const ERROR_MODEL_PATH_INVALID = 'Unknown model path! The model is not under the agreed base path'; -export const ERROR_PRODUCT_ID_EXISTS = 'a record with this productId already exists in the catalog'; - -@injectable() -export class ValidationManager { - private readonly logContext: LogContext; - private readonly percentageLimit: number; - private readonly basePath: string; - - public constructor( - @inject(SERVICES.CONFIG) private readonly config: ConfigType, - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, - @inject(CatalogCall) private readonly catalog: CatalogCall, - @inject(JobnikClient) private readonly jobnik: JobnikClient, - @inject(TilesetReader) private readonly tilesetReader: TilesetReader, - @inject(ExtractableCall) private readonly extractable: ExtractableCall, - @inject(SERVICES.PROVIDER) private readonly provider: Provider - ) { - this.percentageLimit = this.config.get('validation.percentageLimit'); - this.basePath = this.config.get('validation.basePath'); - this.logContext = { - fileName: __filename, - class: ValidationManager.name, - }; - } - - public async validateIngestion(payload: IngestionPayload): Promise { - const logContext = { ...this.logContext, function: this.validateIngestion.name }; - this.logger.info({ msg: 'ingestion validation start', logContext }); - - const metadata = payload.metadata; - const parsed = new3DLayerMetadataSchema.safeParse(metadata); - if (!parsed.success) { - const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); - } - - const footprintResult = geometrySchema.safeParse(metadata.footprint); - if (!footprintResult.success) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); - } - - this.validateDates(metadata.imagingTimeBeginUTC, metadata.imagingTimeEndUTC); - this.validateModelPath(payload.modelPath); - - await this.validateClassification(parsed.data.classification); - await this.validateProductNameUnique(parsed.data.productName); - await this.validateProductNameNotInFlight(parsed.data.productName); - await this.validateProductIdUnique(parsed.data.productId); - await this.validateFileExists(payload.modelPath, payload.tilesetFilename); - await this.validateTileset(payload, footprintResult.data); - } - - public async validateDelete(recordId: string): Promise { - const logContext = { ...this.logContext, function: this.validateDelete.name }; - const records = await this.catalog.findRecords({ id: recordId }); - this.logger.debug({ msg: 'delete validation', logContext, recordId, matches: records.length }); - - const [record] = records; - if (records.length !== 1 || record === undefined) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_RECORD_NOT_FOUND, true); - } - - if (record.productType === BLOCKED_DELETE_PRODUCT_TYPE) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_PRODUCT_TYPE, true); - } - - if (record.productStatus !== RECORD_STATUS_UNPUBLISHED) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_DELETE_STATUS, true); - } - - return record; - } - - public async validateUpdate(identifier: string, payload: UpdatePayload): Promise { - const record = await this.catalog.getRecord(identifier); - if (record === undefined) { - throw new AppError('notFound', StatusCodes.NOT_FOUND, ERROR_RECORD_NOT_FOUND, true); - } - if (record.productStatus === RECORD_STATUS_BEING_DELETED) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_RECORD_BEING_DELETED, true); - } - - const parsed = new3DLayerMetadataSchema.partial().safeParse(payload); - if (!parsed.success) { - const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); - } - - const footprint = (payload as { footprint?: unknown }).footprint; - if (footprint !== undefined && !geometrySchema.safeParse(footprint).success) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); - } - - const data = parsed.data; - if (data.classification !== undefined) { - await this.validateClassification(data.classification); - } - if (data.productName !== undefined) { - await this.validateProductNameUniqueExcept(data.productName, identifier); - } - - return record; - } - - public async validateStatusChange(identifier: string): Promise { - const record = await this.catalog.getRecord(identifier); - if (record === undefined) { - throw new AppError('notFound', StatusCodes.NOT_FOUND, ERROR_RECORD_NOT_FOUND, true); - } - if (record.productStatus === RECORD_STATUS_BEING_DELETED) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_RECORD_BEING_DELETED, true); - } - - return record; - } - - public async ensureRecordAbsentFromExtractable(record: Record3D): Promise { - const logContext = { ...this.logContext, function: this.ensureRecordAbsentFromExtractable.name }; - if (record.productName === undefined) { - return; - } - - const exists = await this.extractable.isExtractableRecordExists(record.productName); - this.logger.debug({ msg: 'extractable existence validation', logContext, productName: record.productName, exists }); - if (exists) { - throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_EXTRACTABLE_CONFLICT, true); - } - } - - private async validateProductNameUniqueExcept(productName: string, identifier: string): Promise { - const records = await this.catalog.findRecords({ productName }); - if (records.some((existing) => existing.id !== identifier)) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); - } - } - - private validateModelPath(modelPath: string): void { - if (!modelPath.startsWith(this.basePath)) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, `${ERROR_MODEL_PATH_INVALID} (basePath: ${this.basePath})`, true); - } - } - - private async validateProductIdUnique(productId: string): Promise { - const logContext = { ...this.logContext, function: this.validateProductIdUnique.name }; - const records = await this.catalog.findRecords({ productId }); - this.logger.debug({ msg: 'product id uniqueness validation', logContext, productId, matches: records.length }); - - if (records.length > 0) { - throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_PRODUCT_ID_EXISTS, true); - } - } - - private async validateFileExists(modelPath: string, tilesetFilename: string): Promise { - const logContext = { ...this.logContext, function: this.validateFileExists.name }; - const path = buildModelFilePath(modelPath, tilesetFilename); - const exists = await this.provider.fileExists(path); - this.logger.debug({ msg: 'file existence validation', logContext, path, exists }); - - if (!exists) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_FILE_NOT_FOUND, true); - } - } - - private async validateProductNameUnique(productName: string): Promise { - const logContext = { ...this.logContext, function: this.validateProductNameUnique.name }; - const records = await this.catalog.findRecords({ productName }); - this.logger.debug({ msg: 'product name uniqueness validation', logContext, productName, matches: records.length }); - - if (records.length > 0) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); - } - } - - private async validateProductNameNotInFlight(productName: string): Promise { - const logContext = { ...this.logContext, function: this.validateProductNameNotInFlight.name }; - const inFlight = await this.jobnik.hasInFlightIngestionJob(productName); - this.logger.debug({ msg: 'in-flight duplicate validation', logContext, productName, inFlight }); - - if (inFlight) { - throw new AppError('conflict', StatusCodes.CONFLICT, ERROR_PRODUCT_NAME_IN_FLIGHT, true); - } - } - - private async validateTileset(payload: IngestionPayload, footprint: Polygon | MultiPolygon): Promise { - const content = await this.tilesetReader.readTilesetJson(payload.modelPath, payload.tilesetFilename); - - const { modelPolygon, response: polygonResponse } = this.getTilesetModelPolygon(content); - if (!polygonResponse.isValid || modelPolygon === undefined) { - this.rejectValidation(polygonResponse); - } - - const intersectionResponse = this.isFootprintAndModelIntersects(footprint, modelPolygon); - if (!intersectionResponse.isValid) { - this.rejectValidation(intersectionResponse); - } - } - - private getTilesetModelPolygon(content: string): { modelPolygon?: Polygon; response: ValidationResponse } { - const logContext = { ...this.logContext, function: this.getTilesetModelPolygon.name }; - try { - const tilesetJson = JSON.parse(content) as TileSetJson; - const modelPolygon = calculatePolygonFromTileset(tilesetJson); - this.logger.debug({ msg: 'extracted tileset model polygon', logContext, modelPolygon }); - - return { modelPolygon, response: { isValid: true } }; - } catch (err) { - const message = err instanceof Error ? err.message : ERROR_TILESET_INVALID; - this.logger.error({ msg: 'tileset polygon extraction failed', logContext, err }); - return { response: { isValid: false, message } }; - } - } - - private isFootprintAndModelIntersects(footprint: Polygon | MultiPolygon, modelPolygon: Polygon): ValidationResponse { - const logContext = { ...this.logContext, function: this.isFootprintAndModelIntersects.name }; - try { - const footprintFeature = feature(footprint); - const modelFeature: Feature = feature(modelPolygon); - - const intersection = intersect(featureCollection([footprintFeature, modelFeature])); - if (intersection === null) { - return { isValid: false, message: ERROR_FOOTPRINT_FAR_FROM_MODEL }; - } - - const combined = union(featureCollection([footprintFeature, modelFeature])); - const combinedArea = combined === null ? 0 : area(combined); - const coverage = combinedArea === 0 ? 0 : (FULL_COVERAGE_PERCENT * area(footprintFeature)) / combinedArea; - this.logger.debug({ msg: 'calculated footprint coverage of the model', logContext, coverage, percentageLimit: this.percentageLimit }); - - if (coverage < this.percentageLimit) { - return { - isValid: false, - message: `The footprint intersectection with the model doesn't reach minimum required threshhold, the coverage is: ${coverage}% when the minimum coverage is ${this.percentageLimit}%`, - }; - } - return { isValid: true }; - } catch (err) { - this.logger.error({ msg: ERROR_INTERSECTION_FAILED, logContext, err }); - return { isValid: false, message: ERROR_INTERSECTION_FAILED }; - } - } - - private rejectValidation(response: ValidationResponse): never { - const logContext = { ...this.logContext, function: this.rejectValidation.name }; - const message = response.message ?? ERROR_TILESET_INVALID; - this.logger.warn({ msg: 'ingestion validation failed', logContext, isValid: response.isValid, message }); - - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); - } - - private validateDates(start: unknown, end: unknown): void { - if (start === undefined || end === undefined) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_MISSING_DATE, true); - } - - const startTime = new Date(start as string).getTime(); - const endTime = new Date(end as string).getTime(); - if (Number.isNaN(startTime) || Number.isNaN(endTime)) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_INVALID_DATE, true); - } - - if (startTime > endTime) { - throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_DATE, true); - } - } - - private async validateClassification(classification: string): Promise { - const logContext = { ...this.logContext, function: this.validateClassification.name }; - const classifications = await this.lookupTables.getClassifications(); - this.logger.debug({ msg: 'classification validation', logContext, classifications }); - - if (!classifications.includes(classification)) { - throw new AppError( - 'badRequest', - StatusCodes.BAD_REQUEST, - `classification is not a valid value. Optional values: ${classifications.join()}`, - true - ); - } - } -} diff --git a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts index 95378a3..ccb0fed 100644 --- a/tests/unit/externalServices/jobnik/jobnikClient.spec.ts +++ b/tests/unit/externalServices/jobnik/jobnikClient.spec.ts @@ -4,7 +4,7 @@ import { Registry } from 'prom-client'; import { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import { STAGE_TYPES } from '@src/common/constants'; import type { ConfigType } from '@src/common/config'; -import type { IngestionPayload } from '@src/records/models/recordManager'; +import type { IngestionPayload } from '@src/common/interfaces'; const jobManagerConfig = { url: 'http://job-manager', diff --git a/tests/unit/records/models/recordManager.spec.ts b/tests/unit/records/models/recordManager.spec.ts index ef7f88c..e2dc26b 100644 --- a/tests/unit/records/models/recordManager.spec.ts +++ b/tests/unit/records/models/recordManager.spec.ts @@ -1,17 +1,21 @@ import { jsLogger } from '@map-colonies/js-logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { RecordManager, type IngestionPayload } from '@src/records/models/recordManager'; -import type { ValidationManager } from '@src/validator/validationManager'; +import { RecordManager } from '@src/records/models/recordManager'; +import type { IngestionPayload } from '@src/common/interfaces'; +import type { IngestionValidator } from '@src/validator/ingestionValidator'; +import type { MetadataValidator } from '@src/validator/metadataValidator'; import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; -const noopValidator = { +const ingestionValidatorStub = { validateIngestion: vi.fn().mockResolvedValue(undefined), +} as unknown as IngestionValidator; +const metadataValidatorStub = { validateDelete: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), validateUpdate: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), validateStatusChange: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), ensureRecordAbsentFromExtractable: vi.fn().mockResolvedValue(undefined), -} as unknown as ValidationManager; +} as unknown as MetadataValidator; const jobnikStub = { createIngestionJob: vi.fn().mockResolvedValue({ jobId: 'job-1', status: 'PENDING' }), createDeleteJob: vi.fn().mockResolvedValue({ jobId: 'del-1', status: 'PENDING' }), @@ -27,7 +31,7 @@ describe('RecordManager', function () { let manager: RecordManager; beforeEach(async function () { - manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator, jobnikStub, catalogStub); + manager = new RecordManager(await jsLogger({ enabled: false }), ingestionValidatorStub, metadataValidatorStub, jobnikStub, catalogStub); }); describe('createIngestion', function () { diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/ingestionValidator.spec.ts similarity index 62% rename from tests/unit/validator/validationManager.spec.ts rename to tests/unit/validator/ingestionValidator.spec.ts index f088ca6..2c4da59 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/ingestionValidator.spec.ts @@ -3,32 +3,26 @@ import { join } from 'node:path'; import { jsLogger } from '@map-colonies/js-logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { StatusCodes } from 'http-status-codes'; +import { IngestionValidator } from '@src/validator/ingestionValidator'; +import { TilesetValidator } from '@src/validator/tilesetValidator'; import { - ValidationManager, ERROR_METADATA_DATE, ERROR_METADATA_FOOTPRINT, ERROR_METADATA_MISSING_DATE, ERROR_METADATA_INVALID_DATE, - ERROR_DELETE_RECORD_NOT_FOUND, - ERROR_DELETE_PRODUCT_TYPE, - ERROR_DELETE_STATUS, ERROR_FILE_NOT_FOUND, ERROR_PRODUCT_NAME_IN_FLIGHT, ERROR_FOOTPRINT_FAR_FROM_MODEL, - ERROR_RECORD_NOT_FOUND, - ERROR_RECORD_BEING_DELETED, - ERROR_EXTRACTABLE_CONFLICT, ERROR_MODEL_PATH_INVALID, -} from '@src/validator/validationManager'; +} from '@src/validator/errors'; import { AppError } from '@src/common/appError'; import type { ConfigType } from '@src/common/config'; import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; import type { JobnikClient } from '@src/externalServices/jobnik/jobnikClient'; import type { TilesetReader } from '@src/tileset/tilesetReader'; -import type { ExtractableCall } from '@src/externalServices/extractableManagement/extractableCall'; import type { Provider } from '@src/providers/interfaces'; -import type { IngestionPayload } from '@src/records/models/recordManager'; +import type { IngestionPayload } from '@src/common/interfaces'; import { buildValidMetadata } from '@tests/helpers/metadata'; const regionTilesetJson = readFileSync(join(__dirname, '../../helpers/tilesets/folder/tileset.json'), 'utf-8'); @@ -45,33 +39,23 @@ const ingest = (metadata: Record): IngestionPayload => ({ metadata, }); -describe('ValidationManager', function () { - let validator: ValidationManager; +describe('IngestionValidator', function () { + let validator: IngestionValidator; let catalogStub: CatalogCall; let jobnikStub: JobnikClient; let tilesetReaderStub: TilesetReader; - let extractableStub: ExtractableCall; let providerStub: Provider; beforeEach(async function () { catalogStub = { findRecords: vi.fn().mockResolvedValue([]), - getRecord: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), } as unknown as CatalogCall; jobnikStub = { hasInFlightIngestionJob: vi.fn().mockResolvedValue(false) } as unknown as JobnikClient; tilesetReaderStub = { readTilesetJson: vi.fn().mockResolvedValue(regionTilesetJson) } as unknown as TilesetReader; - extractableStub = { isExtractableRecordExists: vi.fn().mockResolvedValue(false) } as unknown as ExtractableCall; providerStub = { fileExists: vi.fn().mockResolvedValue(true) }; - validator = new ValidationManager( - configStub, - await jsLogger({ enabled: false }), - lookupStub, - catalogStub, - jobnikStub, - tilesetReaderStub, - extractableStub, - providerStub - ); + const logger = await jsLogger({ enabled: false }); + const tilesetValidator = new TilesetValidator(configStub, logger, tilesetReaderStub); + validator = new IngestionValidator(configStub, logger, lookupStub, catalogStub, jobnikStub, providerStub, tilesetValidator); }); it('should pass a fully valid ingestion payload', async function () { @@ -237,97 +221,4 @@ describe('ValidationManager', function () { expect(thrown).toBeInstanceOf(AppError); expect((thrown as AppError).status).toBe(StatusCodes.BAD_REQUEST); }); - - describe('validateUpdate', function () { - it('should return the record for a valid update payload', async function () { - await expect(validator.validateUpdate('rec-1', { description: 'x' })).resolves.toEqual({ id: 'rec-1', productName: 'afula' }); - }); - - it('should throw 404 when the record does not exist', async function () { - (catalogStub.getRecord as ReturnType).mockResolvedValueOnce(undefined); - - let thrown: unknown; - try { - await validator.validateUpdate('missing', { description: 'x' }); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(AppError); - expect((thrown as AppError).status).toBe(StatusCodes.NOT_FOUND); - expect((thrown as AppError).message).toBe(ERROR_RECORD_NOT_FOUND); - }); - - it('should throw 400 when the record is being deleted', async function () { - (catalogStub.getRecord as ReturnType).mockResolvedValueOnce({ id: 'rec-1', productStatus: 'BEING_DELETED' }); - - await expect(validator.validateUpdate('rec-1', { description: 'x' })).rejects.toThrow(ERROR_RECORD_BEING_DELETED); - }); - - it('should throw 400 when a renamed product name collides with another record', async function () { - (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([{ id: 'other', productName: 'haifa' }]); - - await expect(validator.validateUpdate('rec-1', { productName: 'haifa' })).rejects.toThrow(AppError); - }); - }); - - describe('validateStatusChange', function () { - it('should throw 404 when the record does not exist', async function () { - (catalogStub.getRecord as ReturnType).mockResolvedValueOnce(undefined); - - await expect(validator.validateStatusChange('missing')).rejects.toThrow(ERROR_RECORD_NOT_FOUND); - }); - }); - - describe('ensureRecordAbsentFromExtractable', function () { - it('should throw 409 when the record exists in extractable-management', async function () { - (extractableStub.isExtractableRecordExists as ReturnType).mockResolvedValueOnce(true); - - let thrown: unknown; - try { - await validator.ensureRecordAbsentFromExtractable({ id: 'rec-1', productName: 'afula' }); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(AppError); - expect((thrown as AppError).status).toBe(StatusCodes.CONFLICT); - expect((thrown as AppError).message).toBe(ERROR_EXTRACTABLE_CONFLICT); - }); - - it('should pass when the record is absent from extractable-management', async function () { - await expect(validator.ensureRecordAbsentFromExtractable({ id: 'rec-1', productName: 'afula' })).resolves.toBeUndefined(); - }); - }); - - describe('validateDelete', function () { - it('should return the record when it is deletable', async function () { - const record = { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'UNPUBLISHED' }; - (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([record]); - - await expect(validator.validateDelete('rec-1')).resolves.toEqual(record); - }); - - it('should throw when the record is not found', async function () { - (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([]); - - await expect(validator.validateDelete('missing')).rejects.toThrow(ERROR_DELETE_RECORD_NOT_FOUND); - }); - - it('should throw when the productType is blocked', async function () { - (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([ - { id: 'rec-1', productType: 'QuantizedMeshDTMBest', productStatus: 'UNPUBLISHED' }, - ]); - - await expect(validator.validateDelete('rec-1')).rejects.toThrow(ERROR_DELETE_PRODUCT_TYPE); - }); - - it('should throw when the record is not unpublished', async function () { - (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([ - { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'PUBLISHED' }, - ]); - - await expect(validator.validateDelete('rec-1')).rejects.toThrow(ERROR_DELETE_STATUS); - }); - }); }); diff --git a/tests/unit/validator/metadataValidator.spec.ts b/tests/unit/validator/metadataValidator.spec.ts new file mode 100644 index 0000000..27d441f --- /dev/null +++ b/tests/unit/validator/metadataValidator.spec.ts @@ -0,0 +1,126 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StatusCodes } from 'http-status-codes'; +import { MetadataValidator } from '@src/validator/metadataValidator'; +import { + ERROR_DELETE_RECORD_NOT_FOUND, + ERROR_DELETE_PRODUCT_TYPE, + ERROR_DELETE_STATUS, + ERROR_RECORD_NOT_FOUND, + ERROR_RECORD_BEING_DELETED, + ERROR_EXTRACTABLE_CONFLICT, +} from '@src/validator/errors'; +import { AppError } from '@src/common/appError'; +import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import type { ExtractableCall } from '@src/externalServices/extractableManagement/extractableCall'; + +const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; + +describe('MetadataValidator', function () { + let validator: MetadataValidator; + let catalogStub: CatalogCall; + let extractableStub: ExtractableCall; + + beforeEach(async function () { + catalogStub = { + findRecords: vi.fn().mockResolvedValue([]), + getRecord: vi.fn().mockResolvedValue({ id: 'rec-1', productName: 'afula' }), + } as unknown as CatalogCall; + extractableStub = { isExtractableRecordExists: vi.fn().mockResolvedValue(false) } as unknown as ExtractableCall; + validator = new MetadataValidator(await jsLogger({ enabled: false }), lookupStub, catalogStub, extractableStub); + }); + + describe('validateUpdate', function () { + it('should return the record for a valid update payload', async function () { + await expect(validator.validateUpdate('rec-1', { description: 'x' })).resolves.toEqual({ id: 'rec-1', productName: 'afula' }); + }); + + it('should throw 404 when the record does not exist', async function () { + (catalogStub.getRecord as ReturnType).mockResolvedValueOnce(undefined); + + let thrown: unknown; + try { + await validator.validateUpdate('missing', { description: 'x' }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.NOT_FOUND); + expect((thrown as AppError).message).toBe(ERROR_RECORD_NOT_FOUND); + }); + + it('should throw 400 when the record is being deleted', async function () { + (catalogStub.getRecord as ReturnType).mockResolvedValueOnce({ id: 'rec-1', productStatus: 'BEING_DELETED' }); + + await expect(validator.validateUpdate('rec-1', { description: 'x' })).rejects.toThrow(ERROR_RECORD_BEING_DELETED); + }); + + it('should throw 400 when a renamed product name collides with another record', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([{ id: 'other', productName: 'haifa' }]); + + await expect(validator.validateUpdate('rec-1', { productName: 'haifa' })).rejects.toThrow(AppError); + }); + }); + + describe('validateStatusChange', function () { + it('should throw 404 when the record does not exist', async function () { + (catalogStub.getRecord as ReturnType).mockResolvedValueOnce(undefined); + + await expect(validator.validateStatusChange('missing')).rejects.toThrow(ERROR_RECORD_NOT_FOUND); + }); + }); + + describe('ensureRecordAbsentFromExtractable', function () { + it('should throw 409 when the record exists in extractable-management', async function () { + (extractableStub.isExtractableRecordExists as ReturnType).mockResolvedValueOnce(true); + + let thrown: unknown; + try { + await validator.ensureRecordAbsentFromExtractable({ id: 'rec-1', productName: 'afula' }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.CONFLICT); + expect((thrown as AppError).message).toBe(ERROR_EXTRACTABLE_CONFLICT); + }); + + it('should pass when the record is absent from extractable-management', async function () { + await expect(validator.ensureRecordAbsentFromExtractable({ id: 'rec-1', productName: 'afula' })).resolves.toBeUndefined(); + }); + }); + + describe('validateDelete', function () { + it('should return the record when it is deletable', async function () { + const record = { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'UNPUBLISHED' }; + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([record]); + + await expect(validator.validateDelete('rec-1')).resolves.toEqual(record); + }); + + it('should throw when the record is not found', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([]); + + await expect(validator.validateDelete('missing')).rejects.toThrow(ERROR_DELETE_RECORD_NOT_FOUND); + }); + + it('should throw when the productType is blocked', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([ + { id: 'rec-1', productType: 'QuantizedMeshDTMBest', productStatus: 'UNPUBLISHED' }, + ]); + + await expect(validator.validateDelete('rec-1')).rejects.toThrow(ERROR_DELETE_PRODUCT_TYPE); + }); + + it('should throw when the record is not unpublished', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([ + { id: 'rec-1', productType: '3DPhotoRealistic', productStatus: 'PUBLISHED' }, + ]); + + await expect(validator.validateDelete('rec-1')).rejects.toThrow(ERROR_DELETE_STATUS); + }); + }); +});