From 362aa1ad059b85543786d26995ff2f0ec7724d4a Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:01:09 +0200 Subject: [PATCH 01/52] fix: helm sync with boilerplate --- helm/templates/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 276c464..9d60706 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -73,7 +73,7 @@ spec: - name: REQUESTS_CA_BUNDLE value: {{ printf "%s/%s" .Values.caPath .Values.caKey | quote }} - name: NODE_EXTRA_CA_CERTS - value: {{ printf "[%s/%s]" .Values.caPath .Values.caKey | quote }} + value: {{ printf "%s/%s" .Values.caPath .Values.caKey | quote }} {{- end }} {{- if .Values.extraEnvVars }} {{- toYaml .Values.extraEnvVars | nindent 12 }} From d7fa93ed1dc51082d0ad77851455bc4a2ff1cd17 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:04:13 +0200 Subject: [PATCH 02/52] build: add dependencies --- package-lock.json | 1766 +++++++++++++++++++++++++++++++++------------ package.json | 17 +- 2 files changed, 1320 insertions(+), 463 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6224714..4aee7a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@godaddy/terminus": "^4.12.1", "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", + "@map-colonies/error-types": "^1.3.1", "@map-colonies/express-access-log-middleware": "^4.0.0", "@map-colonies/js-logger": "^4.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", @@ -24,33 +25,44 @@ "compression": "^1.8.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", + "gdal-async": "^3.12.2", "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": "^4.3.6" }, "devDependencies": { "@commitlint/cli": "^20.4.1", + "@faker-js/faker": "^10.1.0", "@map-colonies/commitlint-config": "^2.0.0", "@map-colonies/eslint-config": "^7.2.0", "@map-colonies/openapi-helpers": "^5.1.0", "@map-colonies/prettier-config": "^1.0.0", "@map-colonies/tsconfig": "^2.0.0", + "@readme/openapi-parser": "^5.5.0", "@redocly/cli": "^2.16.0", "@types/compression": "^1.7.5", "@types/express": "^4.17.21", + "@types/lodash": "^4.17.23", "@types/multer": "^1.4.12", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.8", "@vitest/coverage-v8": "^4.0.18", "@vitest/eslint-plugin": "^1.6.9", "@vitest/ui": "^4.0.18", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "copyfiles": "^2.4.1", "cross-env": "^10.1.0", + "epsg-index": "^2.0.0", "eslint": "^9.39.2", "eslint-plugin-jest": "^28.11.0", "husky": "^9.1.7", + "jest-extended": "^7.0.0", "jest-openapi": "^0.14.2", + "lodash": "^4.17.23", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.1", "pretty-quick": "^4.2.2", "rimraf": "^6.1.2", @@ -58,7 +70,8 @@ "ts-jest": "^29.2.6", "tsc-alias": "^1.8.11", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "zod-schema-faker": "^2.1.0" } }, "node_modules/@ampproject/remapping": { @@ -639,30 +652,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/config-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@commitlint/ensure": { "version": "20.4.1", "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.1.tgz", @@ -1550,6 +1539,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -1563,6 +1569,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", @@ -1608,14 +1621,32 @@ "license": "MIT" }, "node_modules/@faker-js/faker": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", - "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], "license": "MIT", "engines": { - "node": ">=14.0.0", - "npm": ">=6.0.0" + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", + "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "license": "MIT", + "dependencies": { + "retry": "^0.13.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@godaddy/terminus": { @@ -1765,6 +1796,18 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1885,6 +1928,16 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -1942,6 +1995,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -2238,43 +2301,6 @@ "type-fest": "^2.3.2" } }, - "node_modules/@map-colonies/config/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@map-colonies/config/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@map-colonies/config/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/@map-colonies/config/node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -2307,6 +2333,26 @@ "node": ">=24" } }, + "node_modules/@map-colonies/error-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@map-colonies/error-types/-/error-types-1.3.1.tgz", + "integrity": "sha512-ZcXiCYcjk4SBhAxO6JGJZ9cmiCInBULpisrnTViPsdxtfk+1a6XG/sKXop5U5se6xQZ77L43ZEUhiwvE7FsaPA==", + "license": "ISC", + "dependencies": { + "@map-colonies/error-express-handler": "^2.0.0", + "express": "^4.17.1", + "http-status-codes": "^2.1.4" + } + }, + "node_modules/@map-colonies/error-types/node_modules/@map-colonies/error-express-handler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@map-colonies/error-express-handler/-/error-express-handler-2.1.0.tgz", + "integrity": "sha512-8qcyePq5JVrbEw7rioZ7nQfYavVw8OiFGwfAJolDmq045ppm82IEKBFMgzLC4p4dbRj+wDzwcuRkcv5yGE0IZA==", + "license": "ISC", + "dependencies": { + "http-status-codes": "^2.1.4" + } + }, "node_modules/@map-colonies/eslint-config": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@map-colonies/eslint-config/-/eslint-config-7.2.0.tgz", @@ -4687,40 +4733,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@map-colonies/tracing/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@map-colonies/tracing/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/@map-colonies/tracing/node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -4739,12 +4751,6 @@ "module-details-from-path": "^1.0.4" } }, - "node_modules/@map-colonies/tracing/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@map-colonies/tracing/node_modules/require-in-the-middle": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", @@ -4844,6 +4850,82 @@ "node": ">= 8" } }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@oozcitak/dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", + "license": "MIT", + "dependencies": { + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", + "license": "MIT", + "engines": { + "node": ">=20.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -5111,6 +5193,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -5269,24 +5357,6 @@ "@types/json-schema": "^7.0.15" } }, - "node_modules/@readme/openapi-parser/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@readme/openapi-parser/node_modules/ajv-draft-04": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", @@ -5302,13 +5372,6 @@ } } }, - "node_modules/@readme/openapi-parser/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@readme/openapi-schemas": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@readme/openapi-schemas/-/openapi-schemas-3.1.0.tgz", @@ -5354,12 +5417,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/@redocly/cli": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.16.0.tgz", @@ -5612,24 +5669,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@redocly/cli/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/@redocly/cli/node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -5655,13 +5694,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@redocly/cli/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@redocly/cli/node_modules/minimatch": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", @@ -5711,9 +5743,9 @@ } }, "node_modules/@redocly/config": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.12.1.tgz", - "integrity": "sha512-RW3rSirfsPdr0uvATijRDU3f55SuZV3m7/ppdTDvGw4IB0cmeZRkFmqTrchxMqWP50Gfg1tpHnjdxUCNo0E2qg==", + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", + "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", "dev": true, "license": "MIT" }, @@ -5785,31 +5817,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@redocly/openapi-core/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@redocly/openapi-core/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@redocly/openapi-core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -5848,6 +5855,17 @@ "npm": ">=10" } }, + "node_modules/@redocly/respect-core/node_modules/@faker-js/faker": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", + "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, "node_modules/@redocly/respect-core/node_modules/ajv": { "name": "@redocly/ajv", "version": "8.17.1", @@ -5873,13 +5891,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@redocly/respect-core/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@redocly/respect-core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -6479,9 +6490,10 @@ "peer": true }, "node_modules/@types/lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" }, "node_modules/@types/memcached": { "version": "2.2.10", @@ -7590,6 +7602,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -7666,16 +7687,16 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -7683,10 +7704,10 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, @@ -7699,28 +7720,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -7990,8 +7989,7 @@ "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==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/better-ajv-errors": { "version": "1.2.0", @@ -8079,7 +8077,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8178,6 +8175,81 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -8336,6 +8408,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -8558,8 +8639,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "2.0.0", @@ -9033,6 +9113,16 @@ "url": "https://dotenvx.com" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9113,12 +9203,21 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/epsg-index": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/epsg-index/-/epsg-index-2.0.0.tgz", + "integrity": "sha512-JFRqtXMmxEO/BWbXZuLNJuWS44vbwDPQkfycnISnfm/I8/OLlAqwbYRImgAV9ulr63p/hyW2YIFT9jizHeDzvQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=18" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -9488,6 +9587,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -9541,6 +9657,13 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9767,6 +9890,12 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -9866,23 +9995,6 @@ "@types/express": "*" } }, - "node_modules/express-openapi-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/express-openapi-validator/node_modules/ajv-draft-04": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", @@ -9896,28 +10008,6 @@ } } }, - "node_modules/express-openapi-validator/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/express-openapi-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/express-openapi-validator/node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -10320,11 +10410,22 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -10380,61 +10481,295 @@ "node": ">=14" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, - "license": "MIT", + "node_modules/gdal-async": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/gdal-async/-/gdal-async-3.12.2.tgz", + "integrity": "sha512-R4XWgWEpiOx1AoxJzQsV1qF/TSR1KyAw8sdrs4SNNeulydwoFqK2+azbFWqg4u1t4q7pcPROFs4NsYyI6qesKw==", + "bundleDependencies": [ + "@mapbox/node-pre-gyp" + ], + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0", + "@petamoriken/float16": "^3.9.2", + "nan": "^2.23.0", + "node-gyp": "^12.1.0", + "xmlbuilder2": "^4.0.0", + "yatag": "^1.2.0" + }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "individual", + "url": "https://github.com/sponsors/mmomtchev" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", + "node_modules/gdal-async/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "inBundle": true, + "license": "ISC", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "minipass": "^7.0.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18.0.0" } }, - "node_modules/get-package-type": { + "node_modules/gdal-async/node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.3", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gdal-async/node_modules/abbrev": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/gdal-async/node_modules/agent-base": { + "version": "7.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gdal-async/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/gdal-async/node_modules/consola": { + "version": "3.4.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/gdal-async/node_modules/debug": { + "version": "4.4.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gdal-async/node_modules/detect-libc": { + "version": "2.0.4", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/gdal-async/node_modules/https-proxy-agent": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gdal-async/node_modules/minipass": { + "version": "7.1.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/gdal-async/node_modules/minizlib": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/gdal-async/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/gdal-async/node_modules/node-fetch": { + "version": "2.7.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gdal-async/node_modules/nopt": { + "version": "8.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/gdal-async/node_modules/semver": { + "version": "7.7.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gdal-async/node_modules/tar": { + "version": "7.5.2", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gdal-async/node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/gdal-async/node_modules/tr46": { + "version": "0.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/gdal-async/node_modules/webidl-conversions": { + "version": "3.0.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/gdal-async/node_modules/whatwg-url": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", @@ -10516,7 +10851,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -10604,8 +10938,7 @@ "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "node_modules/handlebars": { "version": "4.7.7", @@ -10690,6 +11023,12 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -10706,6 +11045,19 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -10837,15 +11189,14 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, "engines": { "node": ">=0.8.19" } }, "node_modules/index-to-position": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", - "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, "license": "MIT", "engines": { @@ -10859,7 +11210,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -10880,6 +11230,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11424,6 +11783,102 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-7.0.0.tgz", + "integrity": "sha512-96jBsVJDxZKFh+kWY7E18Is2usUsUYtBn97MxCtb4COnbgD4aE1h+P0fdFQNeJaI6KOeduas4Numc9yTuk0+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-diff": "^30.0.0" + }, + "engines": { + "node": "^20.9.0 || ^22.11.0 || ^24.11.0 || >=25.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "typescript": { + "optional": false + } + } + }, + "node_modules/jest-extended/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-extended/node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-extended/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-extended/node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-extended/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-extended/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -11998,10 +12453,10 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -12118,9 +12573,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/lodash.camelcase": { "version": "4.3.0", @@ -12137,12 +12593,6 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "dev": true - }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -12230,7 +12680,6 @@ "version": "11.2.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -12286,6 +12735,37 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/make-fetch-happen": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", + "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -12448,31 +12928,159 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { - "node": "*" + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-fetch/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 18" } }, "node_modules/mkdirp": { @@ -12622,6 +13230,12 @@ "url": "https://github.com/sponsors/raouldeheer" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -12710,6 +13324,54 @@ "node": "4.x || >=6.0.0" } }, + "node_modules/node-gyp": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -12767,6 +13429,21 @@ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -12988,28 +13665,6 @@ "openapi-types": "^9.3.1" } }, - "node_modules/openapi-response-validator/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/openapi-response-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/openapi-sampler": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.6.2.tgz", @@ -13034,28 +13689,24 @@ "openapi-types": "^9.3.1" } }, - "node_modules/openapi-schema-validator/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "node_modules/openapi-schema-validator/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "ajv": "^8.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/openapi-schema-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/openapi-types": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-9.3.1.tgz", @@ -13064,17 +13715,18 @@ "peer": true }, "node_modules/openapi-typescript": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.4.1.tgz", - "integrity": "sha512-HrRoWveViADezHCNgQqZmPKmQ74q7nuH/yg9ursFucZaYQNUqsX38fE/V2sKBHVM+pws4tAHpuh/ext2UJ/AoQ==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@redocly/openapi-core": "^1.25.3", + "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", - "parse-json": "^8.1.0", - "supports-color": "^9.4.0", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "bin": { @@ -13085,33 +13737,31 @@ } }, "node_modules/openapi-typescript/node_modules/@redocly/openapi-core": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.25.4.tgz", - "integrity": "sha512-qnpr4Z1rzfXdtxQxt/lfGD0wW3UVrm3qhrTpzLG5R/Ze+z+1u8sSRiQHp9N+RT3IuMjh00wq59nop9x9PPa1jQ==", + "version": "1.34.7", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.7.tgz", + "integrity": "sha512-gn2P0OER6qxF/+f4GqNv9XsnU5+6oszD/0SunulOvPYJDhrNkNVrVZV5waX25uqw5UDn2+roViWlRDHKFfHH0g==", "dev": true, "license": "MIT", "dependencies": { "@redocly/ajv": "^8.11.2", - "@redocly/config": "^0.12.1", + "@redocly/config": "^0.22.0", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", - "lodash.isequal": "^4.5.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, "engines": { - "node": ">=14.19.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=9.5.0" } }, "node_modules/openapi-typescript/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13132,15 +13782,15 @@ } }, "node_modules/openapi-typescript/node_modules/parse-json": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", - "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "index-to-position": "^0.1.2", - "type-fest": "^4.7.1" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { "node": ">=18" @@ -13150,13 +13800,13 @@ } }, "node_modules/openapi-typescript/node_modules/supports-color": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", - "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" @@ -13656,6 +14306,18 @@ "node": ">=8" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -13731,7 +14393,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13762,17 +14423,16 @@ } }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14230,6 +14890,15 @@ "node": ">=6" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -14338,6 +15007,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -14404,6 +15083,20 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14558,13 +15251,6 @@ "styled-components": "^4.1.1 || ^5.1.1 || ^6.0.5" } }, - "node_modules/redoc/node_modules/@redocly/config": { - "version": "0.22.2", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", - "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", - "dev": true, - "license": "MIT" - }, "node_modules/redoc/node_modules/@redocly/openapi-core": { "version": "1.34.6", "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz", @@ -14749,6 +15435,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -15272,6 +15977,44 @@ "node": ">=8.0.0" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -15323,6 +16066,18 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -15723,6 +16478,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -15775,7 +16546,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -15792,7 +16562,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -15810,7 +16579,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -16023,9 +16791,9 @@ } }, "node_modules/type-fest": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", - "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -16136,6 +16904,30 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unique-filename": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", + "license": "ISC", + "dependencies": { + "unique-slug": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/unique-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -16234,19 +17026,11 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", @@ -16700,6 +17484,21 @@ } } }, + "node_modules/xmlbuilder2": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" + }, + "engines": { + "node": ">=20.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -16716,6 +17515,15 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", @@ -16778,6 +17586,18 @@ "node": ">=12" } }, + "node_modules/yatag": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/yatag/-/yatag-1.3.0.tgz", + "integrity": "sha512-kkdqNFmWCWdz2FkGYnnkyhKlpJbd9zlZuMTtcKpDJZ3ZgfuX0beGhuZseI6npm120Ti5iMI+53JOK2EUgUVziw==", + "license": "ISC", + "dependencies": { + "glob": "^7.2.3" + }, + "bin": { + "yatag": "yatag.js" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -16802,6 +17622,30 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-schema-faker": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zod-schema-faker/-/zod-schema-faker-2.1.0.tgz", + "integrity": "sha512-k1WaldYU6nOXUa6Pup8HhuvqvAu2HWZUnxF9RSSi6GvQTTBkdXlvVN6cT4oRMUPxeoIHCBAr9DHKMK+H1r1zWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@faker-js/faker": "10.1.0", + "randexp": "0.5.3" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 42434bd..21639db 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@godaddy/terminus": "^4.12.1", "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", + "@map-colonies/error-types": "^1.3.1", "@map-colonies/express-access-log-middleware": "^4.0.0", "@map-colonies/js-logger": "^4.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", @@ -46,33 +47,44 @@ "compression": "^1.8.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", + "gdal-async": "^3.12.2", "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": "^4.3.6" }, "devDependencies": { "@commitlint/cli": "^20.4.1", + "@faker-js/faker": "^10.1.0", "@map-colonies/commitlint-config": "^2.0.0", "@map-colonies/eslint-config": "^7.2.0", "@map-colonies/openapi-helpers": "^5.1.0", "@map-colonies/prettier-config": "^1.0.0", "@map-colonies/tsconfig": "^2.0.0", + "@readme/openapi-parser": "^5.5.0", "@redocly/cli": "^2.16.0", "@types/compression": "^1.7.5", "@types/express": "^4.17.21", + "@types/lodash": "^4.17.23", "@types/multer": "^1.4.12", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.8", "@vitest/coverage-v8": "^4.0.18", "@vitest/eslint-plugin": "^1.6.9", "@vitest/ui": "^4.0.18", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "copyfiles": "^2.4.1", "cross-env": "^10.1.0", + "epsg-index": "^2.0.0", "eslint": "^9.39.2", "eslint-plugin-jest": "^28.11.0", "husky": "^9.1.7", + "jest-extended": "^7.0.0", "jest-openapi": "^0.14.2", + "lodash": "^4.17.23", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.1", "pretty-quick": "^4.2.2", "rimraf": "^6.1.2", @@ -80,6 +92,7 @@ "ts-jest": "^29.2.6", "tsc-alias": "^1.8.11", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "zod-schema-faker": "^2.1.0" } } From dbb0b3cd262c72add03997d4b697d14e9ca65fe5 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:11:14 +0200 Subject: [PATCH 03/52] chore: define configurations --- config/default.json | 19 +++++++++++++++++++ config/test.json | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/config/default.json b/config/default.json index d20b239..29f30fc 100644 --- a/config/default.json +++ b/config/default.json @@ -29,5 +29,24 @@ "options": null } } + }, + "storageExplorer": { + "sourceDir": "" + }, + "application": { + "defaultGeographicSrsId": 4326, + "defaultProjectedSrsId": 3395, + "supportedFormatsMap": { + "geotiff": "gtiff" + }, + "supportedSrsIds": [ + 4326, 32601, 32602, 32603, 32604, 32605, 32606, 32607, 32608, 32609, 32610, 32611, 32612, 32613, 32614, 32615, 32616, 32617, 32618, 32619, + 32620, 32621, 32622, 32623, 32624, 32625, 32626, 32627, 32628, 32629, 32630, 32631, 32632, 32633, 32634, 32635, 32636, 32637, 32638, 32639, + 32640, 32641, 32642, 32643, 32644, 32645, 32646, 32647, 32648, 32649, 32650, 32651, 32652, 32653, 32654, 32655, 32656, 32657, 32658, 32659, + 32660, 32701, 32702, 32703, 32704, 32705, 32706, 32707, 32708, 32709, 32710, 32711, 32712, 32713, 32714, 32715, 32716, 32717, 32718, 32719, + 32720, 32721, 32722, 32723, 32724, 32725, 32726, 32727, 32728, 32729, 32730, 32731, 32732, 32733, 32734, 32735, 32736, 32737, 32738, 32739, + 32740, 32741, 32742, 32743, 32744, 32745, 32746, 32747, 32748, 32749, 32750, 32751, 32752, 32753, 32754, 32755, 32756, 32757, 32758, 32759, + 32760 + ] } } diff --git a/config/test.json b/config/test.json index 0967ef4..8b94281 100644 --- a/config/test.json +++ b/config/test.json @@ -1 +1,5 @@ -{} +{ + "storageExplorer": { + "sourceDir": "" + } +} From d45b6411d69705cc06928efcf0bb916b5b30ee7c Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:42:41 +0200 Subject: [PATCH 04/52] feat: implement info endpoint with minor api changes --- openapi3.yaml | 45 +- .../controllers/anotherResourceController.ts | 29 -- .../models/anotherResourceManager.ts | 20 - .../routes/anotherResourceRouter.ts | 16 - src/common/constants.ts | 14 + src/common/gdal.ts | 142 ++++++ src/common/interfaces.ts | 20 +- src/common/schemas.ts | 12 + src/containerConfig.ts | 14 +- src/dem/controllers/demController.ts | 36 ++ src/dem/models/demManager.ts | 18 + src/dem/routes/demRouter.ts | 16 + src/info/controllers/infoController.ts | 35 ++ src/info/fileHandlers/gdal.ts | 111 +++++ src/info/models/infoManager.ts | 34 ++ src/info/routes/infoRouter.ts | 16 + src/openapi.d.ts | 459 ++++++++++++++++-- .../controllers/resourceNameController.ts | 35 -- .../models/resourceNameManager.ts | 36 -- src/resourceName/routes/resourceNameRouter.ts | 17 - src/serverBuilder.ts | 26 +- 21 files changed, 913 insertions(+), 238 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/common/gdal.ts create mode 100644 src/common/schemas.ts create mode 100644 src/dem/controllers/demController.ts create mode 100644 src/dem/models/demManager.ts create mode 100644 src/dem/routes/demRouter.ts create mode 100644 src/info/controllers/infoController.ts create mode 100644 src/info/fileHandlers/gdal.ts create mode 100644 src/info/models/infoManager.ts create mode 100644 src/info/routes/infoRouter.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 diff --git a/openapi3.yaml b/openapi3.yaml index 0adee2b..7038b13 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -31,7 +31,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DemSuccessfulResponse' + $ref: '#/components/schemas/DemResponse' 400: description: Bad Request content: @@ -81,7 +81,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DemSuccessfulResponse' + $ref: '#/components/schemas/DemResponse' 400: description: Bad Request content: @@ -130,7 +130,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DemSuccessfulResponse' + $ref: '#/components/schemas/DemResponse' 400: description: Bad Request content: @@ -280,25 +280,17 @@ components: $ref: '#/components/schemas/DemMetadata' inputFiles: $ref: '#/components/schemas/InputFiles' - DemType: - type: string - description: DEM type - minLength: 0 GeoTiffDataType: type: string description: GeoTiff supported data types enum: - - Byte - - UInt16 + - Int8 - Int16 - - UInt32 - Int32 + - Int64 + - Float16 - Float32 - Float64 - - CInt16 - - CInt32 - - CFloat32 - - CFloat64 DemFilePath: type: string pattern: ^(\/?[\w-]+)(\/[\w-]+)*\/[\wא-ת\.-]+\.(tif)$ @@ -334,7 +326,7 @@ components: $ref: '#/components/schemas/ProducerName' productSubType: $ref: '#/components/schemas/ProductSubType' - DemSuccessfulResponse: + DemResponse: type: object unevaluatedProperties: false required: @@ -382,6 +374,8 @@ components: message: type: string minLength: 1 + stacktrace: + type: string GeoidModel: type: string description: Earth's geoid model @@ -419,29 +413,17 @@ components: type: object description: Info response body unevaluatedProperties: false - required: - - demType - discriminator: - propertyName: demType oneOf: - - allOf: - - $ref: '#/components/schemas/InfoGeoTiff' + - $ref: '#/components/schemas/InfoGeoTiff' InfoGeoTiff: description: Info properties of GeoTiff allOf: - $ref: '#/components/schemas/InfoCommonRegularGridProperties' - type: object required: - - demType - dataType - noDataValue properties: - demType: - allOf: - - $ref: '#/components/schemas/DemType' - - type: string - enum: - - geotiff dataType: $ref: '#/components/schemas/GeoTiffDataType' noDataValue: @@ -474,8 +456,11 @@ components: description: Metadata shape file path example: /path/to/ShapeMetadata.shp NoDataValue: - type: number - description: No data value of DEM + oneOf: + - type: number + description: No data value of DEM + - type: string + const: NaN Status: type: string default: UNPUBLISHED 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 4ea0743..0000000 --- a/src/anotherResource/routes/anotherResourceRouter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import { 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/constants.ts b/src/common/constants.ts index 6e7c100..9095834 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,4 +1,13 @@ import { readPackageJsonSync } from '@map-colonies/read-pkg'; +import type { GeoTiffDataType, IsComplete, NoDuplicates, RasterDataType } from './interfaces'; + +const defineConstTuple = + () => + ( + ...args: U & + (NoDuplicates extends false ? 'Error: Duplicate value found' : IsComplete extends false ? 'Error: Missing values from the union' : U) + ): U => + args; export const SERVICE_NAME = readPackageJsonSync().name ?? 'unknown_service'; export const DEFAULT_SERVER_PORT = 80; @@ -14,3 +23,8 @@ export const SERVICES = { METRICS: Symbol('METRICS'), } satisfies Record; /* eslint-enable @typescript-eslint/naming-convention */ + +export const GEOTIFF_DATA_TYPES = defineConstTuple()('Int8', 'Int16', 'Int32', 'Int64', 'Float16', 'Float32', 'Float64'); +export const RASTER_DATA_TYPES: Record = { + geotiff: GEOTIFF_DATA_TYPES, +}; diff --git a/src/common/gdal.ts b/src/common/gdal.ts new file mode 100644 index 0000000..e85ac4f --- /dev/null +++ b/src/common/gdal.ts @@ -0,0 +1,142 @@ +import epsg from 'epsg-index/all.json'; +import { CoordinateTransformation, SpatialReference, type Dataset, type Envelope, type xyz } from 'gdal-async'; +import { z } from 'zod'; +import type { InfoResponse } from '@src/info/models/infoManager'; + +interface PixelInfo { + pixelWidth: number; + pixelHeight: number; +} + +const epsgRecordSchema = z.object({ + code: z.string(), + kind: z.string(), + name: z.string(), + wkt: z.string().nullable(), + proj4: z.string().nullable(), + bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]), + unit: z.string().nullable(), + area: z.string().nullable(), + accuracy: z.number().nullable(), +}); + +const epsgRecords = z.record(z.coerce.number().int().positive(), epsgRecordSchema).parse(epsg); + +const geoTransformSchema = z.tuple([z.number(), z.number(), z.number(), z.number(), z.number(), z.number()]); + +export const getPixelInfo = (options: Pick): PixelInfo => { + const { geoTransform } = options; + const validGeoTransform = geoTransformSchema.parse(geoTransform); + return { pixelHeight: Math.abs(validGeoTransform[5]), pixelWidth: Math.abs(validGeoTransform[1]) }; +}; + +export const getResolutions = ( + options: { + sourceSrs: SpatialReference | number; + targetGeographicSrs: SpatialReference | number; + targetProjectedSrs: SpatialReference | number; + } & Pick & + PixelInfo +): Pick => { + const { targetGeographicSrs, targetProjectedSrs, maxX, maxY, minX, minY, pixelHeight, pixelWidth, sourceSrs } = options; + const resolvedSourceSrs = typeof sourceSrs === 'number' ? SpatialReference.fromEPSG(sourceSrs) : sourceSrs; + const resolvedTargetGeographicSrs = typeof targetGeographicSrs === 'number' ? SpatialReference.fromEPSG(targetGeographicSrs) : targetGeographicSrs; + const resolvedTargetProjectedSrs = typeof targetProjectedSrs === 'number' ? SpatialReference.fromEPSG(targetProjectedSrs) : targetProjectedSrs; + + // TODO: how to handle pixelHeight, pixelWidth? mean / max / min ??? + const [dx, dy] = [maxX - minX, maxY - minY]; + const [sourceMinX, sourceMinY, sourceMaxX, sourceMaxY] = [ + /* eslint-disable @typescript-eslint/no-magic-numbers */ + minX + (dx - pixelWidth) / 2, + minY + (dy - pixelHeight) / 2, + minX + (dx + pixelWidth) / 2, + minY + (dy + pixelHeight) / 2, + /* eslint-enable @typescript-eslint/no-magic-numbers */ + ]; + + // approximation of the reprojected resolution + const getReprojectedResolution = (targetSrs: SpatialReference): number => { + const { x: targetMinX, y: targetMinY } = transformPoint({ + sourceSrs: resolvedSourceSrs, + targetSrs, + point: { x: sourceMinX, y: sourceMinY }, + }); + const { x: targetMaxX, y: targetMaxY } = transformPoint({ + sourceSrs: resolvedSourceSrs, + targetSrs, + point: { x: sourceMaxX, y: sourceMaxY }, + }); + const [dxTarget, dyTarget] = [targetMaxX - targetMinX, targetMaxY - targetMinY]; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const reprojectedResolution = (((dxTarget ** 2 + dyTarget ** 2) / (pixelWidth ** 2 + pixelHeight ** 2)) * pixelHeight ** 2) ** 0.5; + return reprojectedResolution; + }; + + const resolutions = ( + [ + [resolvedSourceSrs.isGeographic(), { resolutionMeter: getReprojectedResolution(resolvedTargetProjectedSrs), resolutionDegrees: pixelHeight }], + [resolvedSourceSrs.isProjected(), { resolutionMeter: pixelHeight, resolutionDegrees: getReprojectedResolution(resolvedTargetGeographicSrs) }], + ] satisfies [boolean, { resolutionMeter: number; resolutionDegrees: number }][] + ).find((value) => value[0])?.[1]; + + if (resolutions == undefined) { + throw new Error('Unsupported SRS type'); + } + + return resolutions; +}; + +export const getSrsName = (srsId: number): string => { + const srs = SpatialReference.fromEPSG(srsId); + const srsName = ( + [ + [srs.isGeographic(), srs.getAttrValue('GEOGCS')], + [srs.isProjected(), srs.getAttrValue('PROJCS')], + ] satisfies [boolean, string][] + ).find((value) => value[0])?.[1]; + + if (srsName == undefined) { + throw new Error('Unsupported SRS type'); + } + + return srsName; +}; + +export const getSrsGeographicBounds = (options: { srsId: number }): [number, number, number, number] => { + const { srsId } = options; + const epsgRecord = epsgRecords[srsId]; + if (!epsgRecord) throw new Error('Unsupported SRS'); + + const [sourceMaxY, sourceMinX, sourceMinY, sourceMaxX] = epsgRecord.bbox; + return [sourceMinX, sourceMinY, sourceMaxX, sourceMaxY]; +}; + +export const getSrsInfo = (srs: SpatialReference): Pick => { + const srsAuthorityCode = srs.getAuthorityCode(); + const srsId = parseInt(srsAuthorityCode); + const srsName = getSrsName(srsId); + + return { + srsId, + srsName, + }; +}; + +export const transformPoint = (options: { sourceSrs: SpatialReference; targetSrs: SpatialReference; point: xyz }): xyz => { + const { sourceSrs, targetSrs, point } = options; + const coordinateTransformation = new CoordinateTransformation(sourceSrs, targetSrs); + + let sourcePoint: xyz; + + if (sourceSrs.isGeographic()) { + sourcePoint = sourceSrs.EPSGTreatsAsLatLong() ? { x: point.y, y: point.x } : point; + } else if (sourceSrs.isProjected()) { + sourcePoint = sourceSrs.EPSGTreatsAsNorthingEasting() ? { x: point.y, y: point.x } : point; + } else { + throw new Error('Unsupported SRS type'); + } + + const transformedPoint = coordinateTransformation.transformPoint(sourcePoint); + return transformedPoint; +}; diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 455054c..fe13253 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,4 +1,16 @@ -export interface IConfig { - get: (setting: string) => T; - has: (setting: string) => boolean; -} +import type { z } from 'zod'; +import type { components } from '@src/openapi'; +import type { pixelDataTypesSchema } from './schemas'; + +export type NoDuplicates = T extends [infer First, ...infer Rest] + ? First extends Rest[number] + ? false + : NoDuplicates + : true; + +export type IsComplete = [Target] extends [U[number]] ? true : false; + +export type GeoTiffDataType = components['schemas']['InfoGeoTiff']['dataType']; +export type RasterDataType = components['schemas']['InfoResponse']['dataType']; + +export type PixelDataType = z.infer; diff --git a/src/common/schemas.ts b/src/common/schemas.ts new file mode 100644 index 0000000..c7924bb --- /dev/null +++ b/src/common/schemas.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { getConfig } from './config'; +import { GEOTIFF_DATA_TYPES } from './constants'; + +const config = getConfig(); + +const supportedSrsIds = config.get('application.supportedSrsIds') as unknown as number[]; // TODO: include application.supportedSrsIds in service schema +export const areaOrPointSchema = z.literal(['Area', 'Point']); +export const noDataValueSchema = z.union([z.number(), z.nan()]).transform((value) => (Number.isNaN(value) ? 'NaN' : value)); +export const pixelDataTypesSchema = z.union([z.literal(GEOTIFF_DATA_TYPES)]); // add additional data types to union for each supported format +export const pixelSchema = z.number().positive(); +export const srsIdSchema = z.literal(supportedSrsIds); diff --git a/src/containerConfig.ts b/src/containerConfig.ts index efaea90..f4de2f1 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -1,14 +1,15 @@ +import { jsLogger } from '@map-colonies/js-logger'; import { getOtelMixin } from '@map-colonies/tracing-utils'; import { trace } from '@opentelemetry/api'; import { Registry } from 'prom-client'; import { DependencyContainer } from 'tsyringe/dist/typings/types'; -import { jsLogger } from '@map-colonies/js-logger'; -import { InjectionObject, registerDependencies } from '@common/dependencyRegistration'; import { SERVICES, SERVICE_NAME } from '@common/constants'; +import { InjectionObject, registerDependencies } from '@common/dependencyRegistration'; 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 { getConfig } from './common/config'; +import { DEM_ROUTER_SYMBOL, demRouterFactory } from './dem/routes/demRouter'; +import { GDALHandler } from './info/fileHandlers/gdal'; +import { INFO_ROUTER_SYMBOL, infoRouterFactory } from './info/routes/infoRouter'; export interface RegisterOptions { override?: InjectionObject[]; @@ -31,8 +32,9 @@ 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: DEM_ROUTER_SYMBOL, provider: { useFactory: demRouterFactory } }, + { token: 'FileHandler', provider: { useClass: GDALHandler } }, + { token: INFO_ROUTER_SYMBOL, provider: { useFactory: infoRouterFactory } }, { token: 'onSignal', provider: { diff --git a/src/dem/controllers/demController.ts b/src/dem/controllers/demController.ts new file mode 100644 index 0000000..75921ac --- /dev/null +++ b/src/dem/controllers/demController.ts @@ -0,0 +1,36 @@ +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 { DEMManager } from '../models/demManager'; + +@injectable() +export class DEMController { + private readonly demEditCounter: Counter; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(DEMManager) private readonly demManager: DEMManager, + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry + ) { + this.demEditCounter = new Counter({ + name: 'edit', + help: 'number of edit requests', + registers: [this.metricsRegistry], + }); + } + + public edit: TypedRequestHandlers['edit'] = (req, res, next) => { + try { + this.demEditCounter.inc(1); + const response = this.demManager.edit({ ...req.params, ...req.body }); + return res.status(httpStatus.OK).json(response); + } catch (error) { + console.error(error); + next(error); + } + }; +} diff --git a/src/dem/models/demManager.ts b/src/dem/models/demManager.ts new file mode 100644 index 0000000..c5a22e4 --- /dev/null +++ b/src/dem/models/demManager.ts @@ -0,0 +1,18 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { inject, injectable } from 'tsyringe'; +import type { components, operations } from '@openapi'; +import { SERVICES } from '@common/constants'; + +export type EditOptions = components['schemas']['EditRequestBody'] & operations['edit']['parameters']['path']; +export type DemResponse = components['schemas']['DemResponse']; + +@injectable() +export class DEMManager { + public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} + + public edit(options: EditOptions): DemResponse { + this.logger.info({ msg: 'editing resource', resource: options }); + + return { jobId: '795bfb61-9c26-4860-aae3-ef071219cdff' }; + } +} diff --git a/src/dem/routes/demRouter.ts b/src/dem/routes/demRouter.ts new file mode 100644 index 0000000..621704e --- /dev/null +++ b/src/dem/routes/demRouter.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { FactoryFunction } from 'tsyringe'; +import { DEMController } from '../controllers/demController'; + +const demRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(DEMController); + + router.patch('/:id', controller.edit); + + return router; +}; + +export const DEM_ROUTER_SYMBOL = Symbol('demRouterFactory'); + +export { demRouterFactory }; diff --git a/src/info/controllers/infoController.ts b/src/info/controllers/infoController.ts new file mode 100644 index 0000000..de68d24 --- /dev/null +++ b/src/info/controllers/infoController.ts @@ -0,0 +1,35 @@ +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 { InfoManager } from '../models/infoManager'; + +@injectable() +export class InfoController { + private readonly infoCounter: Counter; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, + @inject(InfoManager) private readonly infoManager: InfoManager + ) { + this.infoCounter = new Counter({ + name: 'info', + help: 'number of info requests', + registers: [this.metricsRegistry], + }); + } + + public info: TypedRequestHandlers['info'] = async (req, res, next) => { + try { + this.infoCounter.inc(1); + const response = await this.infoManager.info(req.body); + return res.status(httpStatus.OK).json(response); + } catch (error) { + this.logger.error(error); + next(error); + } + }; +} diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts new file mode 100644 index 0000000..766ee79 --- /dev/null +++ b/src/info/fileHandlers/gdal.ts @@ -0,0 +1,111 @@ +import { access, constants } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { Driver, drivers, openAsync, SpatialReference, type Dataset } from 'gdal-async'; +import { inject, injectable } from 'tsyringe'; +import { z } from 'zod'; +import { NotFoundError } from '@map-colonies/error-types'; +import type { Logger } from '@map-colonies/js-logger'; +import type { ConfigType } from '@src/common/config'; +import { SERVICES } from '@src/common/constants'; +import { getPixelInfo, getResolutions, getSrsInfo } from '@src/common/gdal'; +import { areaOrPointSchema, noDataValueSchema, pixelDataTypesSchema } from '@src/common/schemas'; +import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; + +@injectable() +export class GDALHandler implements FileHandler { + private readonly defaultGeographicSrs: SpatialReference; + private readonly defaultProjectedSrs: SpatialReference; + private readonly supportedFormatsMap: Record; + private readonly sourceDir: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.defaultGeographicSrs = SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId') as unknown as number); + this.defaultProjectedSrs = SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId') as unknown as number); + this.supportedFormatsMap = this.config.get('application.supportedFormatsMap') as unknown as Record; + this.sourceDir = this.config.get('storageExplorer.sourceDir') as unknown as string; + } + + public supports(filePath: string): boolean { + try { + this.getDriver(filePath); + return true; + } catch (error) { + this.logger.debug(error); + return false; + } + } + + public async getInfo(filePath: string): Promise { + this.logger.info(`getting info for ${filePath}`); + let dataset: Dataset | undefined; + + const fullFilePath = join(this.sourceDir, filePath); + try { + await access(fullFilePath, constants.F_OK); + } catch (error) { + throw new NotFoundError(`Cannot find file: ${fullFilePath}. got error: ${JSON.stringify(error)}`); + } + try { + const dataset = await openAsync(fullFilePath, 'r'); + + const driverName = dataset.driver.description.toLowerCase(); + const supportedFormat = Object.entries(this.supportedFormatsMap).find(([, value]) => value === driverName)?.[0]; + if (supportedFormat === undefined) throw new Error('Unsupported DEM format'); + + const areaOrPoint = z + // eslint-disable-next-line @typescript-eslint/naming-convention + .object({ AREA_OR_POINT: areaOrPointSchema }, { error: 'Could not extract AREA_OR_POINT metadata' }) + .parse(await dataset.getMetadataAsync()).AREA_OR_POINT; + + const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded + + const dataType = pixelDataTypesSchema.parse(await band.dataTypeAsync); + + const noDataValue = noDataValueSchema.parse(await band.noDataValueAsync); + + const srs = await dataset.srsAsync; + if (srs === null) throw new Error('Unsupported SRS'); + + const { srsId, srsName } = getSrsInfo(srs); + + const geoTransform = await dataset.geoTransformAsync; + + const { resolutionDegrees, resolutionMeter } = getResolutions({ + ...dataset.bands.getEnvelope(), + ...getPixelInfo({ geoTransform }), + targetGeographicSrs: this.defaultGeographicSrs, + targetProjectedSrs: this.defaultProjectedSrs, + sourceSrs: srs, + }); + + return { + areaOrPoint, + dataType, + noDataValue, + resolutionDegrees, + resolutionMeter, + srsId, + srsName, + }; + } finally { + dataset?.close(); + } + } + + private getDriver(filePath: string): Driver { + const fileExtension = extname(filePath).slice(1); + const supportedDriver = Object.values(this.supportedFormatsMap).find((supportedDriver) => { + const driver = drivers.get(supportedDriver); + // eslint-disable-next-line @typescript-eslint/naming-convention + const driverMetadata = driver.getMetadata() as { DMD_EXTENSION?: string; DMD_EXTENSIONS?: string }; + const { DMD_EXTENSION: extension = '', DMD_EXTENSIONS: extensions = '' } = driverMetadata; + return [extension, ...extensions.split(' ')].filter((extension) => extension.length > 0).includes(fileExtension); + }); + + if (supportedDriver === undefined) throw new Error(`Unsupported file format of file: ${filePath}`); + return drivers.get(supportedDriver); + } +} diff --git a/src/info/models/infoManager.ts b/src/info/models/infoManager.ts new file mode 100644 index 0000000..183641d --- /dev/null +++ b/src/info/models/infoManager.ts @@ -0,0 +1,34 @@ +import { injectable, injectAll } from 'tsyringe'; +import { UnprocessableEntityError } from '@map-colonies/error-types'; +import { components } from '@src/openapi'; + +export type InfoOptions = components['schemas']['InfoRequestBody']; +export type InfoResponse = components['schemas']['InfoResponse']; +export interface FileHandler { + supports: (filePath: string) => boolean; + getInfo: (filePath: string) => Promise; +} + +@injectable() +export class InfoManager { + public constructor(@injectAll('FileHandler') private readonly fileHandlers: FileHandler[]) {} + + public async info(options: InfoOptions): Promise { + const { demFilePath } = options; + + const response = await this.process(demFilePath); + + return response; + } + + private async process(filePath: string): Promise { + const handler = this.fileHandlers.find((handler) => handler.supports(filePath)); + + if (!handler) { + throw new UnprocessableEntityError(`No handler found for file: ${filePath}`); + } + + const info = await handler.getInfo(filePath); + return info; + } +} diff --git a/src/info/routes/infoRouter.ts b/src/info/routes/infoRouter.ts new file mode 100644 index 0000000..ad06ead --- /dev/null +++ b/src/info/routes/infoRouter.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { FactoryFunction } from 'tsyringe'; +import { InfoController } from '../controllers/infoController'; + +const infoRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(InfoController); + + router.post('/', controller.info); + + return router; +}; + +export const INFO_ROUTER_SYMBOL = Symbol('infoRouterFactory'); + +export { infoRouterFactory }; diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 3c3f57f..0e64889 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,35 +4,69 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-helpers/typedRequestHandler'; export type paths = { - '/anotherResource': { + '/dem': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getAnotherResource']; + get?: never; put?: never; - post?: never; + /** Create a DEM resource */ + post: operations['create']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/resourceName': { + '/dem/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete an existing DEM resource */ + delete: operations['delete']; + options?: never; + head?: never; + /** Edit an existing DEM resource */ + patch: operations['edit']; + trace?: never; + }; + '/dem/{id}/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Edit status for existing DEM resource */ + patch: operations['editStatus']; + trace?: never; + }; + '/info': { 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']; + /** Extracts GDAL info from the provided DEM file */ + post: operations['info']; delete?: never; options?: never; head?: never; @@ -43,19 +77,140 @@ export type paths = { export type webhooks = Record; export type components = { schemas: { - error: { + /** + * @description Indicates whether each raster pixel represents an area or point + * @enum {string} + */ + AreaOrPoint: 'Area' | 'Point'; + /** @description Permitted roles, value must be between 0 and 100 */ + Classification: string; + CreateRequestBody: { + metadata: components['schemas']['DemMetadata']; + inputFiles: components['schemas']['InputFiles']; + }; + /** + * @description GeoTiff supported data types + * @enum {string} + */ + GeoTiffDataType: 'Int8' | 'Int16' | 'Int32' | 'Int64' | 'Float16' | 'Float32' | 'Float64'; + /** + * @description Input file paths + * @example /path/to/example.tif + */ + DemFilePath: string; + DemMetadata: { + classification: components['schemas']['Classification']; + productId: components['schemas']['ProductId']; + productName: components['schemas']['ProductName']; + productType: components['schemas']['ProductType']; + region: components['schemas']['Region']; + description?: components['schemas']['Description']; + geoidModel?: components['schemas']['GeoidModel']; + keywords?: components['schemas']['Keywords']; + producerName?: components['schemas']['ProducerName']; + productSubType?: components['schemas']['ProductSubType']; + }; + DemResponse: { + /** Format: uuid */ + jobId: string; + }; + /** @description Layer's description */ + Description: string; + EditRequestBody: { + classification?: components['schemas']['Classification']; + description?: components['schemas']['Description']; + geoidModel?: components['schemas']['GeoidModel']; + keywords?: components['schemas']['Keywords']; + producerName?: components['schemas']['ProducerName']; + productName?: components['schemas']['ProductName']; + region?: components['schemas']['Region']; + }; + EditStatusRequestBody: { + status: components['schemas']['Status']; + }; + ErrorMessage: { message: string; + stacktrace?: string; + }; + /** @description Earth's geoid model */ + GeoidModel: string; + /** @description Common properties of regular grids */ + InfoCommonRegularGridProperties: { + areaOrPoint: components['schemas']['AreaOrPoint']; + resolutionDegrees: components['schemas']['ResolutionDegrees']; + resolutionMeter: components['schemas']['ResolutionMeter']; + srsId: components['schemas']['SrsId']; + srsName: components['schemas']['SrsName']; + }; + /** @description Info request body */ + InfoRequestBody: { + demFilePath: components['schemas']['DemFilePath']; }; - resource: { - /** Format: int64 */ - id: number; - name: string; - description: string; + /** @description Info response body */ + InfoResponse: components['schemas']['InfoGeoTiff']; + /** @description Info properties of GeoTiff */ + InfoGeoTiff: components['schemas']['InfoCommonRegularGridProperties'] & { + dataType: components['schemas']['GeoTiffDataType']; + noDataValue: components['schemas']['NoDataValue']; }; - anotherResource: { - kind: string; - isAlive: boolean; + InputFiles: { + demFilePath: components['schemas']['DemFilePath']; + metadataShapefilePath: components['schemas']['MetadataShapefilePath']; + productShapefilePath: components['schemas']['ProductShapefilePath']; }; + Keywords: string; + /** + * Format: uuid + * @description Layer's identifier + * @example c52d8189-7e07-456a-8c6b-53859523c3e9 + */ + LayerId: string; + /** + * @description Metadata shape file path + * @example /path/to/ShapeMetadata.shp + */ + MetadataShapefilePath: string; + NoDataValue: number | 'NaN'; + /** + * @description The status of the DEM + * @default UNPUBLISHED + * @enum {string} + */ + Status: 'PUBLISHED' | 'UNPUBLISHED'; + /** + * @description Layer's producer name default to 'IDFMU' + * @default IDFMU + */ + ProducerName: string; + /** + * @description Layer's external identifier, must start with a letter and contain only letters, numbers and underscores + * @example SRTM + */ + ProductId: string; + /** @description Layer's external name */ + ProductName: string; + /** @description Layer's sub type */ + ProductSubType: string; + /** + * @description Layer's type, list of DEM product types + * @enum {string} + */ + ProductType: 'DTM' | 'DSM' | 'DTMBest' | 'DSMBest'; + /** + * @description Product shape file path + * @example /path/to/Product.shp + */ + ProductShapefilePath: string; + /** @description List of layer's regions */ + Region: string[]; + /** @description DEM resolution in degrees */ + ResolutionDegrees: number; + /** @description DEM resolution in meters */ + ResolutionMeter: number; + /** @description Projection code as registered by EPSG */ + SrsId: number; + /** @description Projection name as registered by EPSG */ + SrsName: string; }; responses: never; parameters: never; @@ -65,14 +220,18 @@ export type components = { }; export type $defs = Record; export interface operations { - getAnotherResource: { + create: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['CreateRequestBody']; + }; + }; responses: { /** @description OK */ 200: { @@ -80,7 +239,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['anotherResource']; + 'application/json': components['schemas']['DemResponse']; }; }; /** @description Bad Request */ @@ -89,16 +248,46 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['error']; + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; }; }; }; }; - getResourceName: { + delete: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The id of the DEM to delete */ + id: components['schemas']['LayerId']; + }; cookie?: never; }; requestBody?: never; @@ -109,8 +298,150 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['DemResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + }; + }; + edit: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the DEM to edit */ + id: components['schemas']['LayerId']; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['EditRequestBody']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DemResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + }; + }; + editStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the DEM whose status to edit */ + id: components['schemas']['LayerId']; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['EditStatusRequestBody']; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -118,31 +449,68 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['error']; + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; }; }; }; }; - createResource: { + info: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** @description An object containing object of DEM input file */ requestBody: { content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['InfoRequestBody']; }; }; responses: { - /** @description created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['InfoResponse']; }; }; /** @description Bad Request */ @@ -151,7 +519,34 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['error']; + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorMessage']; }; }; }; 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 d908906..0000000 --- a/src/resourceName/routes/resourceNameRouter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from 'express'; -import { 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 0df4f55..7e7d377 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -1,18 +1,18 @@ -import express, { Router } from 'express'; -import bodyParser from 'body-parser'; -import compression from 'compression'; -import { OpenapiViewerRouter } from '@map-colonies/openapi-express-viewer'; import { getErrorHandlerMiddleware } from '@map-colonies/error-express-handler'; -import { middleware as OpenApiMiddleware } from 'express-openapi-validator'; -import { inject, injectable } from 'tsyringe'; -import type { Logger } from '@map-colonies/js-logger'; import { httpLogger } from '@map-colonies/express-access-log-middleware'; +import type { Logger } from '@map-colonies/js-logger'; +import { OpenapiViewerRouter } from '@map-colonies/openapi-express-viewer'; import { collectMetricsExpressMiddleware } from '@map-colonies/prometheus'; +import bodyParser from 'body-parser'; +import compression from 'compression'; +import express, { Router } from 'express'; +import { middleware as OpenApiMiddleware } from 'express-openapi-validator'; import { Registry } from 'prom-client'; +import { inject, injectable } from 'tsyringe'; 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 { DEM_ROUTER_SYMBOL } from './dem/routes/demRouter'; +import { INFO_ROUTER_SYMBOL } from './info/routes/infoRouter'; @injectable() export class ServerBuilder { @@ -22,8 +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(RESOURCE_NAME_ROUTER_SYMBOL) private readonly resourceNameRouter: Router, - @inject(ANOTHER_RESOURCE_ROUTER_SYMBOL) private readonly anotherResourceRouter: Router + @inject(DEM_ROUTER_SYMBOL) private readonly demRouter: Router, + @inject(INFO_ROUTER_SYMBOL) private readonly infoRouter: Router ) { this.serverInstance = express(); } @@ -46,8 +46,8 @@ export class ServerBuilder { } private buildRoutes(): void { - this.serverInstance.use('/resourceName', this.resourceNameRouter); - this.serverInstance.use('/anotherResource', this.anotherResourceRouter); + this.serverInstance.use('/dem', this.demRouter); + this.serverInstance.use('/info', this.infoRouter); this.buildDocsRoutes(); } From 088763958237e728af0434fa1a7c1366b4de8ed1 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:04:32 +0200 Subject: [PATCH 05/52] refactor: inject gdal-async into gdal file handler and other changes --- src/common/epsg.ts | 4 +++ src/common/gdal.ts | 24 +++++---------- src/common/schemas.ts | 14 +++++++++ src/containerConfig.ts | 5 +++- src/info/controllers/infoController.ts | 9 ++++-- src/info/fileHandlers/gdal.ts | 41 ++++++++++++++------------ 6 files changed, 58 insertions(+), 39 deletions(-) create mode 100644 src/common/epsg.ts diff --git a/src/common/epsg.ts b/src/common/epsg.ts new file mode 100644 index 0000000..fb8d397 --- /dev/null +++ b/src/common/epsg.ts @@ -0,0 +1,4 @@ +import epsg from 'epsg-index/all.json'; +import { epsgRecordsSchema } from './schemas'; + +export const EPSG_DATA_RECORDS = epsgRecordsSchema.parse(epsg); diff --git a/src/common/gdal.ts b/src/common/gdal.ts index e85ac4f..bd5c7e3 100644 --- a/src/common/gdal.ts +++ b/src/common/gdal.ts @@ -1,32 +1,22 @@ -import epsg from 'epsg-index/all.json'; import { CoordinateTransformation, SpatialReference, type Dataset, type Envelope, type xyz } from 'gdal-async'; +import * as gdalAsync from 'gdal-async'; import { z } from 'zod'; import type { InfoResponse } from '@src/info/models/infoManager'; +import { EPSG_DATA_RECORDS } from './epsg'; interface PixelInfo { pixelWidth: number; pixelHeight: number; } -const epsgRecordSchema = z.object({ - code: z.string(), - kind: z.string(), - name: z.string(), - wkt: z.string().nullable(), - proj4: z.string().nullable(), - bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]), - unit: z.string().nullable(), - area: z.string().nullable(), - accuracy: z.number().nullable(), -}); - -const epsgRecords = z.record(z.coerce.number().int().positive(), epsgRecordSchema).parse(epsg); - const geoTransformSchema = z.tuple([z.number(), z.number(), z.number(), z.number(), z.number(), z.number()]); +export type GdalAsync = typeof gdalAsync; +export const GDAL_ASYNC = Symbol('gdal'); + export const getPixelInfo = (options: Pick): PixelInfo => { const { geoTransform } = options; - const validGeoTransform = geoTransformSchema.parse(geoTransform); + const validGeoTransform = geoTransformSchema.parse(geoTransform, { error: () => 'Unsupported geo transform' }); return { pixelHeight: Math.abs(validGeoTransform[5]), pixelWidth: Math.abs(validGeoTransform[1]) }; }; @@ -105,7 +95,7 @@ export const getSrsName = (srsId: number): string => { export const getSrsGeographicBounds = (options: { srsId: number }): [number, number, number, number] => { const { srsId } = options; - const epsgRecord = epsgRecords[srsId]; + const epsgRecord = EPSG_DATA_RECORDS[srsId]; if (!epsgRecord) throw new Error('Unsupported SRS'); const [sourceMaxY, sourceMinX, sourceMinY, sourceMaxX] = epsgRecord.bbox; diff --git a/src/common/schemas.ts b/src/common/schemas.ts index c7924bb..c024387 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -5,8 +5,22 @@ import { GEOTIFF_DATA_TYPES } from './constants'; const config = getConfig(); const supportedSrsIds = config.get('application.supportedSrsIds') as unknown as number[]; // TODO: include application.supportedSrsIds in service schema + export const areaOrPointSchema = z.literal(['Area', 'Point']); export const noDataValueSchema = z.union([z.number(), z.nan()]).transform((value) => (Number.isNaN(value) ? 'NaN' : value)); export const pixelDataTypesSchema = z.union([z.literal(GEOTIFF_DATA_TYPES)]); // add additional data types to union for each supported format export const pixelSchema = z.number().positive(); export const srsIdSchema = z.literal(supportedSrsIds); + +export const epsgRecordSchema = z.object({ + code: z.string(), + kind: z.string(), + name: z.string(), + wkt: z.string().nullable(), + proj4: z.string().nullable(), + bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]), + unit: z.string().nullable(), + area: z.string().nullable(), + accuracy: z.number().nullable(), +}); +export const epsgRecordsSchema = z.record(z.coerce.number().int().positive(), epsgRecordSchema); diff --git a/src/containerConfig.ts b/src/containerConfig.ts index f4de2f1..a6e2596 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -1,12 +1,14 @@ import { jsLogger } from '@map-colonies/js-logger'; import { getOtelMixin } from '@map-colonies/tracing-utils'; import { trace } from '@opentelemetry/api'; +import * as gdalAsync from 'gdal-async'; import { Registry } from 'prom-client'; import { DependencyContainer } from 'tsyringe/dist/typings/types'; +import { getConfig } from '@common/config'; import { SERVICES, SERVICE_NAME } from '@common/constants'; import { InjectionObject, registerDependencies } from '@common/dependencyRegistration'; +import { GDAL_ASYNC } from '@common/gdal'; import { getTracing } from '@common/tracing'; -import { getConfig } from './common/config'; import { DEM_ROUTER_SYMBOL, demRouterFactory } from './dem/routes/demRouter'; import { GDALHandler } from './info/fileHandlers/gdal'; import { INFO_ROUTER_SYMBOL, infoRouterFactory } from './info/routes/infoRouter'; @@ -33,6 +35,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, { token: DEM_ROUTER_SYMBOL, provider: { useFactory: demRouterFactory } }, + { token: GDAL_ASYNC, provider: { useValue: gdalAsync } }, { token: 'FileHandler', provider: { useClass: GDALHandler } }, { token: INFO_ROUTER_SYMBOL, provider: { useFactory: infoRouterFactory } }, { diff --git a/src/info/controllers/infoController.ts b/src/info/controllers/infoController.ts index de68d24..44a6e92 100644 --- a/src/info/controllers/infoController.ts +++ b/src/info/controllers/infoController.ts @@ -1,7 +1,9 @@ +import { UnprocessableEntityError } from '@map-colonies/error-types'; 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 Registry, Counter } from 'prom-client'; +import { inject, injectable } from 'tsyringe'; +import { ZodError } from 'zod'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; import { InfoManager } from '../models/infoManager'; @@ -29,6 +31,9 @@ export class InfoController { return res.status(httpStatus.OK).json(response); } catch (error) { this.logger.error(error); + if (error instanceof ZodError) { + return next(new UnprocessableEntityError(error.issues[0]?.message ?? 'validation error')); + } next(error); } }; diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 766ee79..ed8bb3b 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -1,14 +1,14 @@ import { access, constants } from 'node:fs/promises'; import { extname, join } from 'node:path'; -import { Driver, drivers, openAsync, SpatialReference, type Dataset } from 'gdal-async'; +import type { Dataset, Driver, SpatialReference } from 'gdal-async'; import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; import { NotFoundError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; import type { ConfigType } from '@src/common/config'; import { SERVICES } from '@src/common/constants'; -import { getPixelInfo, getResolutions, getSrsInfo } from '@src/common/gdal'; -import { areaOrPointSchema, noDataValueSchema, pixelDataTypesSchema } from '@src/common/schemas'; +import { GDAL_ASYNC, getPixelInfo, getResolutions, getSrsInfo, type GdalAsync } from '@src/common/gdal'; +import { areaOrPointSchema, noDataValueSchema, pixelDataTypesSchema, srsIdSchema } from '@src/common/schemas'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; @injectable() @@ -20,10 +20,11 @@ export class GDALHandler implements FileHandler { public constructor( @inject(SERVICES.CONFIG) private readonly config: ConfigType, - @inject(SERVICES.LOGGER) private readonly logger: Logger + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(GDAL_ASYNC) private readonly gdal: GdalAsync ) { - this.defaultGeographicSrs = SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId') as unknown as number); - this.defaultProjectedSrs = SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId') as unknown as number); + this.defaultGeographicSrs = this.gdal.SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId') as unknown as number); + this.defaultProjectedSrs = this.gdal.SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId') as unknown as number); this.supportedFormatsMap = this.config.get('application.supportedFormatsMap') as unknown as Record; this.sourceDir = this.config.get('storageExplorer.sourceDir') as unknown as string; } @@ -39,7 +40,7 @@ export class GDALHandler implements FileHandler { } public async getInfo(filePath: string): Promise { - this.logger.info(`getting info for ${filePath}`); + this.logger.info(`Getting info for ${filePath}`); let dataset: Dataset | undefined; const fullFilePath = join(this.sourceDir, filePath); @@ -48,28 +49,30 @@ export class GDALHandler implements FileHandler { } catch (error) { throw new NotFoundError(`Cannot find file: ${fullFilePath}. got error: ${JSON.stringify(error)}`); } - try { - const dataset = await openAsync(fullFilePath, 'r'); - const driverName = dataset.driver.description.toLowerCase(); - const supportedFormat = Object.entries(this.supportedFormatsMap).find(([, value]) => value === driverName)?.[0]; - if (supportedFormat === undefined) throw new Error('Unsupported DEM format'); + try { + const driver = this.getDriver(filePath); + const dataset = await driver.openAsync(fullFilePath, 'r'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metadata = await dataset.getMetadataAsync(); const areaOrPoint = z // eslint-disable-next-line @typescript-eslint/naming-convention - .object({ AREA_OR_POINT: areaOrPointSchema }, { error: 'Could not extract AREA_OR_POINT metadata' }) - .parse(await dataset.getMetadataAsync()).AREA_OR_POINT; + .object({ AREA_OR_POINT: areaOrPointSchema }) + .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded - const dataType = pixelDataTypesSchema.parse(await band.dataTypeAsync); + const bandDataType = await band.dataTypeAsync; + const dataType = pixelDataTypesSchema.parse(bandDataType, { error: () => 'Unsupported band data type' }); - const noDataValue = noDataValueSchema.parse(await band.noDataValueAsync); + const bandNoDataValueAsync = await band.noDataValueAsync; + const noDataValue = noDataValueSchema.parse(bandNoDataValueAsync, { error: () => 'Unsupported band nodata value' }); const srs = await dataset.srsAsync; if (srs === null) throw new Error('Unsupported SRS'); - const { srsId, srsName } = getSrsInfo(srs); + srsIdSchema.parse(srsId, { error: () => 'Unsupported SRS' }); const geoTransform = await dataset.geoTransformAsync; @@ -98,7 +101,7 @@ export class GDALHandler implements FileHandler { private getDriver(filePath: string): Driver { const fileExtension = extname(filePath).slice(1); const supportedDriver = Object.values(this.supportedFormatsMap).find((supportedDriver) => { - const driver = drivers.get(supportedDriver); + const driver = this.gdal.drivers.get(supportedDriver); // eslint-disable-next-line @typescript-eslint/naming-convention const driverMetadata = driver.getMetadata() as { DMD_EXTENSION?: string; DMD_EXTENSIONS?: string }; const { DMD_EXTENSION: extension = '', DMD_EXTENSIONS: extensions = '' } = driverMetadata; @@ -106,6 +109,6 @@ export class GDALHandler implements FileHandler { }); if (supportedDriver === undefined) throw new Error(`Unsupported file format of file: ${filePath}`); - return drivers.get(supportedDriver); + return this.gdal.drivers.get(supportedDriver); } } From ca5f3df254972a7a5a33fa96348fc6d4801653f0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:56:43 +0200 Subject: [PATCH 06/52] fix: rename resolutionDegrees to resolutionDegree --- openapi3.yaml | 8 ++++---- src/common/gdal.ts | 8 ++++---- src/info/fileHandlers/gdal.ts | 4 ++-- src/openapi.d.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openapi3.yaml b/openapi3.yaml index 7038b13..80ad47e 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -385,15 +385,15 @@ components: description: Common properties of regular grids required: - areaOrPoint - - resolutionDegrees + - resolutionDegree - resolutionMeter - srsId - srsName properties: areaOrPoint: $ref: '#/components/schemas/AreaOrPoint' - resolutionDegrees: - $ref: '#/components/schemas/ResolutionDegrees' + resolutionDegree: + $ref: '#/components/schemas/ResolutionDegree' resolutionMeter: $ref: '#/components/schemas/ResolutionMeter' srsId: @@ -506,7 +506,7 @@ components: minLength: 1 minItems: 1 description: List of layer's regions - ResolutionDegrees: + ResolutionDegree: type: number description: DEM resolution in degrees exclusiveMinimum: 0 diff --git a/src/common/gdal.ts b/src/common/gdal.ts index bd5c7e3..6fd62a5 100644 --- a/src/common/gdal.ts +++ b/src/common/gdal.ts @@ -27,7 +27,7 @@ export const getResolutions = ( targetProjectedSrs: SpatialReference | number; } & Pick & PixelInfo -): Pick => { +): Pick => { const { targetGeographicSrs, targetProjectedSrs, maxX, maxY, minX, minY, pixelHeight, pixelWidth, sourceSrs } = options; const resolvedSourceSrs = typeof sourceSrs === 'number' ? SpatialReference.fromEPSG(sourceSrs) : sourceSrs; const resolvedTargetGeographicSrs = typeof targetGeographicSrs === 'number' ? SpatialReference.fromEPSG(targetGeographicSrs) : targetGeographicSrs; @@ -65,9 +65,9 @@ export const getResolutions = ( const resolutions = ( [ - [resolvedSourceSrs.isGeographic(), { resolutionMeter: getReprojectedResolution(resolvedTargetProjectedSrs), resolutionDegrees: pixelHeight }], - [resolvedSourceSrs.isProjected(), { resolutionMeter: pixelHeight, resolutionDegrees: getReprojectedResolution(resolvedTargetGeographicSrs) }], - ] satisfies [boolean, { resolutionMeter: number; resolutionDegrees: number }][] + [resolvedSourceSrs.isGeographic(), { resolutionMeter: getReprojectedResolution(resolvedTargetProjectedSrs), resolutionDegree: pixelHeight }], + [resolvedSourceSrs.isProjected(), { resolutionMeter: pixelHeight, resolutionDegree: getReprojectedResolution(resolvedTargetGeographicSrs) }], + ] satisfies [boolean, { resolutionMeter: number; resolutionDegree: number }][] ).find((value) => value[0])?.[1]; if (resolutions == undefined) { diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index ed8bb3b..d6847f0 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -76,7 +76,7 @@ export class GDALHandler implements FileHandler { const geoTransform = await dataset.geoTransformAsync; - const { resolutionDegrees, resolutionMeter } = getResolutions({ + const { resolutionDegree, resolutionMeter } = getResolutions({ ...dataset.bands.getEnvelope(), ...getPixelInfo({ geoTransform }), targetGeographicSrs: this.defaultGeographicSrs, @@ -88,7 +88,7 @@ export class GDALHandler implements FileHandler { areaOrPoint, dataType, noDataValue, - resolutionDegrees, + resolutionDegree, resolutionMeter, srsId, srsName, diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 0e64889..2e4209a 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -137,7 +137,7 @@ export type components = { /** @description Common properties of regular grids */ InfoCommonRegularGridProperties: { areaOrPoint: components['schemas']['AreaOrPoint']; - resolutionDegrees: components['schemas']['ResolutionDegrees']; + resolutionDegree: components['schemas']['ResolutionDegree']; resolutionMeter: components['schemas']['ResolutionMeter']; srsId: components['schemas']['SrsId']; srsName: components['schemas']['SrsName']; @@ -204,7 +204,7 @@ export type components = { /** @description List of layer's regions */ Region: string[]; /** @description DEM resolution in degrees */ - ResolutionDegrees: number; + ResolutionDegree: number; /** @description DEM resolution in meters */ ResolutionMeter: number; /** @description Projection code as registered by EPSG */ From 3d977210151e69a360060f53304faec701ca3162 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:46:29 +0200 Subject: [PATCH 07/52] refactor: pr comments --- src/dem/controllers/demController.ts | 52 +++++++++++++++++--------- src/dem/models/demManager.ts | 26 ++++++++++++- src/dem/routes/demRouter.ts | 3 ++ src/info/controllers/infoController.ts | 15 +------- src/info/fileHandlers/gdal.ts | 6 ++- src/info/models/infoManager.ts | 11 +++++- 6 files changed, 77 insertions(+), 36 deletions(-) diff --git a/src/dem/controllers/demController.ts b/src/dem/controllers/demController.ts index 75921ac..418cade 100644 --- a/src/dem/controllers/demController.ts +++ b/src/dem/controllers/demController.ts @@ -1,35 +1,53 @@ 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 { inject, injectable } from 'tsyringe'; import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; - +import { SERVICES } from '@src/common/constants'; import { DEMManager } from '../models/demManager'; @injectable() export class DEMController { - private readonly demEditCounter: Counter; - public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(DEMManager) private readonly demManager: DEMManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.demEditCounter = new Counter({ - name: 'edit', - help: 'number of edit requests', - registers: [this.metricsRegistry], - }); - } + @inject(DEMManager) private readonly demManager: DEMManager + ) {} + + public create: TypedRequestHandlers['create'] = (req, res, next) => { + try { + const response = this.demManager.create(req.body); + return res.status(httpStatus.OK).json(response); + } catch (error) { + this.logger.error(error); + next(error); + } + }; + + public delete: TypedRequestHandlers['delete'] = (req, res, next) => { + try { + const response = this.demManager.delete(req.params); + return res.status(httpStatus.OK).json(response); + } catch (error) { + this.logger.error(error); + next(error); + } + }; public edit: TypedRequestHandlers['edit'] = (req, res, next) => { try { - this.demEditCounter.inc(1); const response = this.demManager.edit({ ...req.params, ...req.body }); return res.status(httpStatus.OK).json(response); } catch (error) { - console.error(error); + this.logger.error(error); + next(error); + } + }; + + public editStatus: TypedRequestHandlers['editStatus'] = (req, res, next) => { + try { + this.demManager.editStatus({ ...req.params, ...req.body }); + return res.status(httpStatus.NO_CONTENT).send(); + } catch (error) { + this.logger.error(error); next(error); } }; diff --git a/src/dem/models/demManager.ts b/src/dem/models/demManager.ts index c5a22e4..d26ff84 100644 --- a/src/dem/models/demManager.ts +++ b/src/dem/models/demManager.ts @@ -1,18 +1,40 @@ +import { NotImplementedError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; import type { components, operations } from '@openapi'; import { SERVICES } from '@common/constants'; +export type CreateOptions = components['schemas']['CreateRequestBody']; +export type DeleteOptions = operations['delete']['parameters']['path']; export type EditOptions = components['schemas']['EditRequestBody'] & operations['edit']['parameters']['path']; +export type EditStatusOptions = components['schemas']['EditStatusRequestBody'] & operations['editStatus']['parameters']['path']; export type DemResponse = components['schemas']['DemResponse']; @injectable() export class DEMManager { public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} + public create(options: CreateOptions): DemResponse { + this.logger.info({ msg: 'Create DEM resource', resource: options }); + + throw new NotImplementedError('Not implemented'); + } + + public delete(options: DeleteOptions): DemResponse { + this.logger.info({ msg: 'Delete DEM resource', resource: options }); + + throw new NotImplementedError('Not implemented'); + } + public edit(options: EditOptions): DemResponse { - this.logger.info({ msg: 'editing resource', resource: options }); + this.logger.info({ msg: 'Edit DEM resource', resource: options }); + + throw new NotImplementedError('Not implemented'); + } + + public editStatus(options: EditStatusOptions): void { + this.logger.info({ msg: 'Edit DEM status', resource: options }); - return { jobId: '795bfb61-9c26-4860-aae3-ef071219cdff' }; + throw new NotImplementedError('Not implemented'); } } diff --git a/src/dem/routes/demRouter.ts b/src/dem/routes/demRouter.ts index 621704e..d391b89 100644 --- a/src/dem/routes/demRouter.ts +++ b/src/dem/routes/demRouter.ts @@ -6,7 +6,10 @@ const demRouterFactory: FactoryFunction = (dependencyContainer) => { const router = Router(); const controller = dependencyContainer.resolve(DEMController); + router.post('/:id', controller.edit); + router.delete('/:id', controller.edit); router.patch('/:id', controller.edit); + router.patch('/:id/status', controller.edit); return router; }; diff --git a/src/info/controllers/infoController.ts b/src/info/controllers/infoController.ts index 44a6e92..40a6457 100644 --- a/src/info/controllers/infoController.ts +++ b/src/info/controllers/infoController.ts @@ -1,7 +1,6 @@ import { UnprocessableEntityError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; import httpStatus from 'http-status-codes'; -import { type Registry, Counter } from 'prom-client'; import { inject, injectable } from 'tsyringe'; import { ZodError } from 'zod'; import type { TypedRequestHandlers } from '@openapi'; @@ -10,27 +9,17 @@ import { InfoManager } from '../models/infoManager'; @injectable() export class InfoController { - private readonly infoCounter: Counter; - public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, @inject(InfoManager) private readonly infoManager: InfoManager - ) { - this.infoCounter = new Counter({ - name: 'info', - help: 'number of info requests', - registers: [this.metricsRegistry], - }); - } + ) {} public info: TypedRequestHandlers['info'] = async (req, res, next) => { try { - this.infoCounter.inc(1); const response = await this.infoManager.info(req.body); return res.status(httpStatus.OK).json(response); } catch (error) { - this.logger.error(error); + this.logger.error({ err: error }); if (error instanceof ZodError) { return next(new UnprocessableEntityError(error.issues[0]?.message ?? 'validation error')); } diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index d6847f0..8ca8d77 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -32,21 +32,23 @@ export class GDALHandler implements FileHandler { public supports(filePath: string): boolean { try { this.getDriver(filePath); + this.logger.debug({ msg: `Handler '${GDALHandler.name}' supports the requested file` }); return true; } catch (error) { - this.logger.debug(error); + this.logger.debug({ msg: `Handler '${GDALHandler.name}' cannot handle the requested file, caused by an error: ${JSON.stringify(error)}` }); return false; } } public async getInfo(filePath: string): Promise { - this.logger.info(`Getting info for ${filePath}`); + this.logger.info({ msg: `Getting info for ${filePath}` }); let dataset: Dataset | undefined; const fullFilePath = join(this.sourceDir, filePath); try { await access(fullFilePath, constants.F_OK); } catch (error) { + this.logger.error({ msg: `Cannot find file: ${fullFilePath}`, err: error }); throw new NotFoundError(`Cannot find file: ${fullFilePath}. got error: ${JSON.stringify(error)}`); } diff --git a/src/info/models/infoManager.ts b/src/info/models/infoManager.ts index 183641d..039322a 100644 --- a/src/info/models/infoManager.ts +++ b/src/info/models/infoManager.ts @@ -1,5 +1,7 @@ -import { injectable, injectAll } from 'tsyringe'; import { UnprocessableEntityError } from '@map-colonies/error-types'; +import type { Logger } from '@map-colonies/js-logger'; +import { inject, injectable, injectAll } from 'tsyringe'; +import { SERVICES } from '@src/common/constants'; import { components } from '@src/openapi'; export type InfoOptions = components['schemas']['InfoRequestBody']; @@ -11,12 +13,17 @@ export interface FileHandler { @injectable() export class InfoManager { - public constructor(@injectAll('FileHandler') private readonly fileHandlers: FileHandler[]) {} + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @injectAll('FileHandler') private readonly fileHandlers: FileHandler[] + ) {} public async info(options: InfoOptions): Promise { const { demFilePath } = options; + this.logger.debug({ msg: `Handling info request`, resource: options }); const response = await this.process(demFilePath); + this.logger.debug({ msg: `Info response`, response }); return response; } From 8da9e91adfb15904a2620b2e5175a44772ae89d0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:17:39 +0200 Subject: [PATCH 08/52] refactor: stricter validation on srs name --- src/common/schemas.ts | 1 + src/info/fileHandlers/gdal.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/common/schemas.ts b/src/common/schemas.ts index c024387..29895c1 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -11,6 +11,7 @@ export const noDataValueSchema = z.union([z.number(), z.nan()]).transform((value export const pixelDataTypesSchema = z.union([z.literal(GEOTIFF_DATA_TYPES)]); // add additional data types to union for each supported format export const pixelSchema = z.number().positive(); export const srsIdSchema = z.literal(supportedSrsIds); +export const srsNameSchema = z.string().min(1); export const epsgRecordSchema = z.object({ code: z.string(), diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 8ca8d77..c0c6e3f 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -8,7 +8,7 @@ import type { Logger } from '@map-colonies/js-logger'; import type { ConfigType } from '@src/common/config'; import { SERVICES } from '@src/common/constants'; import { GDAL_ASYNC, getPixelInfo, getResolutions, getSrsInfo, type GdalAsync } from '@src/common/gdal'; -import { areaOrPointSchema, noDataValueSchema, pixelDataTypesSchema, srsIdSchema } from '@src/common/schemas'; +import { areaOrPointSchema, noDataValueSchema, pixelDataTypesSchema, srsIdSchema, srsNameSchema } from '@src/common/schemas'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; @injectable() @@ -73,8 +73,8 @@ export class GDALHandler implements FileHandler { const srs = await dataset.srsAsync; if (srs === null) throw new Error('Unsupported SRS'); - const { srsId, srsName } = getSrsInfo(srs); - srsIdSchema.parse(srsId, { error: () => 'Unsupported SRS' }); + const srsInfo = getSrsInfo(srs); + const { srsId, srsName } = z.strictObject({ srsId: srsIdSchema, srsName: srsNameSchema }).parse(srsInfo, { error: () => 'Unsupported SRS' }); const geoTransform = await dataset.geoTransformAsync; From 985298d6061ef9fa798eed279d58d9de2599ebfd Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:18:31 +0200 Subject: [PATCH 09/52] refactor: stricter validations on objects --- src/common/schemas.ts | 2 +- src/info/fileHandlers/gdal.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 29895c1..f4a0433 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -13,7 +13,7 @@ export const pixelSchema = z.number().positive(); export const srsIdSchema = z.literal(supportedSrsIds); export const srsNameSchema = z.string().min(1); -export const epsgRecordSchema = z.object({ +export const epsgRecordSchema = z.strictObject({ code: z.string(), kind: z.string(), name: z.string(), diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index c0c6e3f..2f3fae2 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -60,7 +60,7 @@ export class GDALHandler implements FileHandler { const metadata = await dataset.getMetadataAsync(); const areaOrPoint = z // eslint-disable-next-line @typescript-eslint/naming-convention - .object({ AREA_OR_POINT: areaOrPointSchema }) + .strictObject({ AREA_OR_POINT: areaOrPointSchema }) .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded From 0bad12cbc528786903da6d2070946da2431eebb0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:19:06 +0200 Subject: [PATCH 10/52] refactor: use static method to get srs --- src/info/fileHandlers/gdal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 2f3fae2..3d82ce5 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -1,6 +1,6 @@ import { access, constants } from 'node:fs/promises'; import { extname, join } from 'node:path'; -import type { Dataset, Driver, SpatialReference } from 'gdal-async'; +import { type Dataset, type Driver, SpatialReference } from 'gdal-async'; import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; import { NotFoundError } from '@map-colonies/error-types'; @@ -23,8 +23,8 @@ export class GDALHandler implements FileHandler { @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(GDAL_ASYNC) private readonly gdal: GdalAsync ) { - this.defaultGeographicSrs = this.gdal.SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId') as unknown as number); - this.defaultProjectedSrs = this.gdal.SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId') as unknown as number); + this.defaultGeographicSrs = SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId') as unknown as number); + this.defaultProjectedSrs = SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId') as unknown as number); this.supportedFormatsMap = this.config.get('application.supportedFormatsMap') as unknown as Record; this.sourceDir = this.config.get('storageExplorer.sourceDir') as unknown as string; } From 0a578d377c5de42fe175cc15035504795a0af3c6 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:37:48 +0200 Subject: [PATCH 11/52] fix: dataset var shadowing --- src/info/fileHandlers/gdal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 3d82ce5..7b3b4eb 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -54,7 +54,7 @@ export class GDALHandler implements FileHandler { try { const driver = this.getDriver(filePath); - const dataset = await driver.openAsync(fullFilePath, 'r'); + dataset = await driver.openAsync(fullFilePath, 'r'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const metadata = await dataset.getMetadataAsync(); From 5f7c9fef8c0a2420ba09bce3623e1f410bf78cd9 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:33:23 +0200 Subject: [PATCH 12/52] refactor: revert to support additional metadata records --- src/info/fileHandlers/gdal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 7b3b4eb..05106a9 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -60,7 +60,7 @@ export class GDALHandler implements FileHandler { const metadata = await dataset.getMetadataAsync(); const areaOrPoint = z // eslint-disable-next-line @typescript-eslint/naming-convention - .strictObject({ AREA_OR_POINT: areaOrPointSchema }) + .object({ AREA_OR_POINT: areaOrPointSchema }) .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded From 75d8e5f4052682cf8df9bd0ecd0ee5abe4a86287 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:58:33 +0200 Subject: [PATCH 13/52] feat: info validations (MAPCO-10133) (#23) * feat: additional validations * chore: pr comment restructure config * build: update to use temp config schema * feat: use config schema * chore: sync with boilerplate changes related to otel logging --- config/default.json | 33 ++- package-lock.json | 191 +++++++++--------- package.json | 6 +- src/common/config.ts | 6 +- src/common/gdal.ts | 5 +- src/common/schemas.ts | 12 +- src/containerConfig.ts | 2 +- src/info/fileHandlers/gdal.ts | 39 +++- .../anotherResourceName.spec.ts | 2 +- tests/integration/docs/docs.spec.ts | 2 +- .../resourceName/resourceName.spec.ts | 2 +- .../models/resourceNameModel.spec.ts | 4 +- 12 files changed, 179 insertions(+), 125 deletions(-) diff --git a/config/default.json b/config/default.json index 29f30fc..33487ec 100644 --- a/config/default.json +++ b/config/default.json @@ -31,22 +31,33 @@ } }, "storageExplorer": { - "sourceDir": "" + "sourceDir": "", + "displayNameDir": "layerSources", + "validFileExtensions": ["tif"] + }, + "jobnik": { + "baseUrl": "http://localhost:3000" }, "application": { + "validation": { + "blockSize": 256, + "compression": "LZW", + "resolutionDegree": { "min": 0.00000009060870470168063430786, "max": 0.08982 }, + "resolutionMeter": { "min": 0.01, "max": 10000 }, + "supportedSrsIds": [ + 4326, 32601, 32602, 32603, 32604, 32605, 32606, 32607, 32608, 32609, 32610, 32611, 32612, 32613, 32614, 32615, 32616, 32617, 32618, 32619, + 32620, 32621, 32622, 32623, 32624, 32625, 32626, 32627, 32628, 32629, 32630, 32631, 32632, 32633, 32634, 32635, 32636, 32637, 32638, 32639, + 32640, 32641, 32642, 32643, 32644, 32645, 32646, 32647, 32648, 32649, 32650, 32651, 32652, 32653, 32654, 32655, 32656, 32657, 32658, 32659, + 32660, 32701, 32702, 32703, 32704, 32705, 32706, 32707, 32708, 32709, 32710, 32711, 32712, 32713, 32714, 32715, 32716, 32717, 32718, 32719, + 32720, 32721, 32722, 32723, 32724, 32725, 32726, 32727, 32728, 32729, 32730, 32731, 32732, 32733, 32734, 32735, 32736, 32737, 32738, 32739, + 32740, 32741, 32742, 32743, 32744, 32745, 32746, 32747, 32748, 32749, 32750, 32751, 32752, 32753, 32754, 32755, 32756, 32757, 32758, 32759, + 32760 + ] + }, "defaultGeographicSrsId": 4326, "defaultProjectedSrsId": 3395, "supportedFormatsMap": { "geotiff": "gtiff" - }, - "supportedSrsIds": [ - 4326, 32601, 32602, 32603, 32604, 32605, 32606, 32607, 32608, 32609, 32610, 32611, 32612, 32613, 32614, 32615, 32616, 32617, 32618, 32619, - 32620, 32621, 32622, 32623, 32624, 32625, 32626, 32627, 32628, 32629, 32630, 32631, 32632, 32633, 32634, 32635, 32636, 32637, 32638, 32639, - 32640, 32641, 32642, 32643, 32644, 32645, 32646, 32647, 32648, 32649, 32650, 32651, 32652, 32653, 32654, 32655, 32656, 32657, 32658, 32659, - 32660, 32701, 32702, 32703, 32704, 32705, 32706, 32707, 32708, 32709, 32710, 32711, 32712, 32713, 32714, 32715, 32716, 32717, 32718, 32719, - 32720, 32721, 32722, 32723, 32724, 32725, 32726, 32727, 32728, 32729, 32730, 32731, 32732, 32733, 32734, 32735, 32736, 32737, 32738, 32739, - 32740, 32741, 32742, 32743, 32744, 32745, 32746, 32747, 32748, 32749, 32750, 32751, 32752, 32753, 32754, 32755, 32756, 32757, 32758, 32759, - 32760 - ] + } } } diff --git a/package-lock.json b/package-lock.json index 4aee7a9..9e90944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,12 @@ "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/error-types": "^1.3.1", - "@map-colonies/express-access-log-middleware": "^4.0.0", - "@map-colonies/js-logger": "^4.0.0", + "@map-colonies/express-access-log-middleware": "^4.1.0", + "@map-colonies/js-logger": "^5.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", "@map-colonies/read-pkg": "^1.0.0", - "@map-colonies/schemas": "^1.17.0", + "@map-colonies/schemas": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-6939db3448a04daf1d79ab5f8ecfadeab67237c8.tgz", "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^1.0.0", "@opentelemetry/api": "^1.9.0", @@ -2412,9 +2412,9 @@ } }, "node_modules/@map-colonies/express-access-log-middleware": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@map-colonies/express-access-log-middleware/-/express-access-log-middleware-4.0.0.tgz", - "integrity": "sha512-29tnRz5JGRGOMGFjzkqAOuV3rpjIAXkKT7hdHLejFO3vLfDNulpJk1ST0D/uMtX03kOM6/jhscYAvdbVAtKGYQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@map-colonies/express-access-log-middleware/-/express-access-log-middleware-4.1.0.tgz", + "integrity": "sha512-qtuKfNOE9WmEkLD175SI26xO+X8mloxlTE8JrR/gypaqIa8+XdGYNynDKrQK+83t5vMfToMLXhTzmGtT8HgXAg==", "dependencies": { "@opentelemetry/semantic-conventions": "^1.38.0", "http-status-codes": "^2.3.0", @@ -2434,11 +2434,14 @@ } }, "node_modules/@map-colonies/js-logger": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@map-colonies/js-logger/-/js-logger-4.0.0.tgz", - "integrity": "sha512-MjnHPXb5rWYZ7GAqkxn8X/y3AovazTCng5uayq1YoGb90dse5zjt5yfLqULd2WNNmT74ytRM7wlSheD9VjFkKA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@map-colonies/js-logger/-/js-logger-5.0.0.tgz", + "integrity": "sha512-kPEJwJo+bzZprq6Q91+ozJQpP2sPvgjfUF2BMxuXQXXmfY+USU2XcZJdfU4dowV5E275XhFTG9nQT2gJZvdQbg==", "dependencies": { - "@map-colonies/read-pkg": "^1.0.0", + "@map-colonies/read-pkg": "^2.0.0", + "@opentelemetry/resource-detector-container": "^0.8.4", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/semantic-conventions": "^1.40.0", "pino": "^10.1.0", "pino-caller": "^4.0.0", "pino-opentelemetry-transport": "^2.0.0", @@ -2448,6 +2451,41 @@ "node": ">=24" } }, + "node_modules/@map-colonies/js-logger/node_modules/@map-colonies/read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@map-colonies/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha512-rMASq2JeRuaWYGqPN68+FwQa4ZSVJBFpttRypUX6RflZK0svZNNCQSWleqz9iJGw5oVDRILNpAb4HRHm/7rszA==", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@map-colonies/js-logger/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@map-colonies/js-logger/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@map-colonies/openapi-express-viewer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@map-colonies/openapi-express-viewer/-/openapi-express-viewer-5.0.0.tgz", @@ -2655,9 +2693,9 @@ } }, "node_modules/@map-colonies/schemas": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@map-colonies/schemas/-/schemas-1.17.0.tgz", - "integrity": "sha512-lfp27EkpXM2zlHDHtVNkjScbRZDjcn4HHfYtl8tD2lOPMPoR55wTRBsh5OhUllbLbv7O2+youA1w3Ny+iUlW9w==", + "version": "1.18.0", + "resolved": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-6939db3448a04daf1d79ab5f8ecfadeab67237c8.tgz", + "integrity": "sha512-T1MDhznSLhSxpZhyHUj477vZhYFEnykmaSIP99ns5piO1pOKobOdlpG2Xshu+Tvarsb4dX1xkv2P7kYI7owe+A==", "license": "MIT", "peer": true }, @@ -2812,22 +2850,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@map-colonies/tracing/node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@map-colonies/tracing/node_modules/@opentelemetry/exporter-logs-otlp-grpc": { "version": "0.208.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.208.0.tgz", @@ -4346,22 +4368,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@map-colonies/tracing/node_modules/@opentelemetry/resource-detector-container": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.8.2.tgz", - "integrity": "sha512-8oT0tUO+QS8Tz7u0YQZKoZOpS+LIgS4FnLjWSCPyXPOgKuOeOK5Xe0sd0ulkAGPN4yKr7toNYNVkBeaC/HlmFQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, "node_modules/@map-colonies/tracing/node_modules/@opentelemetry/resource-detector-gcp": { "version": "0.44.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.44.0.tgz", @@ -4379,22 +4385,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@map-colonies/tracing/node_modules/@opentelemetry/resources": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", - "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@map-colonies/tracing/node_modules/@opentelemetry/sdk-logs": { "version": "0.208.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", @@ -4936,6 +4926,22 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/instrumentation-openai": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-openai/-/instrumentation-openai-0.7.1.tgz", @@ -5174,6 +5180,38 @@ "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, + "node_modules/@opentelemetry/resource-detector-container": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.8.4.tgz", + "integrity": "sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.34.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", @@ -14143,37 +14181,6 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/otlp-logger/node_modules/@opentelemetry/resources": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", - "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/otlp-logger/node_modules/@opentelemetry/sdk-logs": { "version": "0.206.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.206.0.tgz", diff --git a/package.json b/package.json index 21639db..3d711d6 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "@map-colonies/config": "^4.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/error-types": "^1.3.1", - "@map-colonies/express-access-log-middleware": "^4.0.0", - "@map-colonies/js-logger": "^4.0.0", + "@map-colonies/express-access-log-middleware": "^4.1.0", + "@map-colonies/js-logger": "^5.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", "@map-colonies/read-pkg": "^1.0.0", - "@map-colonies/schemas": "^1.17.0", + "@map-colonies/schemas": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-6939db3448a04daf1d79ab5f8ecfadeab67237c8.tgz", "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^1.0.0", "@opentelemetry/api": "^1.9.0", diff --git a/src/common/config.ts b/src/common/config.ts index 7b1a941..5daa998 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -1,8 +1,8 @@ import { type ConfigInstance, config } from '@map-colonies/config'; -import { commonBoilerplateV2, type commonBoilerplateV2Type } from '@map-colonies/schemas'; +import { demDemGatewayV1, type demDemGatewayV1Type } from '@map-colonies/schemas'; // Choose here the type of the config instance and import this type from the entire application -type ConfigType = ConfigInstance; +type ConfigType = ConfigInstance; let configInstance: ConfigType | undefined; @@ -13,7 +13,7 @@ let configInstance: ConfigType | undefined; */ async function initConfig(offlineMode?: boolean): Promise { configInstance = await config({ - schema: commonBoilerplateV2, + schema: demDemGatewayV1, offlineMode, }); } diff --git a/src/common/gdal.ts b/src/common/gdal.ts index 6fd62a5..a7fdeb0 100644 --- a/src/common/gdal.ts +++ b/src/common/gdal.ts @@ -3,6 +3,7 @@ import * as gdalAsync from 'gdal-async'; import { z } from 'zod'; import type { InfoResponse } from '@src/info/models/infoManager'; import { EPSG_DATA_RECORDS } from './epsg'; +import { resolutionDegreeSchema, resolutionMeterSchema } from './schemas'; interface PixelInfo { pixelWidth: number; @@ -74,7 +75,9 @@ export const getResolutions = ( throw new Error('Unsupported SRS type'); } - return resolutions; + const response = z.strictObject({ resolutionMeter: resolutionMeterSchema, resolutionDegree: resolutionDegreeSchema }).parse(resolutions); + + return response; }; export const getSrsName = (srsId: number): string => { diff --git a/src/common/schemas.ts b/src/common/schemas.ts index f4a0433..79bb419 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -4,12 +4,22 @@ import { GEOTIFF_DATA_TYPES } from './constants'; const config = getConfig(); -const supportedSrsIds = config.get('application.supportedSrsIds') as unknown as number[]; // TODO: include application.supportedSrsIds in service schema +const blockSize = config.get('application.validation.blockSize'); +const compression = config.get('application.validation.compression'); +const resolutionDegree = config.get('application.validation.resolutionDegree'); +const resolutionMeter = config.get('application.validation.resolutionMeter'); +const supportedSrsIds = config.get('application.validation.supportedSrsIds'); export const areaOrPointSchema = z.literal(['Area', 'Point']); +export const blockSizeSchema = z.object({ x: z.literal(blockSize), y: z.literal(blockSize) }); +export const compressionSchema = z.literal(compression); +export const layoutSchema = z.literal('COG'); export const noDataValueSchema = z.union([z.number(), z.nan()]).transform((value) => (Number.isNaN(value) ? 'NaN' : value)); +export const overviewsCount = z.number().positive(); export const pixelDataTypesSchema = z.union([z.literal(GEOTIFF_DATA_TYPES)]); // add additional data types to union for each supported format export const pixelSchema = z.number().positive(); +export const resolutionDegreeSchema = z.number().min(resolutionDegree.min).max(resolutionDegree.max); +export const resolutionMeterSchema = z.number().min(resolutionMeter.min).max(resolutionMeter.max); export const srsIdSchema = z.literal(supportedSrsIds); export const srsNameSchema = z.string().min(1); diff --git a/src/containerConfig.ts b/src/containerConfig.ts index a6e2596..17e2841 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -23,7 +23,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise const loggerConfig = configInstance.get('telemetry.logger'); - const logger = jsLogger({ ...loggerConfig, prettyPrint: loggerConfig.prettyPrint, mixin: getOtelMixin() }); + const logger = await jsLogger({ ...loggerConfig, prettyPrint: loggerConfig.prettyPrint, mixin: getOtelMixin() }); const tracer = trace.getTracer(SERVICE_NAME); const metricsRegistry = new Registry(); diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 05106a9..4b05c1d 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -1,14 +1,24 @@ import { access, constants } from 'node:fs/promises'; import { extname, join } from 'node:path'; -import { type Dataset, type Driver, SpatialReference } from 'gdal-async'; -import { inject, injectable } from 'tsyringe'; -import { z } from 'zod'; import { NotFoundError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; +import { SpatialReference, type Dataset, type Driver } from 'gdal-async'; +import { inject, injectable } from 'tsyringe'; +import { z } from 'zod'; import type { ConfigType } from '@src/common/config'; import { SERVICES } from '@src/common/constants'; import { GDAL_ASYNC, getPixelInfo, getResolutions, getSrsInfo, type GdalAsync } from '@src/common/gdal'; -import { areaOrPointSchema, noDataValueSchema, pixelDataTypesSchema, srsIdSchema, srsNameSchema } from '@src/common/schemas'; +import { + areaOrPointSchema, + blockSizeSchema, + compressionSchema, + layoutSchema, + noDataValueSchema, + overviewsCount, + pixelDataTypesSchema, + srsIdSchema, + srsNameSchema, +} from '@src/common/schemas'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; @injectable() @@ -23,10 +33,10 @@ export class GDALHandler implements FileHandler { @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(GDAL_ASYNC) private readonly gdal: GdalAsync ) { - this.defaultGeographicSrs = SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId') as unknown as number); - this.defaultProjectedSrs = SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId') as unknown as number); - this.supportedFormatsMap = this.config.get('application.supportedFormatsMap') as unknown as Record; - this.sourceDir = this.config.get('storageExplorer.sourceDir') as unknown as string; + this.defaultGeographicSrs = SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId')); + this.defaultProjectedSrs = SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId')); + this.supportedFormatsMap = this.config.get('application.supportedFormatsMap'); + this.sourceDir = this.config.get('storageExplorer.sourceDir'); } public supports(filePath: string): boolean { @@ -63,8 +73,21 @@ export class GDALHandler implements FileHandler { .object({ AREA_OR_POINT: areaOrPointSchema }) .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metadataImageStructure = await dataset.getMetadataAsync('IMAGE_STRUCTURE'); + void z + // eslint-disable-next-line @typescript-eslint/naming-convention + .object({ LAYOUT: layoutSchema, COMPRESSION: compressionSchema }) + .parse(metadataImageStructure, { error: () => 'Could not extract LAYOUT metadata' }).LAYOUT; + const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded + const bandBlockSize = await band.blockSizeAsync; + blockSizeSchema.parse(bandBlockSize); + + const bandOverviewsCount = await band.overviews.countAsync(); + overviewsCount.parse(bandOverviewsCount); + const bandDataType = await band.dataTypeAsync; const dataType = pixelDataTypesSchema.parse(bandDataType, { error: () => 'Unsupported band data type' }); diff --git a/tests/integration/anotherResource/anotherResourceName.spec.ts b/tests/integration/anotherResource/anotherResourceName.spec.ts index 4755f83..23ada25 100644 --- a/tests/integration/anotherResource/anotherResourceName.spec.ts +++ b/tests/integration/anotherResource/anotherResourceName.spec.ts @@ -18,7 +18,7 @@ describe('anotherResourceName', function () { beforeEach(async function () { const [app] = await getApp({ override: [ - { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, ], useChild: true, diff --git a/tests/integration/docs/docs.spec.ts b/tests/integration/docs/docs.spec.ts index 95e11b0..dce1eeb 100644 --- a/tests/integration/docs/docs.spec.ts +++ b/tests/integration/docs/docs.spec.ts @@ -17,7 +17,7 @@ describe('docs', function () { beforeEach(async function () { const [app] = await getApp({ override: [ - { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, ], useChild: true, diff --git a/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts index c1548dd..628f854 100644 --- a/tests/integration/resourceName/resourceName.spec.ts +++ b/tests/integration/resourceName/resourceName.spec.ts @@ -18,7 +18,7 @@ describe('resourceName', function () { beforeEach(async function () { const [app] = await getApp({ override: [ - { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, ], useChild: true, diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts index 17ed6f6..d2fce9e 100644 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ b/tests/unit/resourceName/models/resourceNameModel.spec.ts @@ -5,8 +5,8 @@ import { ResourceNameManager } from '@src/resourceName/models/resourceNameManage let resourceNameManager: ResourceNameManager; describe('ResourceNameManager', () => { - beforeEach(function () { - resourceNameManager = new ResourceNameManager(jsLogger({ enabled: false })); + beforeEach(async function () { + resourceNameManager = new DEMManager(await jsLogger({ enabled: false })); }); describe('#getResource', () => { From f7f8a5ce207adfaa96b4d0a9b7f7b7219d3a418c Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:03:20 +0200 Subject: [PATCH 14/52] build: move epsg-index to dependencies --- package-lock.json | 3 +-- package.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9e90944..e59d3a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@map-colonies/tracing-utils": "^1.0.0", "@opentelemetry/api": "^1.9.0", "compression": "^1.8.0", + "epsg-index": "^2.0.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", "gdal-async": "^3.12.2", @@ -55,7 +56,6 @@ "ajv-formats": "^3.0.1", "copyfiles": "^2.4.1", "cross-env": "^10.1.0", - "epsg-index": "^2.0.0", "eslint": "^9.39.2", "eslint-plugin-jest": "^28.11.0", "husky": "^9.1.7", @@ -9250,7 +9250,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/epsg-index/-/epsg-index-2.0.0.tgz", "integrity": "sha512-JFRqtXMmxEO/BWbXZuLNJuWS44vbwDPQkfycnISnfm/I8/OLlAqwbYRImgAV9ulr63p/hyW2YIFT9jizHeDzvQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 3d711d6..fc0e148 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@map-colonies/tracing-utils": "^1.0.0", "@opentelemetry/api": "^1.9.0", "compression": "^1.8.0", + "epsg-index": "^2.0.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", "gdal-async": "^3.12.2", @@ -77,7 +78,6 @@ "ajv-formats": "^3.0.1", "copyfiles": "^2.4.1", "cross-env": "^10.1.0", - "epsg-index": "^2.0.0", "eslint": "^9.39.2", "eslint-plugin-jest": "^28.11.0", "husky": "^9.1.7", From 6c7c11ac44b499840250d4d62598b562329d24f2 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:31:39 +0200 Subject: [PATCH 15/52] refactor: pr comment improved log handler --- src/common/dependencyRegistration.ts | 46 ++++++++++----- src/common/logger.ts | 80 ++++++++++++++++++++++++++ src/containerConfig.ts | 22 ++++--- src/dem/routes/demRouter.ts | 5 +- src/info/controllers/infoController.ts | 6 +- src/serverBuilder.ts | 11 +++- 6 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 src/common/logger.ts diff --git a/src/common/dependencyRegistration.ts b/src/common/dependencyRegistration.ts index a591ff9..97190d0 100644 --- a/src/common/dependencyRegistration.ts +++ b/src/common/dependencyRegistration.ts @@ -1,27 +1,45 @@ -import { ClassProvider, container as defaultContainer, FactoryProvider, InjectionToken, ValueProvider } from 'tsyringe'; -import { constructor, DependencyContainer } from 'tsyringe/dist/typings/types'; +import { + container as defaultContainer, + type ClassProvider, + type FactoryProvider, + type InjectionToken, + type ValueProvider, + type Provider, +} from 'tsyringe'; +import { type DependencyContainer, constructor } from 'tsyringe/dist/typings/types'; + +interface CreateAsyncProvider> { + useAsync: (dependencyContainer: DependencyContainer) => Promise; +} + +async function getProvider(injectionObj: InjectionObject, container: DependencyContainer): Promise> { + if ('useAsync' in injectionObj.provider) { + const provider = await injectionObj.provider.useAsync(container); + return provider; + } else { + return injectionObj.provider; + } +} export type Providers = ValueProvider | FactoryProvider | ClassProvider | constructor; export interface InjectionObject { token: InjectionToken; - provider: Providers; + provider: Providers | CreateAsyncProvider>; } -export const registerDependencies = ( +export const registerDependencies = async ( dependencies: InjectionObject[], override?: InjectionObject[], useChild = false -): DependencyContainer => { +): Promise => { const container = useChild ? defaultContainer.createChildContainer() : defaultContainer; - dependencies.forEach((injectionObj) => { - const inject = override?.find((overrideObj) => overrideObj.token === injectionObj.token) === undefined; - if (inject) { - container.register(injectionObj.token, injectionObj.provider as constructor); - } - }); - override?.forEach((injectionObj) => { - container.register(injectionObj.token, injectionObj.provider as constructor); - }); + + for (const injectionObj of dependencies) { + const inject = override?.find((overrideObj) => overrideObj.token === injectionObj.token) ?? injectionObj; + const provider = await getProvider(inject, container); + container.register(injectionObj.token, provider as constructor); + } + return container; }; diff --git a/src/common/logger.ts b/src/common/logger.ts new file mode 100644 index 0000000..1ce0248 --- /dev/null +++ b/src/common/logger.ts @@ -0,0 +1,80 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { type Logger, jsLogger } from '@map-colonies/js-logger'; +import { getOtelMixin } from '@map-colonies/tracing-utils'; +import type { AttributeValue, Attributes } from '@opentelemetry/api'; +import * as api from '@opentelemetry/api'; +import type { NextFunction, Request, Response } from 'express'; +import { get } from 'lodash'; +import type { DependencyContainer } from 'tsyringe'; +import type { ConfigType } from './config'; +import { SERVICES } from './constants'; + +const logContext = new AsyncLocalStorage(); + +export function addOperationIdToLog(req: IncomingMessage, res: ServerResponse, loggableObject: Record): unknown { + const operationId = get(req, 'openapi.schema.operationId') as string | undefined; + if (operationId !== undefined) { + loggableObject['operationId'] = operationId; + } + + const store = logContext.getStore(); + const span = api.trace.getActiveSpan(); + + if (store) { + span?.setAttributes(store); + } + + return loggableObject; +} + +export function enrichLogContext(values: Attributes, addToCurrentTrace = false): void { + const store = logContext.getStore(); + if (store) { + Object.assign(store, values); + } + + if (addToCurrentTrace) { + const span = api.trace.getActiveSpan(); + span?.setAttributes(values); + } +} + +export function getLogContext(): Attributes | undefined { + return structuredClone(logContext.getStore()); +} + +export async function loggerFactory(container: DependencyContainer): Promise { + const config = container.resolve(SERVICES.CONFIG); + const loggerConfig = config.get('telemetry.logger'); + + const logger = await jsLogger({ + ...loggerConfig, + mixin: (mergeObj, level) => { + const otelMixin = getOtelMixin(); + const store = logContext.getStore(); + + return { ...otelMixin(mergeObj, level), ...store }; + }, + }); + + return logger; +} + +export function logContextInjectionMiddleware(req: Request, res: Response, next: NextFunction): void { + logContext.run({}, () => { + next(); + }); +} + +export function logEnrichmentParamMiddlewareFactory( + logEntry: string +): (req: Request, res: Response, next: NextFunction, paramValue: AttributeValue) => void { + return function (req: Request, res: Response, next: NextFunction, paramValue: AttributeValue): void { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (paramValue) { + enrichLogContext({ [logEntry]: paramValue }); + } + next(); + }; +} diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 17e2841..94b2f8c 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -1,14 +1,14 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { getOtelMixin } from '@map-colonies/tracing-utils'; +import type { Logger } from '@map-colonies/js-logger'; import { trace } from '@opentelemetry/api'; import * as gdalAsync from 'gdal-async'; import { Registry } from 'prom-client'; -import { DependencyContainer } from 'tsyringe/dist/typings/types'; +import type { DependencyContainer } from 'tsyringe/dist/typings/types'; import { getConfig } from '@common/config'; import { SERVICES, SERVICE_NAME } from '@common/constants'; -import { InjectionObject, registerDependencies } from '@common/dependencyRegistration'; +import { InjectionObject, registerDependencies, type Providers } from '@common/dependencyRegistration'; import { GDAL_ASYNC } from '@common/gdal'; import { getTracing } from '@common/tracing'; +import { loggerFactory } from './common/logger'; import { DEM_ROUTER_SYMBOL, demRouterFactory } from './dem/routes/demRouter'; import { GDALHandler } from './info/fileHandlers/gdal'; import { INFO_ROUTER_SYMBOL, infoRouterFactory } from './info/routes/infoRouter'; @@ -21,17 +21,21 @@ export interface RegisterOptions { export const registerExternalValues = async (options?: RegisterOptions): Promise => { const configInstance = getConfig(); - const loggerConfig = configInstance.get('telemetry.logger'); - - const logger = await jsLogger({ ...loggerConfig, prettyPrint: loggerConfig.prettyPrint, mixin: getOtelMixin() }); - const tracer = trace.getTracer(SERVICE_NAME); const metricsRegistry = new Registry(); configInstance.initializeMetrics(metricsRegistry); const dependencies: InjectionObject[] = [ { token: SERVICES.CONFIG, provider: { useValue: configInstance } }, - { token: SERVICES.LOGGER, provider: { useValue: logger } }, + { + token: SERVICES.LOGGER, + provider: { + useAsync: async (dependencyContainer: DependencyContainer): Promise> => { + const logger = await loggerFactory(dependencyContainer); + return { useValue: logger }; + }, + }, + }, { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, { token: DEM_ROUTER_SYMBOL, provider: { useFactory: demRouterFactory } }, diff --git a/src/dem/routes/demRouter.ts b/src/dem/routes/demRouter.ts index d391b89..6114e6d 100644 --- a/src/dem/routes/demRouter.ts +++ b/src/dem/routes/demRouter.ts @@ -1,11 +1,14 @@ import { Router } from 'express'; -import { FactoryFunction } from 'tsyringe'; +import type { FactoryFunction } from 'tsyringe'; +import { logEnrichmentParamMiddlewareFactory } from '@src/common/logger'; import { DEMController } from '../controllers/demController'; const demRouterFactory: FactoryFunction = (dependencyContainer) => { const router = Router(); const controller = dependencyContainer.resolve(DEMController); + router.param('id', logEnrichmentParamMiddlewareFactory('id')); + router.post('/:id', controller.edit); router.delete('/:id', controller.edit); router.patch('/:id', controller.edit); diff --git a/src/info/controllers/infoController.ts b/src/info/controllers/infoController.ts index 40a6457..e6d3877 100644 --- a/src/info/controllers/infoController.ts +++ b/src/info/controllers/infoController.ts @@ -1,10 +1,11 @@ -import { UnprocessableEntityError } from '@map-colonies/error-types'; -import type { Logger } from '@map-colonies/js-logger'; import httpStatus from 'http-status-codes'; import { inject, injectable } from 'tsyringe'; import { ZodError } from 'zod'; +import { UnprocessableEntityError } from '@map-colonies/error-types'; +import type { Logger } from '@map-colonies/js-logger'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; +import { enrichLogContext } from '@src/common/logger'; import { InfoManager } from '../models/infoManager'; @injectable() @@ -16,6 +17,7 @@ export class InfoController { public info: TypedRequestHandlers['info'] = async (req, res, next) => { try { + enrichLogContext({ demFilePath: req.body.demFilePath }); const response = await this.infoManager.info(req.body); return res.status(httpStatus.OK).json(response); } catch (error) { diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 7e7d377..e1552e8 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -11,6 +11,7 @@ import { Registry } from 'prom-client'; import { inject, injectable } from 'tsyringe'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; +import { addOperationIdToLog, logContextInjectionMiddleware } from '@common/logger'; import { DEM_ROUTER_SYMBOL } from './dem/routes/demRouter'; import { INFO_ROUTER_SYMBOL } from './info/routes/infoRouter'; @@ -52,8 +53,16 @@ export class ServerBuilder { } private registerPreRoutesMiddleware(): void { + this.serverInstance.use(logContextInjectionMiddleware); this.serverInstance.use(collectMetricsExpressMiddleware({ registry: this.metricsRegistry })); - this.serverInstance.use(httpLogger({ logger: this.logger, ignorePaths: ['/metrics'] })); + this.serverInstance.use( + httpLogger({ + logger: this.logger, + ignorePaths: ['/metrics'], + customSuccessObject: addOperationIdToLog, + customErrorObject: (req, res, err, val) => addOperationIdToLog(req, res, val as Record), + }) + ); if (this.config.get('server.response.compression.enabled')) { this.serverInstance.use(compression(this.config.get('server.response.compression.options') as unknown as compression.CompressionFilter)); From a89878f59f12e3bccbfcf9dd40fc9bc303ba2d67 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:35:35 +0200 Subject: [PATCH 16/52] chore: sync with boilerplate --- .gitignore | 5 +++++ config/default.json | 6 ++++-- helm/templates/deployment.yaml | 16 ++++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 606515f..8fd8b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Temp folder +tmp + # Logs logs *.log @@ -111,3 +114,5 @@ dist # Jest jest_html_reporters.html reports + +config/local*.json diff --git a/config/default.json b/config/default.json index 33487ec..16a8ecd 100644 --- a/config/default.json +++ b/config/default.json @@ -12,8 +12,10 @@ }, "shared": {}, "logger": { - "level": "info", - "prettyPrint": false + "prettyPrint": false, + "opentelemetryOptions": { + "enabled": false + } } }, "server": { diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 9d60706..745e16b 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -50,10 +50,10 @@ spec: imagePullPolicy: {{ .pullPolicy | default "IfNotPresent" }} {{- end }} {{- if .Values.command }} - command: + command: {{- toYaml .Values.command | nindent 12 }} {{- if .Values.args }} - args: + args: {{- toYaml .Values.args | nindent 12 }} {{- end }} {{- end }} @@ -67,6 +67,10 @@ spec: {{ toYaml .Values.extraVolumeMounts | nindent 12 }} {{- end }} env: + - name: K8S_POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid - name: SERVER_PORT value: {{ .Values.env.targetPort | quote }} {{- if .Values.caSecretName }} @@ -77,7 +81,7 @@ spec: {{- end }} {{- if .Values.extraEnvVars }} {{- toYaml .Values.extraEnvVars | nindent 12 }} - {{- end }} + {{- end }} envFrom: - configMapRef: name: {{ printf "%s-configmap" (include "ts-server-boilerplate.fullname" .) }} @@ -102,7 +106,7 @@ spec: httpGet: path: {{ .Values.readinessProbe.path }} port: {{ .Values.env.targetPort }} - {{- end }} + {{- end }} {{- if .Values.resources.enabled }} resources: {{- toYaml .Values.resources.value | nindent 12 }} @@ -113,7 +117,7 @@ spec: volumes: - name: nginx-config configMap: - name: 'nginx-extra-configmap' + name: 'nginx-extra-configmap' {{- if .Values.caSecretName }} - name: root-ca secret: @@ -121,5 +125,5 @@ spec: {{- end }} {{- if .Values.extraVolumes -}} {{ tpl (toYaml .Values.extraVolumes) . | nindent 8 }} - {{- end }} + {{- end }} {{- end -}} From 525798148758e7fd8fa5c90f1c8480c9a62a92e4 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:37:48 +0200 Subject: [PATCH 17/52] chore: sync with boilerplate --- src/instrumentation.mts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/instrumentation.mts b/src/instrumentation.mts index b1bd0c2..267beab 100644 --- a/src/instrumentation.mts +++ b/src/instrumentation.mts @@ -1,16 +1,19 @@ // This file handles the tracing initialization and starts the tracing process before the app starts. // You should be careful about editing this file, as it is a critical part of the application's functionality. // Because this file is a module it should imported using the `--import` flag in the `node` command, and should not be imported by any other file. +import { isMainThread } from 'node:worker_threads'; import { tracingFactory } from './common/tracing.js'; import { getConfig, initConfig } from './common/config.js'; -await initConfig(); +if (isMainThread) { + await initConfig(); -const config = getConfig(); + const config = getConfig(); -const tracingConfig = config.get('telemetry.tracing'); -const sharedConfig = config.get('telemetry.shared'); + const tracingConfig = config.get('telemetry.tracing'); + const sharedConfig = config.get('telemetry.shared'); -const tracing = tracingFactory({ ...tracingConfig, ...sharedConfig }); + const tracing = tracingFactory({ ...tracingConfig, ...sharedConfig }); -tracing.start(); + tracing.start(); +} From 14950b45178ea83f0c4ad72f3d9f477b34192c9a Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:46:40 +0200 Subject: [PATCH 18/52] refactor: import types --- src/index.ts | 4 ++-- src/info/routes/infoRouter.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b6ee4c5..a0dba25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,9 @@ import 'reflect-metadata'; import { createServer } from 'http'; import { createTerminus } from '@godaddy/terminus'; -import { Logger } from '@map-colonies/js-logger'; +import type { Logger } from '@map-colonies/js-logger'; import { SERVICES } from '@common/constants'; -import { ConfigType } from '@common/config'; +import type { ConfigType } from '@common/config'; import { getApp } from './app'; void getApp() diff --git a/src/info/routes/infoRouter.ts b/src/info/routes/infoRouter.ts index ad06ead..4a7584c 100644 --- a/src/info/routes/infoRouter.ts +++ b/src/info/routes/infoRouter.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { FactoryFunction } from 'tsyringe'; +import type { FactoryFunction } from 'tsyringe'; import { InfoController } from '../controllers/infoController'; const infoRouterFactory: FactoryFunction = (dependencyContainer) => { From 0de3c06e9c029b2507144bc3107f9d066782c5f7 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:15:29 +0200 Subject: [PATCH 19/52] refactor: modify fileHandler interface to include handler name --- src/info/fileHandlers/gdal.ts | 5 +++-- src/info/models/infoManager.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 4b05c1d..7a76d65 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -23,6 +23,7 @@ import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; @injectable() export class GDALHandler implements FileHandler { + public readonly name = GDALHandler.name; private readonly defaultGeographicSrs: SpatialReference; private readonly defaultProjectedSrs: SpatialReference; private readonly supportedFormatsMap: Record; @@ -42,10 +43,10 @@ export class GDALHandler implements FileHandler { public supports(filePath: string): boolean { try { this.getDriver(filePath); - this.logger.debug({ msg: `Handler '${GDALHandler.name}' supports the requested file` }); + this.logger.debug({ msg: `Handler '${this.name}' supports the requested file` }); return true; } catch (error) { - this.logger.debug({ msg: `Handler '${GDALHandler.name}' cannot handle the requested file, caused by an error: ${JSON.stringify(error)}` }); + this.logger.debug({ msg: `Handler '${this.name}' cannot handle the requested file, caused by an error: ${JSON.stringify(error)}` }); return false; } } diff --git a/src/info/models/infoManager.ts b/src/info/models/infoManager.ts index 039322a..a8f7970 100644 --- a/src/info/models/infoManager.ts +++ b/src/info/models/infoManager.ts @@ -7,6 +7,7 @@ import { components } from '@src/openapi'; export type InfoOptions = components['schemas']['InfoRequestBody']; export type InfoResponse = components['schemas']['InfoResponse']; export interface FileHandler { + name: string; supports: (filePath: string) => boolean; getInfo: (filePath: string) => Promise; } From 255cdfd9ded8e4f97e00cb4d095ed2c597e07ae1 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:39:15 +0200 Subject: [PATCH 20/52] refactor: define error messages for validations --- src/info/fileHandlers/gdal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 7a76d65..dfa50dd 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -84,10 +84,10 @@ export class GDALHandler implements FileHandler { const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded const bandBlockSize = await band.blockSizeAsync; - blockSizeSchema.parse(bandBlockSize); + blockSizeSchema.parse(bandBlockSize, { error: () => 'Unsupported block size' }); const bandOverviewsCount = await band.overviews.countAsync(); - overviewsCount.parse(bandOverviewsCount); + overviewsCount.parse(bandOverviewsCount, { error: () => 'Could not find overviews' }); const bandDataType = await band.dataTypeAsync; const dataType = pixelDataTypesSchema.parse(bandDataType, { error: () => 'Unsupported band data type' }); From 9831a7b8939c165b97def5f143b417e382e8383e Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:45:52 +0200 Subject: [PATCH 21/52] refactor: add logs --- src/info/fileHandlers/gdal.ts | 9 +++++++-- src/info/models/infoManager.ts | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index dfa50dd..3b76a3f 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import type { ConfigType } from '@src/common/config'; import { SERVICES } from '@src/common/constants'; import { GDAL_ASYNC, getPixelInfo, getResolutions, getSrsInfo, type GdalAsync } from '@src/common/gdal'; +import { enrichLogContext } from '@src/common/logger'; import { areaOrPointSchema, blockSizeSchema, @@ -42,6 +43,7 @@ export class GDALHandler implements FileHandler { public supports(filePath: string): boolean { try { + this.logger.debug({ msg: 'Check if file is supported by handler' }); this.getDriver(filePath); this.logger.debug({ msg: `Handler '${this.name}' supports the requested file` }); return true; @@ -52,7 +54,8 @@ export class GDALHandler implements FileHandler { } public async getInfo(filePath: string): Promise { - this.logger.info({ msg: `Getting info for ${filePath}` }); + enrichLogContext({ handler: this.name }); + this.logger.debug({ msg: 'Getting info' }); let dataset: Dataset | undefined; const fullFilePath = join(this.sourceDir, filePath); @@ -135,6 +138,8 @@ export class GDALHandler implements FileHandler { }); if (supportedDriver === undefined) throw new Error(`Unsupported file format of file: ${filePath}`); - return this.gdal.drivers.get(supportedDriver); + const driver = this.gdal.drivers.get(supportedDriver); + this.logger.debug(`Found driver '${supportedDriver}' supporting file`); + return driver; } } diff --git a/src/info/models/infoManager.ts b/src/info/models/infoManager.ts index a8f7970..12a0888 100644 --- a/src/info/models/infoManager.ts +++ b/src/info/models/infoManager.ts @@ -22,9 +22,9 @@ export class InfoManager { public async info(options: InfoOptions): Promise { const { demFilePath } = options; - this.logger.debug({ msg: `Handling info request`, resource: options }); + this.logger.debug({ msg: 'Handling info request', resource: options }); const response = await this.process(demFilePath); - this.logger.debug({ msg: `Info response`, response }); + this.logger.debug({ msg: 'Info response', response }); return response; } @@ -35,6 +35,7 @@ export class InfoManager { if (!handler) { throw new UnprocessableEntityError(`No handler found for file: ${filePath}`); } + this.logger.debug({ msg: `Using handler '${handler.name}'` }); const info = await handler.getInfo(filePath); return info; From 973860fe2ab84dce029daa3c26f126ef27466e11 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:12:45 +0200 Subject: [PATCH 22/52] refactor: pr comments organize validations --- src/info/fileHandlers/gdal.ts | 113 ++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 53 deletions(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 3b76a3f..80ba8c4 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -2,7 +2,7 @@ import { access, constants } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { NotFoundError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; -import { SpatialReference, type Dataset, type Driver } from 'gdal-async'; +import { SpatialReference, type Dataset, type Driver, type RasterBand } from 'gdal-async'; import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; import type { ConfigType } from '@src/common/config'; @@ -69,59 +69,10 @@ export class GDALHandler implements FileHandler { try { const driver = this.getDriver(filePath); dataset = await driver.openAsync(fullFilePath, 'r'); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const metadata = await dataset.getMetadataAsync(); - const areaOrPoint = z - // eslint-disable-next-line @typescript-eslint/naming-convention - .object({ AREA_OR_POINT: areaOrPointSchema }) - .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const metadataImageStructure = await dataset.getMetadataAsync('IMAGE_STRUCTURE'); - void z - // eslint-disable-next-line @typescript-eslint/naming-convention - .object({ LAYOUT: layoutSchema, COMPRESSION: compressionSchema }) - .parse(metadataImageStructure, { error: () => 'Could not extract LAYOUT metadata' }).LAYOUT; - const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded - - const bandBlockSize = await band.blockSizeAsync; - blockSizeSchema.parse(bandBlockSize, { error: () => 'Unsupported block size' }); - - const bandOverviewsCount = await band.overviews.countAsync(); - overviewsCount.parse(bandOverviewsCount, { error: () => 'Could not find overviews' }); - - const bandDataType = await band.dataTypeAsync; - const dataType = pixelDataTypesSchema.parse(bandDataType, { error: () => 'Unsupported band data type' }); - - const bandNoDataValueAsync = await band.noDataValueAsync; - const noDataValue = noDataValueSchema.parse(bandNoDataValueAsync, { error: () => 'Unsupported band nodata value' }); - - const srs = await dataset.srsAsync; - if (srs === null) throw new Error('Unsupported SRS'); - const srsInfo = getSrsInfo(srs); - const { srsId, srsName } = z.strictObject({ srsId: srsIdSchema, srsName: srsNameSchema }).parse(srsInfo, { error: () => 'Unsupported SRS' }); - - const geoTransform = await dataset.geoTransformAsync; - - const { resolutionDegree, resolutionMeter } = getResolutions({ - ...dataset.bands.getEnvelope(), - ...getPixelInfo({ geoTransform }), - targetGeographicSrs: this.defaultGeographicSrs, - targetProjectedSrs: this.defaultProjectedSrs, - sourceSrs: srs, - }); - - return { - areaOrPoint, - dataType, - noDataValue, - resolutionDegree, - resolutionMeter, - srsId, - srsName, - }; + await this.validateMetadata({ dataset, band }); + const metadata = await this.getMetadata({ dataset, band }); + return metadata; } finally { dataset?.close(); } @@ -142,4 +93,60 @@ export class GDALHandler implements FileHandler { this.logger.debug(`Found driver '${supportedDriver}' supporting file`); return driver; } + + private async getMetadata({ band, dataset }: { dataset: Dataset; band: RasterBand }): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metadata = await dataset.getMetadataAsync(); + const areaOrPoint = z + // eslint-disable-next-line @typescript-eslint/naming-convention + .object({ AREA_OR_POINT: areaOrPointSchema }) + .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; + + const bandDataType = await band.dataTypeAsync; + const dataType = pixelDataTypesSchema.parse(bandDataType, { error: () => 'Unsupported band data type' }); + + const bandNoDataValueAsync = await band.noDataValueAsync; + const noDataValue = noDataValueSchema.parse(bandNoDataValueAsync, { error: () => 'Unsupported band nodata value' }); + + const srs = await dataset.srsAsync; + if (srs === null) throw new Error('Unsupported SRS'); + const srsInfo = getSrsInfo(srs); + const { srsId, srsName } = z.strictObject({ srsId: srsIdSchema, srsName: srsNameSchema }).parse(srsInfo, { error: () => 'Unsupported SRS' }); + + const geoTransform = await dataset.geoTransformAsync; + const pixelInfo = getPixelInfo({ geoTransform }); + + const { resolutionDegree, resolutionMeter } = getResolutions({ + ...dataset.bands.getEnvelope(), + ...pixelInfo, + targetGeographicSrs: this.defaultGeographicSrs, + targetProjectedSrs: this.defaultProjectedSrs, + sourceSrs: srs, + }); + + return { + areaOrPoint, + dataType, + noDataValue, + resolutionDegree, + resolutionMeter, + srsId, + srsName, + }; + } + + private async validateMetadata({ band, dataset }: { dataset: Dataset; band: RasterBand }): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metadataImageStructure = await dataset.getMetadataAsync('IMAGE_STRUCTURE'); + void z + // eslint-disable-next-line @typescript-eslint/naming-convention + .object({ LAYOUT: layoutSchema, COMPRESSION: compressionSchema }) + .parse(metadataImageStructure, { error: () => 'Could not extract LAYOUT metadata' }).LAYOUT; + + const bandBlockSize = await band.blockSizeAsync; + blockSizeSchema.parse(bandBlockSize, { error: () => 'Unsupported block size' }); + + const bandOverviewsCount = await band.overviews.countAsync(); + overviewsCount.parse(bandOverviewsCount, { error: () => 'Could not find overviews' }); + } } From fd99b7fb3fc0337a9fb4d22a94869359b3a73c81 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:38:57 +0200 Subject: [PATCH 23/52] chore: pr comment modify info response schema --- openapi3.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openapi3.yaml b/openapi3.yaml index 80ad47e..8194f1c 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -410,13 +410,11 @@ components: demFilePath: $ref: '#/components/schemas/DemFilePath' InfoResponse: - type: object description: Info response body - unevaluatedProperties: false - oneOf: - - $ref: '#/components/schemas/InfoGeoTiff' + $ref: '#/components/schemas/InfoGeoTiff' InfoGeoTiff: description: Info properties of GeoTiff + unevaluatedProperties: false allOf: - $ref: '#/components/schemas/InfoCommonRegularGridProperties' - type: object From f0cc37a0d3daa1251f8aa9cbccb3bfe1b44a9349 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:14:19 +0300 Subject: [PATCH 24/52] fix: validate against configured format --- src/common/constants.ts | 4 ++-- src/common/interfaces.ts | 4 +++- src/common/schemas.ts | 12 +++++++++--- src/info/fileHandlers/gdal.ts | 30 ++++++++++++++++++------------ 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index 9095834..1c9f54c 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,5 +1,5 @@ import { readPackageJsonSync } from '@map-colonies/read-pkg'; -import type { GeoTiffDataType, IsComplete, NoDuplicates, RasterDataType } from './interfaces'; +import type { GeoTiffDataType, IsComplete, NoDuplicates } from './interfaces'; const defineConstTuple = () => @@ -25,6 +25,6 @@ export const SERVICES = { /* eslint-enable @typescript-eslint/naming-convention */ export const GEOTIFF_DATA_TYPES = defineConstTuple()('Int8', 'Int16', 'Int32', 'Int64', 'Float16', 'Float32', 'Float64'); -export const RASTER_DATA_TYPES: Record = { +export const RASTER_DATA_TYPES = { geotiff: GEOTIFF_DATA_TYPES, }; diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index fe13253..9398cbf 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,5 +1,6 @@ import type { z } from 'zod'; import type { components } from '@src/openapi'; +import type { RASTER_DATA_TYPES } from './constants'; import type { pixelDataTypesSchema } from './schemas'; export type NoDuplicates = T extends [infer First, ...infer Rest] @@ -13,4 +14,5 @@ export type IsComplete = [Target] extends [U[number export type GeoTiffDataType = components['schemas']['InfoGeoTiff']['dataType']; export type RasterDataType = components['schemas']['InfoResponse']['dataType']; -export type PixelDataType = z.infer; +export type PixelDataType = z.infer>; +export type RasterFormats = keyof typeof RASTER_DATA_TYPES; diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 79bb419..548a8f2 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -1,6 +1,6 @@ -import { z } from 'zod'; +import { z, type ZodLiteral } from 'zod'; import { getConfig } from './config'; -import { GEOTIFF_DATA_TYPES } from './constants'; +import { RASTER_DATA_TYPES } from './constants'; const config = getConfig(); @@ -10,13 +10,19 @@ const resolutionDegree = config.get('application.validation.resolutionDegree'); const resolutionMeter = config.get('application.validation.resolutionMeter'); const supportedSrsIds = config.get('application.validation.supportedSrsIds'); +export const hasKey = >(x: PropertyKey, object: T): x is keyof T => { + return Object.keys(object).includes(String(x)); +}; + export const areaOrPointSchema = z.literal(['Area', 'Point']); export const blockSizeSchema = z.object({ x: z.literal(blockSize), y: z.literal(blockSize) }); export const compressionSchema = z.literal(compression); export const layoutSchema = z.literal('COG'); export const noDataValueSchema = z.union([z.number(), z.nan()]).transform((value) => (Number.isNaN(value) ? 'NaN' : value)); export const overviewsCount = z.number().positive(); -export const pixelDataTypesSchema = z.union([z.literal(GEOTIFF_DATA_TYPES)]); // add additional data types to union for each supported format +export const pixelDataTypesSchema = ( + format: keyof typeof RASTER_DATA_TYPES +): ZodLiteral<(typeof RASTER_DATA_TYPES)[keyof typeof RASTER_DATA_TYPES][number]> => z.literal(RASTER_DATA_TYPES[format]); export const pixelSchema = z.number().positive(); export const resolutionDegreeSchema = z.number().min(resolutionDegree.min).max(resolutionDegree.max); export const resolutionMeterSchema = z.number().min(resolutionMeter.min).max(resolutionMeter.max); diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 80ba8c4..7596144 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -6,13 +6,14 @@ import { SpatialReference, type Dataset, type Driver, type RasterBand } from 'gd import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; import type { ConfigType } from '@src/common/config'; -import { SERVICES } from '@src/common/constants'; +import { RASTER_DATA_TYPES, SERVICES } from '@src/common/constants'; import { GDAL_ASYNC, getPixelInfo, getResolutions, getSrsInfo, type GdalAsync } from '@src/common/gdal'; import { enrichLogContext } from '@src/common/logger'; import { areaOrPointSchema, blockSizeSchema, compressionSchema, + hasKey, layoutSchema, noDataValueSchema, overviewsCount, @@ -21,6 +22,7 @@ import { srsNameSchema, } from '@src/common/schemas'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; +import type { RasterFormats } from '@src/common/interfaces'; @injectable() export class GDALHandler implements FileHandler { @@ -67,20 +69,20 @@ export class GDALHandler implements FileHandler { } try { - const driver = this.getDriver(filePath); + const { driver, format } = this.getDriver(filePath); dataset = await driver.openAsync(fullFilePath, 'r'); const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded await this.validateMetadata({ dataset, band }); - const metadata = await this.getMetadata({ dataset, band }); + const metadata = await this.getMetadata({ band, dataset, format }); return metadata; } finally { dataset?.close(); } } - private getDriver(filePath: string): Driver { + private getDriver(filePath: string): { driver: Driver; format: RasterFormats } { const fileExtension = extname(filePath).slice(1); - const supportedDriver = Object.values(this.supportedFormatsMap).find((supportedDriver) => { + const supportedFormat = Object.entries(this.supportedFormatsMap).find(([, supportedDriver]) => { const driver = this.gdal.drivers.get(supportedDriver); // eslint-disable-next-line @typescript-eslint/naming-convention const driverMetadata = driver.getMetadata() as { DMD_EXTENSION?: string; DMD_EXTENSIONS?: string }; @@ -88,13 +90,17 @@ export class GDALHandler implements FileHandler { return [extension, ...extensions.split(' ')].filter((extension) => extension.length > 0).includes(fileExtension); }); - if (supportedDriver === undefined) throw new Error(`Unsupported file format of file: ${filePath}`); - const driver = this.gdal.drivers.get(supportedDriver); - this.logger.debug(`Found driver '${supportedDriver}' supporting file`); - return driver; + if (supportedFormat === undefined) throw new Error(`Unsupported file format of file: ${filePath}`); + const [format, driverName] = supportedFormat; + if (!hasKey(format, RASTER_DATA_TYPES)) { + throw new Error(`Format '${format}' is not part of service's API`); + } + const driver = this.gdal.drivers.get(driverName); + this.logger.debug(`Found driver '${driverName}' supporting file`); + return { driver, format }; } - private async getMetadata({ band, dataset }: { dataset: Dataset; band: RasterBand }): Promise { + private async getMetadata({ band, dataset, format }: { band: RasterBand; dataset: Dataset; format: RasterFormats }): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const metadata = await dataset.getMetadataAsync(); const areaOrPoint = z @@ -103,7 +109,7 @@ export class GDALHandler implements FileHandler { .parse(metadata, { error: () => 'Could not extract AREA_OR_POINT metadata' }).AREA_OR_POINT; const bandDataType = await band.dataTypeAsync; - const dataType = pixelDataTypesSchema.parse(bandDataType, { error: () => 'Unsupported band data type' }); + const dataType = pixelDataTypesSchema(format).parse(bandDataType, { error: () => 'Unsupported band data type' }); const bandNoDataValueAsync = await band.noDataValueAsync; const noDataValue = noDataValueSchema.parse(bandNoDataValueAsync, { error: () => 'Unsupported band nodata value' }); @@ -135,7 +141,7 @@ export class GDALHandler implements FileHandler { }; } - private async validateMetadata({ band, dataset }: { dataset: Dataset; band: RasterBand }): Promise { + private async validateMetadata({ band, dataset }: { band: RasterBand; dataset: Dataset }): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const metadataImageStructure = await dataset.getMetadataAsync('IMAGE_STRUCTURE'); void z From f3154b15b6786beccea0b1777b5ed9ab69b316b0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:28:31 +0300 Subject: [PATCH 25/52] chore: remove unnecessary space in start:dev script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fc0e148..c428702 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "prebuild": "npm run clean && npm run generate:openapi-types", "build": "tsc --project tsconfig.build.json && tsc-alias -p tsconfig.build.json && npm run assets:copy", "start": "npm run build && cd dist && node --import ./instrumentation.mjs ./index.js", - "start:dev": "npm run build && cd dist && cross-env CONFIG_OFFLINE_MODE=true node --enable-source-maps --import ./instrumentation.mjs ./index.js", + "start:dev": "npm run build && cd dist && cross-env CONFIG_OFFLINE_MODE=true node --enable-source-maps --import ./instrumentation.mjs ./index.js", "assets:copy": "copyfiles -f ./config/* ./dist/config && copyfiles -f ./openapi3.yaml ./dist/ && copyfiles ./package.json dist", "clean": "rimraf dist", "generate:openapi-types": "openapi-helpers generate types ./openapi3.yaml ./src/openapi.d.ts --format --add-typed-request-handler", From 3f9ff2f9e0777ebe3c8fd4481d832c4e8e3e18ac Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:36:36 +0300 Subject: [PATCH 26/52] fix: missing type --- openapi3.yaml | 2 ++ src/openapi.d.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/openapi3.yaml b/openapi3.yaml index 8194f1c..b5ced22 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -413,6 +413,7 @@ components: description: Info response body $ref: '#/components/schemas/InfoGeoTiff' InfoGeoTiff: + type: object description: Info properties of GeoTiff unevaluatedProperties: false allOf: @@ -428,6 +429,7 @@ components: $ref: '#/components/schemas/NoDataValue' InputFiles: type: object + description: Input files unevaluatedProperties: false required: - demFilePath diff --git a/src/openapi.d.ts b/src/openapi.d.ts index 2e4209a..13b9534 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -153,6 +153,7 @@ export type components = { dataType: components['schemas']['GeoTiffDataType']; noDataValue: components['schemas']['NoDataValue']; }; + /** @description Input files */ InputFiles: { demFilePath: components['schemas']['DemFilePath']; metadataShapefilePath: components['schemas']['MetadataShapefilePath']; From 4af957c53cc522df0740166d34918a116440a3f5 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:51:28 +0300 Subject: [PATCH 27/52] fix: correctly swap input and output coordinate order --- src/common/gdal.ts | 48 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/common/gdal.ts b/src/common/gdal.ts index a7fdeb0..06d6d12 100644 --- a/src/common/gdal.ts +++ b/src/common/gdal.ts @@ -116,20 +116,44 @@ export const getSrsInfo = (srs: SpatialReference): Pick { - const { sourceSrs, targetSrs, point } = options; - const coordinateTransformation = new CoordinateTransformation(sourceSrs, targetSrs); - - let sourcePoint: xyz; - - if (sourceSrs.isGeographic()) { - sourcePoint = sourceSrs.EPSGTreatsAsLatLong() ? { x: point.y, y: point.x } : point; - } else if (sourceSrs.isProjected()) { - sourcePoint = sourceSrs.EPSGTreatsAsNorthingEasting() ? { x: point.y, y: point.x } : point; +/** + * Swap coordinate order to have a { x: lon, y: lat } order, if needed + * @param options - Object with the following properties: + * @param options.point - Point to swap order if needed + * @param options.srs - SRS + * @returns Point with swapped coordinates + */ +export const swapCoordinateOrder = (options: { srs: SpatialReference; point: xyz }): xyz => { + const { point, srs } = options; + + let swappedPoint: xyz; + + if (srs.isGeographic()) { + swappedPoint = srs.EPSGTreatsAsLatLong() ? { x: point.y, y: point.x } : point; + } else if (srs.isProjected()) { + swappedPoint = srs.EPSGTreatsAsNorthingEasting() ? { x: point.y, y: point.x } : point; } else { - throw new Error('Unsupported SRS type'); + throw new Error(`Unsupported SRS type of '${srs.getAuthorityName()}:${srs.getAuthorityCode()}'`); } + return swappedPoint; +}; + +/** + * Reproject point + * @param options - Object with the following properties: + * @param options.point - Point to be transfomed (long/lat or east/north order) + * @param options.sourceSrs - Source SRS + * @param options.targetSrs - Target SRS + * @returns Reprojected point + */ +export const transformPoint = (options: { point: xyz; sourceSrs: SpatialReference; targetSrs: SpatialReference }): xyz => { + const { point, sourceSrs, targetSrs } = options; + + const sourcePoint = swapCoordinateOrder({ point, srs: sourceSrs }); + const coordinateTransformation = new CoordinateTransformation(sourceSrs, targetSrs); const transformedPoint = coordinateTransformation.transformPoint(sourcePoint); - return transformedPoint; + const targetPoint = swapCoordinateOrder({ point: transformedPoint, srs: targetSrs }); + + return targetPoint; }; From ecacf5136b33aba670fba27d92c20fb7466d25d0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:52:04 +0300 Subject: [PATCH 28/52] refactor: change overview count validation schema and remove pixel schema --- src/common/schemas.ts | 17 ++++++++++++++--- src/info/fileHandlers/gdal.ts | 8 ++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 548a8f2..185346e 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -1,4 +1,4 @@ -import { z, type ZodLiteral } from 'zod'; +import { z, type ZodLiteral, type ZodNumber } from 'zod'; import { getConfig } from './config'; import { RASTER_DATA_TYPES } from './constants'; @@ -19,11 +19,22 @@ export const blockSizeSchema = z.object({ x: z.literal(blockSize), y: z.literal( export const compressionSchema = z.literal(compression); export const layoutSchema = z.literal('COG'); export const noDataValueSchema = z.union([z.number(), z.nan()]).transform((value) => (Number.isNaN(value) ? 'NaN' : value)); -export const overviewsCount = z.number().positive(); +export const overviewsCountSchema = ({ + blockSize, + size: { x, y }, +}: { + blockSize: z.infer; + size: { x: number; y: number }; +}): ZodNumber => + z + .number() + .min(1) + .max(Math.min(...[Math.ceil(x / blockSize.x), Math.ceil(y / blockSize.y)])) + .int() + .positive(); export const pixelDataTypesSchema = ( format: keyof typeof RASTER_DATA_TYPES ): ZodLiteral<(typeof RASTER_DATA_TYPES)[keyof typeof RASTER_DATA_TYPES][number]> => z.literal(RASTER_DATA_TYPES[format]); -export const pixelSchema = z.number().positive(); export const resolutionDegreeSchema = z.number().min(resolutionDegree.min).max(resolutionDegree.max); export const resolutionMeterSchema = z.number().min(resolutionMeter.min).max(resolutionMeter.max); export const srsIdSchema = z.literal(supportedSrsIds); diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 7596144..3c9b8c3 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -16,7 +16,7 @@ import { hasKey, layoutSchema, noDataValueSchema, - overviewsCount, + overviewsCountSchema, pixelDataTypesSchema, srsIdSchema, srsNameSchema, @@ -147,12 +147,12 @@ export class GDALHandler implements FileHandler { void z // eslint-disable-next-line @typescript-eslint/naming-convention .object({ LAYOUT: layoutSchema, COMPRESSION: compressionSchema }) - .parse(metadataImageStructure, { error: () => 'Could not extract LAYOUT metadata' }).LAYOUT; + .parse(metadataImageStructure, { error: () => 'Unsupported image structure metadata (LAYOUT and COMPRESSION)' }).LAYOUT; const bandBlockSize = await band.blockSizeAsync; - blockSizeSchema.parse(bandBlockSize, { error: () => 'Unsupported block size' }); + const blockSize = blockSizeSchema.parse(bandBlockSize, { error: () => 'Unsupported block size' }); const bandOverviewsCount = await band.overviews.countAsync(); - overviewsCount.parse(bandOverviewsCount, { error: () => 'Could not find overviews' }); + overviewsCountSchema({ blockSize, size: band.size }).parse(bandOverviewsCount, { error: () => 'Could not find overviews' }); } } From c139da9d3fd716b925f04b87b211546041b4076f Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:57:17 +0300 Subject: [PATCH 29/52] refactor: use custom error to throw 422 error --- src/common/errors.ts | 1 + src/common/gdal.ts | 12 +++++++----- src/info/controllers/infoController.ts | 5 ++++- src/info/fileHandlers/gdal.ts | 3 ++- 4 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 src/common/errors.ts diff --git a/src/common/errors.ts b/src/common/errors.ts new file mode 100644 index 0000000..bbd61b4 --- /dev/null +++ b/src/common/errors.ts @@ -0,0 +1 @@ +export class UnsupportedSrsError extends Error {} diff --git a/src/common/gdal.ts b/src/common/gdal.ts index 06d6d12..5845994 100644 --- a/src/common/gdal.ts +++ b/src/common/gdal.ts @@ -1,8 +1,9 @@ -import { CoordinateTransformation, SpatialReference, type Dataset, type Envelope, type xyz } from 'gdal-async'; import * as gdalAsync from 'gdal-async'; +import { CoordinateTransformation, SpatialReference, type Dataset, type Envelope, type xyz } from 'gdal-async'; import { z } from 'zod'; import type { InfoResponse } from '@src/info/models/infoManager'; import { EPSG_DATA_RECORDS } from './epsg'; +import { UnsupportedSrsError } from './errors'; import { resolutionDegreeSchema, resolutionMeterSchema } from './schemas'; interface PixelInfo { @@ -72,7 +73,7 @@ export const getResolutions = ( ).find((value) => value[0])?.[1]; if (resolutions == undefined) { - throw new Error('Unsupported SRS type'); + throw new UnsupportedSrsError('Unsupported SRS type'); } const response = z.strictObject({ resolutionMeter: resolutionMeterSchema, resolutionDegree: resolutionDegreeSchema }).parse(resolutions); @@ -90,7 +91,7 @@ export const getSrsName = (srsId: number): string => { ).find((value) => value[0])?.[1]; if (srsName == undefined) { - throw new Error('Unsupported SRS type'); + throw new UnsupportedSrsError('Unsupported SRS type'); } return srsName; @@ -99,7 +100,7 @@ export const getSrsName = (srsId: number): string => { export const getSrsGeographicBounds = (options: { srsId: number }): [number, number, number, number] => { const { srsId } = options; const epsgRecord = EPSG_DATA_RECORDS[srsId]; - if (!epsgRecord) throw new Error('Unsupported SRS'); + if (!epsgRecord) throw new UnsupportedSrsError('Unsupported SRS'); const [sourceMaxY, sourceMinX, sourceMinY, sourceMaxX] = epsgRecord.bbox; return [sourceMinX, sourceMinY, sourceMaxX, sourceMaxY]; @@ -108,6 +109,7 @@ export const getSrsGeographicBounds = (options: { srsId: number }): [number, num export const getSrsInfo = (srs: SpatialReference): Pick => { const srsAuthorityCode = srs.getAuthorityCode(); const srsId = parseInt(srsAuthorityCode); + if (Number.isNaN(srsId)) throw new UnsupportedSrsError('Unsupported SRS'); const srsName = getSrsName(srsId); return { @@ -133,7 +135,7 @@ export const swapCoordinateOrder = (options: { srs: SpatialReference; point: xyz } else if (srs.isProjected()) { swappedPoint = srs.EPSGTreatsAsNorthingEasting() ? { x: point.y, y: point.x } : point; } else { - throw new Error(`Unsupported SRS type of '${srs.getAuthorityName()}:${srs.getAuthorityCode()}'`); + throw new UnsupportedSrsError(`Unsupported SRS type of '${srs.getAuthorityName()}:${srs.getAuthorityCode()}'`); } return swappedPoint; diff --git a/src/info/controllers/infoController.ts b/src/info/controllers/infoController.ts index e6d3877..d882c21 100644 --- a/src/info/controllers/infoController.ts +++ b/src/info/controllers/infoController.ts @@ -1,10 +1,11 @@ +import { UnprocessableEntityError } from '@map-colonies/error-types'; import httpStatus from 'http-status-codes'; import { inject, injectable } from 'tsyringe'; import { ZodError } from 'zod'; -import { UnprocessableEntityError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; import type { TypedRequestHandlers } from '@openapi'; import { SERVICES } from '@common/constants'; +import { UnsupportedSrsError } from '@src/common/errors'; import { enrichLogContext } from '@src/common/logger'; import { InfoManager } from '../models/infoManager'; @@ -24,6 +25,8 @@ export class InfoController { this.logger.error({ err: error }); if (error instanceof ZodError) { return next(new UnprocessableEntityError(error.issues[0]?.message ?? 'validation error')); + } else if (error instanceof UnsupportedSrsError) { + return next(new UnprocessableEntityError(error.message)); } next(error); } diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 3c9b8c3..8e708e7 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -22,6 +22,7 @@ import { srsNameSchema, } from '@src/common/schemas'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; +import { UnsupportedSrsError } from '@src/common/errors'; import type { RasterFormats } from '@src/common/interfaces'; @injectable() @@ -115,7 +116,7 @@ export class GDALHandler implements FileHandler { const noDataValue = noDataValueSchema.parse(bandNoDataValueAsync, { error: () => 'Unsupported band nodata value' }); const srs = await dataset.srsAsync; - if (srs === null) throw new Error('Unsupported SRS'); + if (srs === null) throw new UnsupportedSrsError('Unsupported SRS'); const srsInfo = getSrsInfo(srs); const { srsId, srsName } = z.strictObject({ srsId: srsIdSchema, srsName: srsNameSchema }).parse(srsInfo, { error: () => 'Unsupported SRS' }); From 729191c38ad07e2bf773df6bbc56edeceb8335b2 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:59:58 +0300 Subject: [PATCH 30/52] build: add geographiclib-geodesic --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index e59d3a3..c2be59d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "express": "^4.21.2", "express-openapi-validator": "^5.6.2", "gdal-async": "^3.12.2", + "geographiclib-geodesic": "^2.2.0", "http-status-codes": "^2.3.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", @@ -10761,6 +10762,12 @@ "node": ">=6.9.0" } }, + "node_modules/geographiclib-geodesic": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/geographiclib-geodesic/-/geographiclib-geodesic-2.2.0.tgz", + "integrity": "sha512-cIedo9VTYb0DFufodgibDmVfsWe9EASqb/kUByl09xc6PZYvLvlc89BHCThtGTPf2OII/zWJGxsR3Uz6O7QOVw==", + "license": "MIT" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/package.json b/package.json index c428702..0678369 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "express": "^4.21.2", "express-openapi-validator": "^5.6.2", "gdal-async": "^3.12.2", + "geographiclib-geodesic": "^2.2.0", "http-status-codes": "^2.3.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", From b20fc4c3c67c1051c44cc7948f6696672461126e Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:00:26 +0300 Subject: [PATCH 31/52] build: update @map-colonies/schemas --- package-lock.json | 6 +++--- package.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2be59d..17933e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", "@map-colonies/read-pkg": "^1.0.0", - "@map-colonies/schemas": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-6939db3448a04daf1d79ab5f8ecfadeab67237c8.tgz", + "@map-colonies/schemas": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-ba913c633a12f913c8c68774d46036fa5c2ab978.tgz", "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^1.0.0", "@opentelemetry/api": "^1.9.0", @@ -2695,8 +2695,8 @@ }, "node_modules/@map-colonies/schemas": { "version": "1.18.0", - "resolved": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-6939db3448a04daf1d79ab5f8ecfadeab67237c8.tgz", - "integrity": "sha512-T1MDhznSLhSxpZhyHUj477vZhYFEnykmaSIP99ns5piO1pOKobOdlpG2Xshu+Tvarsb4dX1xkv2P7kYI7owe+A==", + "resolved": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-ba913c633a12f913c8c68774d46036fa5c2ab978.tgz", + "integrity": "sha512-YwTz15mf+Kp0co+UgHzMnQ0SOmoKWLU+u8DpcvOuhy5Yf6l7028K3d+BsJfN035XDjzqTDQ/BmpWgHkFuAjVRQ==", "license": "MIT", "peer": true }, diff --git a/package.json b/package.json index 0678369..5f4fd9e 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "This is template for map colonies typescript service", "main": "./src/index.ts", "scripts": { - "test:unit": "vitest run --coverage.enabled=false --project unit", - "test:integration": "vitest run --coverage.enabled=false --project integration", + "test:unit": "vitest run --coverage.enabled=true --project unit", + "test:integration": "vitest run --coverage.enabled=true --project integration", "test": "vitest run", "test:watch": "vitest watch", "test:ui": "vitest --ui", @@ -40,7 +40,7 @@ "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", "@map-colonies/read-pkg": "^1.0.0", - "@map-colonies/schemas": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-6939db3448a04daf1d79ab5f8ecfadeab67237c8.tgz", + "@map-colonies/schemas": "https://ghatmpstorage.blob.core.windows.net/npm-packages/schemas-ba913c633a12f913c8c68774d46036fa5c2ab978.tgz", "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^1.0.0", "@opentelemetry/api": "^1.9.0", From 0764ce898a38264508609a4164eb0b4947ed5a73 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:15:58 +0300 Subject: [PATCH 32/52] refactor: resolution calculation --- config/default.json | 2 - src/common/gdal.ts | 95 ++++++++++++++++++++++++----------- src/info/fileHandlers/gdal.ts | 12 ++--- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/config/default.json b/config/default.json index 16a8ecd..78baebe 100644 --- a/config/default.json +++ b/config/default.json @@ -56,8 +56,6 @@ 32760 ] }, - "defaultGeographicSrsId": 4326, - "defaultProjectedSrsId": 3395, "supportedFormatsMap": { "geotiff": "gtiff" } diff --git a/src/common/gdal.ts b/src/common/gdal.ts index 5845994..46bcd11 100644 --- a/src/common/gdal.ts +++ b/src/common/gdal.ts @@ -1,11 +1,14 @@ import * as gdalAsync from 'gdal-async'; import { CoordinateTransformation, SpatialReference, type Dataset, type Envelope, type xyz } from 'gdal-async'; +import { Geodesic } from 'geographiclib-geodesic'; import { z } from 'zod'; import type { InfoResponse } from '@src/info/models/infoManager'; import { EPSG_DATA_RECORDS } from './epsg'; import { UnsupportedSrsError } from './errors'; import { resolutionDegreeSchema, resolutionMeterSchema } from './schemas'; +const EPSG_CODE_WGS84 = 4326; + interface PixelInfo { pixelWidth: number; pixelHeight: number; @@ -22,53 +25,85 @@ export const getPixelInfo = (options: Pick): PixelInfo return { pixelHeight: Math.abs(validGeoTransform[5]), pixelWidth: Math.abs(validGeoTransform[1]) }; }; +/** + * Get resolutions in a bound region + * @param options - Object with the following properties: + * @param options.sourceSrs - EPSG code or {@link SpatialReference} instance + * @param options.minX - Minimum X of region, in `sourceSrs` units + * @param options.minY - Minimum Y of region, in `sourceSrs` units + * @param options.maxX - Maximum X of region, in `sourceSrs` units + * @param options.maxY - Maximum Y of region, in `sourceSrs` units + * @param options.pixelWidth - Pixel width, in `sourceSrs` units + * @param options.pixelHeight - Pixel height, in `sourceSrs` units + * @returns Object of resolutions in meters and degrees on WGS84 ellipsoid + */ export const getResolutions = ( options: { sourceSrs: SpatialReference | number; - targetGeographicSrs: SpatialReference | number; - targetProjectedSrs: SpatialReference | number; } & Pick & PixelInfo ): Pick => { - const { targetGeographicSrs, targetProjectedSrs, maxX, maxY, minX, minY, pixelHeight, pixelWidth, sourceSrs } = options; + const { maxX, maxY, minX, minY, pixelHeight, pixelWidth, sourceSrs } = options; const resolvedSourceSrs = typeof sourceSrs === 'number' ? SpatialReference.fromEPSG(sourceSrs) : sourceSrs; - const resolvedTargetGeographicSrs = typeof targetGeographicSrs === 'number' ? SpatialReference.fromEPSG(targetGeographicSrs) : targetGeographicSrs; - const resolvedTargetProjectedSrs = typeof targetProjectedSrs === 'number' ? SpatialReference.fromEPSG(targetProjectedSrs) : targetProjectedSrs; - // TODO: how to handle pixelHeight, pixelWidth? mean / max / min ??? const [dx, dy] = [maxX - minX, maxY - minY]; - const [sourceMinX, sourceMinY, sourceMaxX, sourceMaxY] = [ - /* eslint-disable @typescript-eslint/no-magic-numbers */ - minX + (dx - pixelWidth) / 2, - minY + (dy - pixelHeight) / 2, - minX + (dx + pixelWidth) / 2, - minY + (dy + pixelHeight) / 2, - /* eslint-enable @typescript-eslint/no-magic-numbers */ - ]; + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const [centerX, centerY] = [minX + dx / 2, minY + dy / 2]; + + /* eslint-disable @typescript-eslint/no-magic-numbers */ + const { x: targetMinX } = transformPoint({ + sourceSrs: resolvedSourceSrs, + targetSrs: SpatialReference.fromEPSG(EPSG_CODE_WGS84), + point: { x: centerX - pixelWidth / 2, y: centerY }, + }); + const { x: targetMaxX } = transformPoint({ + sourceSrs: resolvedSourceSrs, + targetSrs: SpatialReference.fromEPSG(EPSG_CODE_WGS84), + point: { x: centerX + pixelWidth / 2, y: centerY }, + }); + const { y: targetMinY } = transformPoint({ + sourceSrs: resolvedSourceSrs, + targetSrs: SpatialReference.fromEPSG(EPSG_CODE_WGS84), + point: { x: centerX, y: centerY - pixelHeight / 2 }, + }); + const { y: targetMaxY } = transformPoint({ + sourceSrs: resolvedSourceSrs, + targetSrs: SpatialReference.fromEPSG(EPSG_CODE_WGS84), + point: { x: centerX, y: centerY + pixelHeight / 2 }, + }); + /* eslint-enable @typescript-eslint/no-magic-numbers */ // approximation of the reprojected resolution - const getReprojectedResolution = (targetSrs: SpatialReference): number => { - const { x: targetMinX, y: targetMinY } = transformPoint({ - sourceSrs: resolvedSourceSrs, - targetSrs, - point: { x: sourceMinX, y: sourceMinY }, - }); - const { x: targetMaxX, y: targetMaxY } = transformPoint({ - sourceSrs: resolvedSourceSrs, - targetSrs, - point: { x: sourceMaxX, y: sourceMaxY }, - }); - const [dxTarget, dyTarget] = [targetMaxX - targetMinX, targetMaxY - targetMinY]; + const getReprojectedDegreeResolution = (): number => { + const [reprojectedResolutionX, reprojectedResolutionY] = [targetMaxX - targetMinX, targetMaxY - targetMinY]; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + return (reprojectedResolutionX + reprojectedResolutionY) / 2; + }; + + const getReprojectedMeterResolution = (): number => { + const geodesicDistanceX = Geodesic.WGS84.Inverse(centerY, targetMinX, centerY, targetMaxX).s12; + if (geodesicDistanceX === undefined) + throw new Error( + `Could not calculate geodesic distance between points (${[centerY, targetMinX].toString()})-(${[centerY, targetMaxX].toString()})]` + ); + + const geodesicDistanceY = Geodesic.WGS84.Inverse(targetMinY, centerX, targetMaxY, centerX).s12; + if (geodesicDistanceY === undefined) + throw new Error( + `Could not calculate geodesic distance between points (${[targetMinY, centerX].toString()})-(${[targetMaxY, centerY].toString()})]` + ); // eslint-disable-next-line @typescript-eslint/no-magic-numbers - const reprojectedResolution = (((dxTarget ** 2 + dyTarget ** 2) / (pixelWidth ** 2 + pixelHeight ** 2)) * pixelHeight ** 2) ** 0.5; - return reprojectedResolution; + return (geodesicDistanceX + geodesicDistanceY) / 2; }; const resolutions = ( [ - [resolvedSourceSrs.isGeographic(), { resolutionMeter: getReprojectedResolution(resolvedTargetProjectedSrs), resolutionDegree: pixelHeight }], - [resolvedSourceSrs.isProjected(), { resolutionMeter: pixelHeight, resolutionDegree: getReprojectedResolution(resolvedTargetGeographicSrs) }], + /* eslint-disable @typescript-eslint/no-magic-numbers */ + [resolvedSourceSrs.isGeographic(), { resolutionMeter: getReprojectedMeterResolution(), resolutionDegree: (pixelWidth + pixelHeight) / 2 }], + [resolvedSourceSrs.isProjected(), { resolutionMeter: (pixelWidth + pixelHeight) / 2, resolutionDegree: getReprojectedDegreeResolution() }], + /* eslint-enable @typescript-eslint/no-magic-numbers */ ] satisfies [boolean, { resolutionMeter: number; resolutionDegree: number }][] ).find((value) => value[0])?.[1]; diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 8e708e7..1f34606 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -2,12 +2,14 @@ import { access, constants } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { NotFoundError } from '@map-colonies/error-types'; import type { Logger } from '@map-colonies/js-logger'; -import { SpatialReference, type Dataset, type Driver, type RasterBand } from 'gdal-async'; +import { type Dataset, type Driver, type RasterBand } from 'gdal-async'; import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; import type { ConfigType } from '@src/common/config'; import { RASTER_DATA_TYPES, SERVICES } from '@src/common/constants'; +import { UnsupportedSrsError } from '@src/common/errors'; import { GDAL_ASYNC, getPixelInfo, getResolutions, getSrsInfo, type GdalAsync } from '@src/common/gdal'; +import type { RasterFormats } from '@src/common/interfaces'; import { enrichLogContext } from '@src/common/logger'; import { areaOrPointSchema, @@ -22,14 +24,10 @@ import { srsNameSchema, } from '@src/common/schemas'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; -import { UnsupportedSrsError } from '@src/common/errors'; -import type { RasterFormats } from '@src/common/interfaces'; @injectable() export class GDALHandler implements FileHandler { public readonly name = GDALHandler.name; - private readonly defaultGeographicSrs: SpatialReference; - private readonly defaultProjectedSrs: SpatialReference; private readonly supportedFormatsMap: Record; private readonly sourceDir: string; @@ -38,8 +36,6 @@ export class GDALHandler implements FileHandler { @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(GDAL_ASYNC) private readonly gdal: GdalAsync ) { - this.defaultGeographicSrs = SpatialReference.fromEPSG(this.config.get('application.defaultGeographicSrsId')); - this.defaultProjectedSrs = SpatialReference.fromEPSG(this.config.get('application.defaultProjectedSrsId')); this.supportedFormatsMap = this.config.get('application.supportedFormatsMap'); this.sourceDir = this.config.get('storageExplorer.sourceDir'); } @@ -126,8 +122,6 @@ export class GDALHandler implements FileHandler { const { resolutionDegree, resolutionMeter } = getResolutions({ ...dataset.bands.getEnvelope(), ...pixelInfo, - targetGeographicSrs: this.defaultGeographicSrs, - targetProjectedSrs: this.defaultProjectedSrs, sourceSrs: srs, }); From a02d22b57bfe64b55c249e00f124f82f94d691ba Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:16:55 +0300 Subject: [PATCH 33/52] docs: add readme file --- README.md | 90 ++++++++++++++++++++----------------------------------- 1 file changed, 33 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index aa757fb..8993977 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,48 @@ -# Map Colonies typescript service template - ----------------------------------- - -This is a basic repo template for building new MapColonies web services in Typescript. - -> [!IMPORTANT] -> To regenerate the types on openapi change run the command `npm run generate:openapi-types`. - -> [!WARNING] -> After creating a new repo based on this template, you should delete the CODEOWNERS file. +# dem-gateway +Gateway for DEM resources manipulation ## 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 +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. -- feature request +### Adding a New Handler -- pull request + +Before all, check if existing handlers can fulfill your need. For example, [`gdal handler`](src/info//fileHandlers/gdal.ts) can handle most of raster file formats. -- github actions +Add a new file handler under `src/info/fileHandlers`. The file should contain a class implementing the `FileHandler` interface. +Verify that OpenAPI spec supports the file format associated with the new handler. OpenAPI validates input file formats through a RegEx pattern. -- on pull_request +Add a new or record into the [default.json](config/default.json) under `application.supportedFormatsMap` in the form of: -- LGTM +```json +{ + ... + "application": { + ... + "supportedFormatsMap": { + ... + "formatName": "drivername" + }, + ... + } +} -- test +``` +This configuration is used to map common format names into a given file handler internal name. For example, the gdal handler maps formats into gdal's internal driver name. -- lint +Finally, since DI is utilized a new record for the new handler should be added in [containerConfig.ts](src/containerConfig.ts): -- snyk +```javascript +const dependencies: InjectionObject[] = [ + ... + { token: 'FileHandler', provider: { useClass: NewHandler } } +]; +``` ## API + Checkout the OpenAPI spec [here](/openapi3.yaml) ## Installation @@ -74,33 +58,25 @@ npm install Clone the project ```bash - git clone https://link-to-project - ``` Go to the project directory ```bash - cd my-project - ``` Install dependencies ```bash - npm install - ``` Start the server ```bash - npm run start - ``` ## Running Tests @@ -108,17 +84,17 @@ npm run start To run tests, run the following command ```bash - npm run test - ``` To only run unit tests: + ```bash npm run test:unit ``` To only run integration tests: + ```bash npm run test:integration ``` From a92bbec62d3caef5847208e24db05c9f088939f0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:08:28 +0300 Subject: [PATCH 34/52] test: setup test configurations and vitest and OpenAPI validation --- config/test.json | 2 +- tests/configurations/initConfig.setup.ts | 3 + .../initCustomMatchers.setup.ts | 1 + .../configurations/initJestExtended.setup.ts | 4 + tests/configurations/initJestOpenapi.setup.ts | 4 - .../initZodSchemaFaker.setup.ts | 4 + tests/helpers/faker/info.faker.ts | 49 ++++ tests/helpers/faker/inputFiles.faker.ts | 17 ++ tests/helpers/faker/rasterMetadata.faker.ts | 240 ++++++++++++++++++ tests/helpers/generators/gdal.ts | 77 ++++++ tests/helpers/interfaces.ts | 10 + tests/helpers/matchers/index.d.ts | 7 + tests/helpers/matchers/openApiSpec.matcher.ts | 237 +++++++++++++++++ tests/helpers/setupOpenApiSpec.ts | 66 +++++ vitest.config.mts | 16 +- 15 files changed, 730 insertions(+), 7 deletions(-) create mode 100644 tests/configurations/initConfig.setup.ts create mode 100644 tests/configurations/initCustomMatchers.setup.ts create mode 100644 tests/configurations/initJestExtended.setup.ts create mode 100644 tests/configurations/initZodSchemaFaker.setup.ts create mode 100644 tests/helpers/faker/info.faker.ts create mode 100644 tests/helpers/faker/inputFiles.faker.ts create mode 100644 tests/helpers/faker/rasterMetadata.faker.ts create mode 100644 tests/helpers/generators/gdal.ts create mode 100644 tests/helpers/interfaces.ts create mode 100644 tests/helpers/matchers/index.d.ts create mode 100644 tests/helpers/matchers/openApiSpec.matcher.ts create mode 100644 tests/helpers/setupOpenApiSpec.ts diff --git a/config/test.json b/config/test.json index 8b94281..f362cfa 100644 --- a/config/test.json +++ b/config/test.json @@ -1,5 +1,5 @@ { "storageExplorer": { - "sourceDir": "" + "sourceDir": "/" } } diff --git a/tests/configurations/initConfig.setup.ts b/tests/configurations/initConfig.setup.ts new file mode 100644 index 0000000..523c7b2 --- /dev/null +++ b/tests/configurations/initConfig.setup.ts @@ -0,0 +1,3 @@ +import { initConfig } from '@src/common/config'; + +void initConfig(true); diff --git a/tests/configurations/initCustomMatchers.setup.ts b/tests/configurations/initCustomMatchers.setup.ts new file mode 100644 index 0000000..1594057 --- /dev/null +++ b/tests/configurations/initCustomMatchers.setup.ts @@ -0,0 +1 @@ +import '../helpers/matchers/openApiSpec.matcher'; diff --git a/tests/configurations/initJestExtended.setup.ts b/tests/configurations/initJestExtended.setup.ts new file mode 100644 index 0000000..e6a82c6 --- /dev/null +++ b/tests/configurations/initJestExtended.setup.ts @@ -0,0 +1,4 @@ +import { expect } from 'vitest'; +import * as matchers from 'jest-extended'; + +expect.extend(matchers); diff --git a/tests/configurations/initJestOpenapi.setup.ts b/tests/configurations/initJestOpenapi.setup.ts index 72d9e9f..90465c7 100644 --- a/tests/configurations/initJestOpenapi.setup.ts +++ b/tests/configurations/initJestOpenapi.setup.ts @@ -1,12 +1,8 @@ /* eslint-disable */ -import path from 'node:path'; import { expect } from 'vitest'; -import jestOpenApi from 'jest-openapi'; //@ts-ignore globalThis.expect = expect; -jestOpenApi(path.join(process.cwd(), 'openapi3.yaml')); - //@ts-ignore globalThis.expect = undefined as any; // Reset global expect to avoid conflicts with other test frameworks diff --git a/tests/configurations/initZodSchemaFaker.setup.ts b/tests/configurations/initZodSchemaFaker.setup.ts new file mode 100644 index 0000000..ebf2a39 --- /dev/null +++ b/tests/configurations/initZodSchemaFaker.setup.ts @@ -0,0 +1,4 @@ +import { setFaker } from 'zod-schema-faker/v4'; +import { faker } from '@faker-js/faker'; + +setFaker(faker); diff --git a/tests/helpers/faker/info.faker.ts b/tests/helpers/faker/info.faker.ts new file mode 100644 index 0000000..8af2590 --- /dev/null +++ b/tests/helpers/faker/info.faker.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { fake } from 'zod-schema-faker/v4'; +import type { RASTER_DATA_TYPES } from '@src/common/constants'; +import { + areaOrPointSchema, + noDataValueSchema, + pixelDataTypesSchema, + resolutionDegreeSchema, + resolutionMeterSchema, + srsIdSchema, + srsNameSchema, +} from '@src/common/schemas'; +import { createGDALGeotiffCOGRaster, type CreateGDALRasterOptions } from '../generators/gdal'; +import type { DemType, InfoRequestBody, InfoResponse } from '../interfaces'; +import { createGDALRasterMetadata } from './rasterMetadata.faker'; + +export const createInfoMetadata = (options: DemType): CreateGDALRasterOptions => { + const { demType } = options; + + const rasterMetadata = createGDALRasterMetadata({ demType }); + return rasterMetadata; +}; + +export const createInfoResource = async (options: CreateGDALRasterOptions): Promise> => { + let demFilePath: string; + switch (options.driverName) { + case 'gtiff': { + demFilePath = await createGDALGeotiffCOGRaster(options); + break; + } + default: { + throw new Error(`Unsupported driver '${options.driverName}' for info resource creation`); + } + } + return { demFilePath }; +}; + +export const generateInfoResponse = (format: keyof typeof RASTER_DATA_TYPES): InfoResponse => { + const infoResponseSchema = z.strictObject({ + areaOrPoint: areaOrPointSchema, + resolutionDegree: resolutionDegreeSchema, + resolutionMeter: resolutionMeterSchema, + srsId: srsIdSchema, + srsName: srsNameSchema, + dataType: pixelDataTypesSchema(format), + noDataValue: noDataValueSchema, + }); + return fake(infoResponseSchema); +}; diff --git a/tests/helpers/faker/inputFiles.faker.ts b/tests/helpers/faker/inputFiles.faker.ts new file mode 100644 index 0000000..8baa279 --- /dev/null +++ b/tests/helpers/faker/inputFiles.faker.ts @@ -0,0 +1,17 @@ +import { merge } from 'lodash'; +import { fake } from 'zod-schema-faker/v4'; +import { z } from 'zod/v4'; +import type { paths } from '@openapi'; + +// TODO: get schema from dem-shared +const demFilePathSchema = z.strictObject({ + demFilePath: z.string().regex(new RegExp('^(\\/?[\\w-]+)(\\/[\\w-]+)*\\/[\\wא-ת\\.-]+\\.(tif)$')), // TODO: extract regex pattern to dem-shared +}); + +export type DemFilePath = Pick; + +export const createDemFilePath = (overrides: Partial = {}): DemFilePath => { + const demFilePath = fake(demFilePathSchema) satisfies DemFilePath; + + return merge(demFilePath, overrides); +}; diff --git a/tests/helpers/faker/rasterMetadata.faker.ts b/tests/helpers/faker/rasterMetadata.faker.ts new file mode 100644 index 0000000..9bc7817 --- /dev/null +++ b/tests/helpers/faker/rasterMetadata.faker.ts @@ -0,0 +1,240 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { faker } from '@faker-js/faker'; +import { drivers, SpatialReference } from 'gdal-async'; +import { merge } from 'lodash'; +import { z } from 'zod'; +import { fake } from 'zod-schema-faker/v4'; +import { getConfig } from '@src/common/config'; +import { RASTER_DATA_TYPES } from '@src/common/constants'; +import { getResolutions, getSrsGeographicBounds, getSrsName, transformPoint } from '@src/common/gdal'; +import { type PixelDataType } from '@src/common/interfaces'; +import { + areaOrPointSchema, + blockSizeSchema, + compressionSchema, + layoutSchema, + overviewsCountSchema, + resolutionDegreeSchema, + srsIdSchema, +} from '@src/common/schemas'; +import type { CreateGDALRasterOptions } from '@tests/helpers/generators/gdal'; +import type { DemType, InfoGeoTiff } from '@tests/helpers/interfaces'; + +const MAX_FLOAT32 = (2 - Math.pow(2, -23)) * Math.pow(2, 127); +const MAX_INT64 = 2n ** 63n; +const MAX_PIXELS_DIM = 10; // keep as low number of pixels for quicker performance in tests + +const config = getConfig(); +const supportedFormatsMap: Record = config.get('application.supportedFormatsMap'); + +const regularGridMetadata = z.strictObject({ + areaOrPoint: areaOrPointSchema, + srsId: srsIdSchema, +}); + +const geotiffMetadataSchema = z.strictObject({ + ...regularGridMetadata.shape, +}); + +const geotiffGDALMetadataSchema = z.strictObject({ + ...geotiffMetadataSchema.shape, + blockSize: blockSizeSchema.shape.x, + compression: compressionSchema, + layout: layoutSchema, +}); + +const createPixelDataTypeValue = (dataType: PixelDataType): number => { + switch (dataType) { + // case 'Byte': { + // const value = faker.number.int({ min: 0, max: 255 }); + // return Number(new Uint8Array([value])[0]); + // } + // case 'CInt16': { + // const [value1, value2] = [faker.number.int({ min: -32768, max: 32767 }), faker.number.int({ min: -32768, max: 32767 })]; + // return Number(new Int16Array([value1, value2])[0]); + // } + // case 'CInt32': { + // const [value1, value2] = [faker.number.int({ min: -2147483648, max: 2147483647 }), faker.number.int({ min: -2147483648, max: 2147483647 })]; + // return Number(new Int32Array([value1, value2])[0]); + // } + // case 'CFloat16': { + // const [value1, value2] = [faker.number.float({ min: -65504, max: 65504 }), faker.number.float({ min: -65504, max: 65504 })]; + // return Number(new Float16Array([value1, value2])[0]); + // } + // case 'CFloat32': { + // const [value1, value2] = [ + // faker.number.float({ min: -MAX_FLOAT32, max: MAX_FLOAT32 }), + // faker.number.float({ min: -MAX_FLOAT32, max: MAX_FLOAT32 }), + // ]; + // return Number(new Float32Array([value1, value2])[0]); + // } + // case 'CFloat64': { + // const [value1, value2] = [ + // faker.number.float({ min: -Number.MAX_VALUE, max: Number.MAX_VALUE }), + // faker.number.float({ min: -Number.MAX_VALUE, max: Number.MAX_VALUE }), + // ]; + // return Number(new Float64Array([value1, value2])[0]); + // } + case 'Int8': { + const value = faker.number.int({ min: -128, max: 127 }); + return Number(new Int8Array([value])[0]); + } + case 'Int16': { + const value = faker.number.int({ min: -32768, max: 32767 }); + return Number(new Int16Array([value])[0]); + } + case 'Int32': { + const value = faker.number.int({ min: -2147483648, max: 2147483647 }); + return Number(new Int32Array([value])[0]); + } + case 'Int64': { + const value = faker.number.bigInt({ min: -MAX_INT64, max: MAX_INT64 - 1n }); + return Number(new BigInt64Array([BigInt(value)])[0]); + } + // case 'UInt16': { + // const value = faker.number.int({ min: 0, max: 65535 }); + // return Number(new Uint16Array([value])[0]); + // } + // case 'UInt32': { + // const value = faker.number.int({ min: 0, max: 4294967295 }); + // return Number(new Uint32Array([value])[0]); + // } + // case 'UInt64': { + // const value = faker.number.bigInt({ min: 0n }); + // return Number(new BigUint64Array([BigInt(value)])[0]); + // } + case 'Float16': { + const value = faker.number.float({ min: -65504, max: 65504 }); + return Number(new Float16Array([value])[0]); + } + case 'Float32': { + const value = faker.number.float({ min: -MAX_FLOAT32, max: MAX_FLOAT32 }); + return Number(new Float32Array([value])[0]); + } + case 'Float64': { + // faker overflows to infinity in inner calculations thats we get half the range and then decide on the sign + const value = faker.helpers.arrayElement([1, -1]) * faker.number.float({ min: 0, max: Number.MAX_VALUE }); + return Number(new Float64Array([value])[0]); + } + default: + throw new Error('Unsupported pixel data type'); + } +}; + +export const createGDALRasterMetadata = (options: Partial & DemType): CreateGDALRasterOptions => { + const { demType, ...overrides } = options; + const driverName = supportedFormatsMap[demType]; + const supportedDataTypes = RASTER_DATA_TYPES[demType]; + if (driverName === undefined) throw new Error(`Unsupported dem type: ${demType}`); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const driverMetadata = drivers.get(driverName).getMetadata() as unknown as { DMD_CREATIONDATATYPES: string }; + + const driverPixelDataTypes = driverMetadata.DMD_CREATIONDATATYPES.split(' ').filter((driverPixelDataType): driverPixelDataType is PixelDataType => + (supportedDataTypes as string[]).includes(driverPixelDataType) + ); + if (driverPixelDataTypes.length === 0) throw new Error(`Unsupported data types for dem type: ${demType}`); + const dataType = faker.helpers.arrayElement(driverPixelDataTypes); + const fakePixelDataTypeValue = createPixelDataTypeValue(dataType); + const noDataValue = faker.helpers.arrayElement([fakePixelDataTypeValue, Number.NaN]); + + // TODO: adjust to support not only geotiff/COG + const rasterMetadata = fake(geotiffGDALMetadataSchema) satisfies Omit< + CreateGDALRasterOptions, + | 'dataType' + | 'driverName' + | 'maxX' + | 'maxY' + | 'minX' + | 'minY' + | 'noDataValue' + | 'overviewsCount' + | 'pixelHeight' + | 'pixelWidth' + | 'resolutionDegree' + | 'resolutionMeter' + | 'srsName' + | 'xSize' + | 'ySize' + >; + + const sourceSrs = SpatialReference.fromEPSG(4326); // source bbox is in WGS84 (EPSG:4326) + const targetSrs = SpatialReference.fromEPSG(rasterMetadata.srsId); + + const resolutionDegree = fake(resolutionDegreeSchema); + const [geoSrsMinX, geoSrsMinY, geoSrsMaxX, geoSrsMaxY] = getSrsGeographicBounds({ srsId: rasterMetadata.srsId }); + const [pixelsX, pixelsY] = [ + Math.max(1, Math.min(MAX_PIXELS_DIM, Math.floor((geoSrsMaxX - geoSrsMinX) / resolutionDegree))), + Math.max(1, Math.min(MAX_PIXELS_DIM, Math.floor((geoSrsMaxY - geoSrsMinY) / resolutionDegree))), + ]; + const [geoWidth, geoHeight] = [pixelsX * resolutionDegree, pixelsY * resolutionDegree]; + const [geoMinX, geoMinY] = [ + faker.number.float({ min: geoSrsMinX, max: geoSrsMaxX - geoWidth }), + faker.number.float({ min: geoSrsMinY, max: geoSrsMaxY - geoHeight }), + ]; + const [geoMaxX, geoMaxY] = [geoMinX + geoWidth, geoMinY + geoHeight]; + + const [{ x: minX }, { x: maxX }, { y: minY }, { y: maxY }] = [ + transformPoint({ + point: { x: geoMinX, y: (geoMinY + geoMaxY) / 2 }, + sourceSrs, + targetSrs, + }), + transformPoint({ + point: { x: geoMaxX, y: (geoMinY + geoMaxY) / 2 }, + sourceSrs, + targetSrs, + }), + transformPoint({ + point: { x: (geoMinX + geoMaxX) / 2, y: geoMinY }, + sourceSrs, + targetSrs, + }), + transformPoint({ + point: { x: (geoMinX + geoMaxX) / 2, y: geoMaxY }, + sourceSrs, + targetSrs, + }), + ]; + + const pixelHeight = Math.abs(maxY - minY) / pixelsY; + const pixelWidth = Math.abs(maxX - minX) / pixelsX; + + const overviewsCount = fake( + overviewsCountSchema({ + blockSize: { x: rasterMetadata.blockSize, y: rasterMetadata.blockSize }, + size: { x: pixelsX, y: pixelsY }, + }) + ); + + const resolutions = getResolutions({ + maxX, + maxY, + minX, + minY, + pixelHeight, + pixelWidth, + sourceSrs: rasterMetadata.srsId, + }); + + return merge( + rasterMetadata, + { + ...resolutions, + driverName, + dataType, + maxX, + maxY, + minX, + minY, + noDataValue: Number.isNaN(noDataValue) ? 'NaN' : noDataValue, + overviewsCount, + pixelHeight, + pixelWidth, + srsName: getSrsName(rasterMetadata.srsId), + xSize: pixelsX, + ySize: pixelsY, + }, + overrides + ); +}; diff --git a/tests/helpers/generators/gdal.ts b/tests/helpers/generators/gdal.ts new file mode 100644 index 0000000..f76bee0 --- /dev/null +++ b/tests/helpers/generators/gdal.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { join } from 'node:path'; +import { faker } from '@faker-js/faker'; +import { drivers, SpatialReference, type StringOptions } from 'gdal-async'; +import type { z } from 'zod'; +import type { blockSizeSchema, compressionSchema, layoutSchema, overviewsCountSchema } from '@src/common/schemas'; +import type { InfoGeoTiff } from '../interfaces'; + +interface GDALCreationOptions { + driverName: string; + xSize: number; + ySize: number; + blockSize: z.infer['x']; + compression: z.infer; + layout: z.infer; + overviewsCount: z.infer>; + creationOptions?: StringOptions; +} + +const BAND_COUNT = 1; + +export type CreateGDALRasterOptions = GDALCreationOptions & + Omit & { + minX: number; + minY: number; + maxX: number; + maxY: number; + noDataValue: number | 'NaN'; // NaN is provided as string literal since JSON response cannot encode NaN numberically + pixelWidth: number; + pixelHeight: number; + }; + +/** + * @returns Path to the COG geotiff + */ +export const createGDALGeotiffCOGRaster = async (options: CreateGDALRasterOptions): Promise => { + const { + areaOrPoint, + blockSize, + compression, + dataType, + layout, + maxY, + minX, + noDataValue, + overviewsCount, + pixelHeight, + pixelWidth, + srsId, + creationOptions = {}, + xSize, + ySize, + } = options; + + const driverGeoTiff = drivers.get('MEM'); + const filePath = join(__dirname, '../../integration/tmp', faker.system.commonFileName('tif')); + + const dataset = await driverGeoTiff.createAsync('', xSize, ySize, BAND_COUNT, dataType, creationOptions); + + const srs = SpatialReference.fromEPSG(srsId); + dataset.srs = srs; + dataset.geoTransform = [minX, pixelWidth, 0, maxY, 0, -pixelHeight]; + dataset.bands.forEach((band) => { + band.noDataValue = Number(noDataValue); + }); + dataset.setMetadata({ AREA_OR_POINT: areaOrPoint }); + await dataset.buildOverviewsAsync('NEAREST', [overviewsCount]); + + const cogDriver = drivers.get('COG'); + const cogOptions = { LAYOUT: layout, COMPRESS: compression, BLOCKSIZE: blockSize }; + + const finalDs = await cogDriver.createCopyAsync(filePath, dataset, cogOptions, false); + finalDs.close(); + dataset.close(); + return filePath; +}; diff --git a/tests/helpers/interfaces.ts b/tests/helpers/interfaces.ts new file mode 100644 index 0000000..75333a7 --- /dev/null +++ b/tests/helpers/interfaces.ts @@ -0,0 +1,10 @@ +import type { RasterFormats } from '@src/common/interfaces'; +import type { components, paths } from '@src/openapi'; + +export type InfoGeoTiff = components['schemas']['InfoGeoTiff']; +export type InfoResponse = paths['/info']['post']['responses']['200']['content']['application/json']; +export type InfoRequestBody = paths['/info']['post']['requestBody']['content']['application/json']; + +export interface DemType { + demType: RasterFormats; +} diff --git a/tests/helpers/matchers/index.d.ts b/tests/helpers/matchers/index.d.ts new file mode 100644 index 0000000..e30d796 --- /dev/null +++ b/tests/helpers/matchers/index.d.ts @@ -0,0 +1,7 @@ +import 'vitest'; + +declare module 'vitest' { + interface Matchers extends CustomMatchers { + toSatisfyApiSpec: () => T; + } +} diff --git a/tests/helpers/matchers/openApiSpec.matcher.ts b/tests/helpers/matchers/openApiSpec.matcher.ts new file mode 100644 index 0000000..f835f8d --- /dev/null +++ b/tests/helpers/matchers/openApiSpec.matcher.ts @@ -0,0 +1,237 @@ +import { resolve } from 'node:path'; +import type { RequestSender } from '@map-colonies/openapi-helpers/requestSender'; +import { dereference } from '@readme/openapi-parser'; +import addFormats from 'ajv-formats'; +import { Ajv2020, type ErrorObject } from 'ajv/dist/2020'; +import type { OpenAPIV3_1 } from 'openapi-types'; +import { beforeAll, expect } from 'vitest'; +import type { operations, paths } from '@src/openapi'; +import { matchRoute } from '../setupOpenApiSpec'; + +type ExpectationResult = ReturnType; +type MatcherState = ReturnType<(typeof expect)['getState']>; +type RawMatcherFn = Parameters<(typeof expect)['extend']>[0][number]; +type SupertestResponse = Awaited['sendRequest']>>; +type TestContext = { matcherName: string } & Pick & MatcherState['utils']; + +const strictAjv = new Ajv2020({ + allErrors: true, + discriminator: true, + strict: true, + strictRequired: false, + useDefaults: true, +}); +addFormats(strictAjv); + +const coerciveAjv = new Ajv2020({ + allErrors: true, + coerceTypes: true, + useDefaults: true, +}); +addFormats(coerciveAjv); + +const getHintedErrorOnProperty = ( + { received, property, type }: { received: unknown; property: string; type: string }, + { + isNot, + matcherHint, + matcherName, + // eslint-disable-next-line @typescript-eslint/naming-convention + RECEIVED_COLOR, + printReceived, + printWithType, + }: TestContext +): string => + matcherErrorMessage( + matcherHint(matcherName, 'response', 'expected', { isNot }), + `${RECEIVED_COLOR('received')} must have '${property}' property with '${type}' type`, + printWithType('Received', received, printReceived) + ); + +const isSupertestResponse = (received: unknown, context: TestContext): received is SupertestResponse => { + const { + isNot, + matcherHint, + matcherName, + // eslint-disable-next-line @typescript-eslint/naming-convention + RECEIVED_COLOR, + printReceived, + printWithType, + } = context; + if (typeof received !== 'object' || received === null) { + throw new TypeError( + matcherErrorMessage( + matcherHint(matcherName, 'object', 'expected', { isNot }), + `${RECEIVED_COLOR('Received:')} ${printReceived(received)}`, + printWithType('Received', received, printReceived) + ) + ); + } + + if (!('status' in received && typeof received.status === 'number')) { + throw new TypeError(getHintedErrorOnProperty({ received, property: 'status', type: 'number' }, context)); + } + + if (!('type' in received && typeof received.type === 'string')) { + throw new TypeError(getHintedErrorOnProperty({ received, property: 'type', type: 'string' }, context)); + } + + if (!('request' in received && typeof received.request === 'object' && received.request !== null)) { + throw new TypeError(getHintedErrorOnProperty({ received, property: 'request', type: 'object' }, context)); + } + + const request = received.request; + + if (!('method' in request && typeof request.method === 'string')) { + throw new TypeError(getHintedErrorOnProperty({ received, property: 'request.method', type: 'string' }, context)); + } + + if (!('url' in request && typeof request.method === 'string')) { + throw new TypeError(getHintedErrorOnProperty({ received, property: 'request.url', type: 'string' }, context)); + } + + return true; +}; + +const parseOpenApi = async (options?: { openApiDoc: string }): Promise => { + const { openApiDoc } = options ?? { openApiDoc: 'openapi3.yaml' }; + const specPath = resolve(process.cwd(), openApiDoc); + const openApiDocument = await dereference(specPath); + return openApiDocument; +}; + +const findSchemaInSpec = ( + openApiDocument: OpenAPIV3_1.Document, + path: string, + method: string, + status: number, + media?: string +): Pick & Pick => { + if (openApiDocument.paths?.[path] === undefined) { + throw new Error(`Path '${path}' not found in spec`); + } + + const pathItem = openApiDocument.paths[path]; + const operation = pathItem[method.toLowerCase() as OpenAPIV3_1.HttpMethods]; + + if (!operation) { + throw new Error(`Method '${method}' not defined for '${path}'`); + } + + const response = operation.responses[String(status)]; + + if (!response) throw new Error(`Could not find a matching response for '${method} ${path} ${status}'`); + if ('$ref' in response) throw new Error('Responses should have been dereferenced'); + + const schema = media !== undefined ? response.content?.[media]?.schema : undefined; + const headers = response.headers; + + return { schema, headers }; +}; + +const extractRequestInfo = (received: SupertestResponse): { mediaType: string; method: string; path: string } => { + const { + type: mediaType, + request: { method, url }, + } = received; + const path = new URL(url).pathname; + + return { mediaType, method, path }; +}; + +const matcherErrorMessage = (...args: string[]): string => args.join('\n'); + +const toSatisfyApiSpecFactory = (openApiDocument: OpenAPIV3_1.Document): RawMatcherFn => { + function toSatisfyApiSpec(this: MatcherState, received: unknown): ExpectationResult { + const matcherName = 'toSatisfyApiSpec'; + const { isNot, utils } = this; + const { matcherHint, printReceived, RECEIVED_COLOR } = utils; + + try { + if (!isSupertestResponse(received, { ...utils, isNot, matcherName })) throw new Error(); + const { mediaType: requestMediaType, method: requestMethod, path: requestPath } = extractRequestInfo(received); + const status = received.status; + + // Find the matching OpenAPI path (handles path parameters like {id}) + let matchedOpenApiPath: string | undefined; + if (openApiDocument.paths) { + matchedOpenApiPath = Object.keys(openApiDocument.paths).find((openApiPath) => matchRoute(openApiPath, requestPath)); + } + + if (matchedOpenApiPath === undefined) { + throw new Error(`No matching path found in OpenAPI spec for request path: '${requestPath}'`); + } + + // Find and validate the schema + const { schema, headers } = findSchemaInSpec(openApiDocument, matchedOpenApiPath, requestMethod, status, requestMediaType); + let passSchema = true; + let passHeaders = true; + let schemaErrors: ErrorObject[] | null | undefined; + let headersErrors: ErrorObject[] | null | undefined; + + if (schema && 'body' in received) { + const validateSchema = strictAjv.compile(schema); + passSchema = validateSchema(received.body); + schemaErrors = validateSchema.errors; + } + + if (headers && 'headers' in received) { + const headersSchema = { + type: 'object', + properties: Object.fromEntries( + Object.entries(headers as { [header: string]: OpenAPIV3_1.HeaderObject }).map(([key, value]) => { + if (!(value.schema || value.content?.[requestMediaType]?.schema)) { + throw new Error(`OpenAPI spec headers should contain either 'content' or 'schema'`); + } + return [key.toLowerCase(), value.schema ?? value.content?.[requestMediaType]?.schema]; + }) + ), + required: Object.entries(headers as { [header: string]: OpenAPIV3_1.HeaderObject }) + .filter(([, value]) => value.required === true) + .map(([key]) => key.toLowerCase()), + additionalProperties: true, // Accept extra headers that are typically undeclared + }; + const validateHeaders = coerciveAjv.compile(headersSchema); + passHeaders = validateHeaders(received.headers); + headersErrors = validateHeaders.errors; + } + + const pass = passSchema && passHeaders; + const errors = [ + !passSchema && strictAjv.errorsText(schemaErrors, { dataVar: 'response.body' }), + !passHeaders && coerciveAjv.errorsText(headersErrors, { dataVar: 'response.headers' }), + ] + .filter(Boolean) + .join(';'); + + return { + pass, + message: (): string => + pass + ? `Expected response not to match OpenAPI spec for ${requestMethod.toUpperCase()} ${matchedOpenApiPath} ${status}` + : `OAS 3.1 Validation Error for ${requestMethod.toUpperCase()} ${matchedOpenApiPath} ${status}: ${errors}`, + }; + } catch (error: unknown) { + return { + pass: false, + message: (): string => `${matcherHint(matcherName, 'received', '', { isNot })} + Error: ${error instanceof Error ? error.message : JSON.stringify(error)} + ${RECEIVED_COLOR('Received:')} ${printReceived(received)}`, + }; + } + } + return toSatisfyApiSpec; +}; + +beforeAll(async () => { + try { + const openApiDocument = await parseOpenApi(); + const toSatisfyApiSpec = toSatisfyApiSpecFactory(openApiDocument); + expect.extend({ + toSatisfyApiSpec, + }); + } catch (err) { + console.error(err); + throw err; + } +}); diff --git a/tests/helpers/setupOpenApiSpec.ts b/tests/helpers/setupOpenApiSpec.ts new file mode 100644 index 0000000..affe1c5 --- /dev/null +++ b/tests/helpers/setupOpenApiSpec.ts @@ -0,0 +1,66 @@ +import { resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; +import type { OpenAPIV3_1 } from 'openapi-types'; +import { dereference } from '@readme/openapi-parser'; + +// eslint-disable-next-line no-useless-escape +const urlTemplateAllowedValues = `([A-Za-z0-9\-\._~]|%[0-9A-Fa-f]{2}|[!$&'()*+,;=]|[:@\/])*`; + +export type RemoveNever = { + [K in keyof T as T[K] extends undefined ? never : K]: T[K]; +}; +export type OasMethodsOfPath = keyof Omit, 'parameters'>; +export type OasOperationOfMethod> = P[T][M]; +export type OasStatusCodesOfMethod> = + OasOperationOfMethod extends Record ? keyof OasOperationOfMethod['responses'] : never; + +// @readme/openapi-parser +export type OpenApiDocument = Awaited>; +export type Paths = NonNullable; +export type Path = NonNullable; +export type PathKeys = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch'; +export type Method = NonNullable; +export type Responses = Method['responses']; +export type Response = NonNullable; +export type Content = NonNullable['content']>; +export type MediaType = Content[number | string]; +export type Schema = MediaType['schema']; + +/** + * Basic route matcher: converts "/users/{id}" in spec to regex + */ +export function matchRoute(openapiPath: string, actualPath: string): boolean { + const regexPath = openapiPath.replace(/{.+}/g, urlTemplateAllowedValues); + + return new RegExp(`^${regexPath}$`).test(actualPath); +} + +export const parseOpenApi = async (options?: { openApiDoc: string }): Promise => { + const { openApiDoc } = options ?? { openApiDoc: 'openapi3.yaml' }; + const specPath = resolve(process.cwd(), openApiDoc); + const spec = readFileSync(specPath, 'utf8'); + const openApiDocument = await dereference(spec); + return openApiDocument; +}; + +export function findSchemaInSpec(spec: OpenAPIV3_1.Document, path: string, method: string, status: number): OpenAPIV3_1.SchemaObject { + if (spec.paths?.[path] === undefined) { + throw new Error(`Path '${path}' not found in spec.`); + } + + const pathItem = spec.paths[path]; + const operation = pathItem[method.toLowerCase() as OpenAPIV3_1.HttpMethods]; + + if (!operation) { + throw new Error(`Method '${method}' not defined for '${path}'.`); + } + + const response = operation.responses[String(status)] as OpenAPIV3_1.ResponseObject; + const content = response.content?.['application/json']; + + if (!content?.schema) { + throw new Error(`No JSON schema found for '${method} ${path} ${status}'.`); + } + + return content.schema; +} diff --git a/vitest.config.mts b/vitest.config.mts index e032130..0b54b21 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -25,7 +25,12 @@ export default defineConfig({ { test: { name: 'unit', - setupFiles: ['./tests/configurations/initJestOpenapi.setup.ts', './tests/configurations/vite.setup.ts'], + setupFiles: [ + './tests/configurations/initConfig.setup.ts', + './tests/configurations/initJestExtended.setup.ts', + './tests/configurations/initZodSchemaFaker.setup.ts', + './tests/configurations/vite.setup.ts', + ], include: ['tests/unit/**/*.spec.ts'], environment: 'node', }, @@ -36,7 +41,14 @@ export default defineConfig({ { test: { name: 'integration', - setupFiles: ['./tests/configurations/initJestOpenapi.setup.ts', './tests/configurations/vite.setup.ts'], + setupFiles: [ + './tests/configurations/initConfig.setup.ts', + './tests/configurations/initCustomMatchers.setup.ts', + './tests/configurations/initJestExtended.setup.ts', + './tests/configurations/initZodSchemaFaker.setup.ts', + './tests/configurations/initJestOpenapi.setup.ts', + './tests/configurations/vite.setup.ts', + ], include: ['tests/integration/**/*.spec.ts'], environment: 'node', }, From e08933bf65c8ac5eb439e02f51144bc742686d56 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:14:37 +0300 Subject: [PATCH 35/52] test: remove boilerplate tests files --- .../anotherResourceName.spec.ts | 56 --------------- .../resourceName/resourceName.spec.ts | 70 ------------------- 2 files changed, 126 deletions(-) delete mode 100644 tests/integration/anotherResource/anotherResourceName.spec.ts delete mode 100644 tests/integration/resourceName/resourceName.spec.ts diff --git a/tests/integration/anotherResource/anotherResourceName.spec.ts b/tests/integration/anotherResource/anotherResourceName.spec.ts deleted file mode 100644 index 23ada25..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, RequestSender } from '@map-colonies/openapi-helpers/requestSender'; -import { 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/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts deleted file mode 100644 index 628f854..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, RequestSender } from '@map-colonies/openapi-helpers/requestSender'; -import { 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); - }); - }); -}); From 3584c16366f85474bc2b7283f67ead05a5b3f9ed Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:15:11 +0300 Subject: [PATCH 36/52] test: add info endpoint --- tests/integration/info/info.spec.ts | 712 ++++++++++++++++++++++++++++ 1 file changed, 712 insertions(+) create mode 100644 tests/integration/info/info.spec.ts diff --git a/tests/integration/info/info.spec.ts b/tests/integration/info/info.spec.ts new file mode 100644 index 0000000..8c67cca --- /dev/null +++ b/tests/integration/info/info.spec.ts @@ -0,0 +1,712 @@ +import * as nodeFsPromise from 'node:fs/promises'; +import { faker } from '@faker-js/faker'; +import { jsLogger } from '@map-colonies/js-logger'; +import { createRequestSender, RequestSender } from '@map-colonies/openapi-helpers/requestSender'; +import { trace } from '@opentelemetry/api'; +import { Dataset, DatasetBands, Driver } from 'gdal-async'; +import * as gdalAsync from 'gdal-async'; +import httpStatusCodes from 'http-status-codes'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { operations, paths } from '@openapi'; +import { getApp } from '@src/app'; +import { getConfig } from '@src/common/config'; +import { RASTER_DATA_TYPES, SERVICES } from '@src/common/constants'; +import { createInfoResource } from '@tests/helpers/faker/info.faker'; +import { createInfoMetadata } from '@tests/helpers/faker/info.faker'; +import { hasKey } from '@src/common/schemas'; + +vi.mock('node:fs/promises', { spy: true }); + +const seed = process.env.TEST_SEED ?? Math.floor(Math.random() * 1000000); +faker.seed(Number(seed)); +console.info(`Test seed: ${seed}`); + +const config = getConfig(); + +const supportedFormatsMap: Record = config.get('application.supportedFormatsMap'); + +const happyTests = Object.keys(supportedFormatsMap).map((supportedFormat) => { + if (!hasKey(supportedFormat, RASTER_DATA_TYPES)) { + throw new Error(`Format '${supportedFormat}' is not part of service's API`); + } + return { demType: supportedFormat }; +}); + +describe('POST /info', () => { + type InfoRequestBody = paths['/info']['post']['requestBody']['content']['application/json']; + let requestSender: RequestSender; + + beforeEach(async () => { + 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); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Happy Path', () => { + it.for(happyTests)('should return 200 status code and respond with dem info for $demType', async ({ demType }) => { + const metadata = createInfoMetadata({ demType }); + const demFilePath = await createInfoResource(metadata); + const { areaOrPoint, resolutionDegree, resolutionMeter, srsId, srsName, dataType, noDataValue } = metadata; + const expected = { areaOrPoint, resolutionDegree, resolutionMeter, srsId, srsName, dataType, noDataValue }; + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + expect(response.body).toStrictEqual(expected); + expect.assertions(3); + }); + }); + + describe('Bad Path', () => { + type InfoResponseBody = paths['/info']['post']['responses'][400]['content']['application/json']; + + it('should return 400 status code and respond with error message when request body is incorrect', async () => { + const response = await requestSender.info({ requestBody: false as unknown as InfoRequestBody }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBody).message).toBe('Unexpected token \'f\', "false" is not valid JSON'); + expect.assertions(3); + }); + + it('should return 400 status code and respond with error message when request body has redundant properties', async () => { + const response = await requestSender.info({ + requestBody: { demFilePath: '/path/to/tif.tif', redundantProperty: '' } as unknown as InfoRequestBody, + }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBody).message).toBe('request/body must NOT have unevaluated properties'); + expect.assertions(3); + }); + + it('should return 400 status code and respond with error message when demFilePath property in request body has incorrect type', async () => { + const response = await requestSender.info({ + requestBody: { demFilePath: 0 } as unknown as InfoRequestBody, + }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBody).message).toBe('request/body/demFilePath must be string'); + expect.assertions(3); + }); + + it('should return 400 status code and respond with error message when demFilePath property in request body does not follows validation pattern - file in root dir', async () => { + const response = await requestSender.info({ + requestBody: { demFilePath: '/file.tif' }, + }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBody).message).toBe( + 'request/body/demFilePath must match pattern "^(\\/?[\\w-]+)(\\/[\\w-]+)*\\/[\\wא-ת\\.-]+\\.(tif)$"' + ); + expect.assertions(3); + }); + + it('should return 400 status code and respond with error message when demFilePath property in request body does not follows validation pattern - path contains invalid characters', async () => { + const response = await requestSender.info({ + requestBody: { demFilePath: '/fold r/file.tif' }, + }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBody).message).toBe( + 'request/body/demFilePath must match pattern "^(\\/?[\\w-]+)(\\/[\\w-]+)*\\/[\\wא-ת\\.-]+\\.(tif)$"' + ); + expect.assertions(3); + }); + + it('should return 400 status code and respond with error message when demFilePath property in request body does not follows validation pattern - incorrect extension', async () => { + const response = await requestSender.info({ + requestBody: { demFilePath: '/folder/file.tiff' }, + }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBody).message).toBe( + 'request/body/demFilePath must match pattern "^(\\/?[\\w-]+)(\\/[\\w-]+)*\\/[\\wא-ת\\.-]+\\.(tif)$"' + ); + expect.assertions(3); + }); + }); + + describe('Sad Path', () => { + type InfoResponseBodyNotFound = paths['/info']['post']['responses'][404]['content']['application/json']; + + // TODO: ADD NEW METADATA VALIDATION TESTS + + it('should return 404 status code and respond with unsuccessful message when file does not exist', async () => { + const demFilePath = '/non/existent/file.tif'; + const response = await requestSender.info({ requestBody: { demFilePath } }); + + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyNotFound).message).toStartWith(`Cannot find file: ${demFilePath}. got error:`); + expect.assertions(3); + }); + + it('should return 404 status code and respond with unsuccessful message when file cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + vi.mocked(nodeFsPromise.access).mockRejectedValueOnce(new Error()); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyNotFound).message).toStartWith(`Cannot find file: ${demFilePath.demFilePath}. got error:`); + expect.assertions(3); + }); + + type InfoResponseBodyUnprocessableContent = paths['/info']['post']['responses'][422]['content']['application/json']; + + it('should return 422 status code and respond with unsuccessful message when supported extensions do not include provided file', async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + vi.spyOn(Driver.prototype, 'getMetadata').mockReturnValueOnce({ DMD_EXTENSION: 'tiff' }); + + const response = await requestSender.info({ requestBody: { demFilePath: '/non/existent/file.tif' } }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe('No handler found for file: /non/existent/file.tif'); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when layout in image structure metadata is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported image structure metadata (LAYOUT and COMPRESSION)'; + vi.spyOn(gdalAsync.Dataset.prototype, 'getMetadataAsync') + // eslint-disable-next-line @typescript-eslint/naming-convention + .mockResolvedValueOnce({ LAYOUT: '', COMPRESSION: metadata.compression }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when compression image structure metadata is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported image structure metadata (LAYOUT and COMPRESSION)'; + vi.spyOn(gdalAsync.Dataset.prototype, 'getMetadataAsync') + // eslint-disable-next-line @typescript-eslint/naming-convention + .mockResolvedValueOnce({ LAYOUT: metadata.layout, COMPRESSION: '' }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }, 100000000); + + it('should return 422 status code and respond with unsuccessful message when block size is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported block size'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band, 'blockSizeAsync', 'get').mockImplementationOnce(async () => { + return Promise.resolve({ x: -1, y: 5 }); + }); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when overviews count is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Could not find overviews'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band.overviews, 'countAsync').mockImplementationOnce(async () => { + return Promise.resolve(0); + }); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when area or point metadata is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Could not extract AREA_OR_POINT metadata'; + vi.spyOn(gdalAsync.Dataset.prototype, 'getMetadataAsync') + // eslint-disable-next-line @typescript-eslint/naming-convention + .mockResolvedValueOnce({ LAYOUT: metadata.layout, COMPRESSION: metadata.compression }) + // eslint-disable-next-line @typescript-eslint/naming-convention + .mockResolvedValueOnce({ AREA_OR_POINT: '' }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when band data type is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported band data type'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band, 'dataTypeAsync', 'get').mockImplementationOnce(async () => { + return Promise.resolve('invalid data type'); + }); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when nodata value is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported band nodata value'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band, 'noDataValueAsync', 'get').mockImplementationOnce(async () => { + return Promise.resolve(Number.POSITIVE_INFINITY); + }); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when srs is null', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported SRS'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset, 'srsAsync', 'get').mockImplementationOnce(async () => { + return Promise.resolve(null); // this EPSG code should not be included in the supported srs ids config + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when srs authority code is unrecognized', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported SRS'; + vi.spyOn(gdalAsync.SpatialReference.prototype, 'getAuthorityCode').mockReturnValueOnce(undefined as unknown as string); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when srs type is unsupported', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported SRS type'; + vi.spyOn(gdalAsync.SpatialReference.prototype, 'isGeographic').mockReturnValueOnce(false); + vi.spyOn(gdalAsync.SpatialReference.prototype, 'isProjected').mockReturnValueOnce(false); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when srs is unsupported', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported SRS'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset, 'srsAsync', 'get').mockImplementationOnce(async () => { + return Promise.resolve(gdalAsync.SpatialReference.fromEPSG(3857)); // this EPSG code should not be included in the supported srs ids config + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 422 status code and respond with unsuccessful message when geo transform is incorrect', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported geo transform'; + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset, 'geoTransformAsync', 'get').mockImplementationOnce(async () => { + return Promise.resolve([]); + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.UNPROCESSABLE_ENTITY); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyUnprocessableContent).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + type InfoResponseBodyInternalServerError = paths['/info']['post']['responses'][500]['content']['application/json']; + + it('should return 500 status code and respond with unsuccessful message when file cannot be read', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot open dataset'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockRejectedValueOnce(error); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when band cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access band'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.DatasetBands.prototype, 'getAsync').mockRejectedValueOnce(error); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when metadata cannot be accessed for validation', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot read metadata'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Dataset.prototype, 'getMetadataAsync') + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + .mockImplementationOnce(async (...args: Parameters) => gdalAsync.Dataset.prototype.getMetadataAsync(...args)) + .mockRejectedValueOnce(error); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when block size cannot be accessed for validation', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported SRS'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band, 'blockSizeAsync', 'get').mockRejectedValueOnce(error); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when overviews cannot be accessed for validation', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'Unsupported SRS'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band.overviews, 'countAsync').mockRejectedValueOnce(error); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when metadata cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot read metadata'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Dataset.prototype, 'getMetadataAsync') + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + .mockImplementationOnce(async (...args: Parameters) => gdalAsync.Dataset.prototype.getMetadataAsync(...args)) + .mockRejectedValueOnce(error); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when band data type cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access band data type'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band, 'dataTypeAsync', 'get').mockImplementationOnce(async () => { + return Promise.reject(error); + }); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when band nodata value cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access nodata value'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset.bands, 'getAsync').mockImplementationOnce(async (...args: Parameters) => { + const band = await dataset.bands.getAsync(...args); + vi.spyOn(band, 'noDataValueAsync', 'get').mockImplementationOnce(async () => { + return Promise.reject(error); + }); + return band; + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when srs cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access srs'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset, 'srsAsync', 'get').mockImplementationOnce(async () => { + return Promise.reject(error); + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when srs authority code cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access srs authority code'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.SpatialReference.prototype, 'getAuthorityCode').mockImplementationOnce(() => { + throw error; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when is geographic srs check throws an error', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access is geographic srs'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.SpatialReference.prototype, 'isGeographic').mockImplementationOnce(() => { + throw error; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when is projected srs check throws an error', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access is projected srs'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.SpatialReference.prototype, 'isProjected').mockImplementationOnce(() => { + throw error; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when srs attribute cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access srs attribute'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.SpatialReference.prototype, 'getAttrValue').mockImplementationOnce(() => { + throw error; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when geo transform cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access geo transform'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.Driver.prototype, 'openAsync').mockImplementationOnce(async (...args: Parameters) => { + const dataset = await gdalAsync.drivers.get(metadata.driverName).openAsync(...args); + vi.spyOn(dataset, 'geoTransformAsync', 'get').mockImplementationOnce(async () => { + return Promise.reject(error); + }); + return dataset; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + + it('should return 500 status code and respond with unsuccessful message when bands envelope cannot be accessed', async () => { + const metadata = createInfoMetadata({ demType: 'geotiff' }); + const demFilePath = await createInfoResource(metadata); + const expectedErrorMessage = 'cannot access bands envelope'; + const error = new Error(expectedErrorMessage); + vi.spyOn(gdalAsync.DatasetBands.prototype, 'getEnvelope').mockImplementationOnce(() => { + throw error; + }); + + const response = await requestSender.info({ requestBody: demFilePath }); + + expect(response.status).toBe(httpStatusCodes.INTERNAL_SERVER_ERROR); + expect(response).toSatisfyApiSpec(); + expect((response.body as InfoResponseBodyInternalServerError).message).toBe(expectedErrorMessage); + expect.assertions(3); + }); + }); +}); From 23b47e1b7d6504dd214019673bfb558eeddabf35 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:23:33 +0300 Subject: [PATCH 37/52] chore: set correct service name --- helm/values.yaml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/helm/values.yaml b/helm/values.yaml index 11b81f6..a239b98 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -21,7 +21,7 @@ fullnameOverride: "" configManagement: offlineMode: false - name: 'service-name' + name: 'dem-gateway' version: 'latest' serverUrl: 'http://localhost:8080/api' diff --git a/package-lock.json b/package-lock.json index 17933e6..2204de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "service-name", + "name": "dem-gateway", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "service-name", + "name": "dem-gateway", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 5f4fd9e..41311cb 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "service-name", + "name": "dem-gateway", "version": "1.0.0", "description": "This is template for map colonies typescript service", "main": "./src/index.ts", From c64107357682020820399d07e8fb2279381ea83b Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:26:24 +0300 Subject: [PATCH 38/52] style: format and remove disable eslint rule --- tests/helpers/matchers/openApiSpec.matcher.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/helpers/matchers/openApiSpec.matcher.ts b/tests/helpers/matchers/openApiSpec.matcher.ts index f835f8d..12d93d5 100644 --- a/tests/helpers/matchers/openApiSpec.matcher.ts +++ b/tests/helpers/matchers/openApiSpec.matcher.ts @@ -49,15 +49,7 @@ const getHintedErrorOnProperty = ( ); const isSupertestResponse = (received: unknown, context: TestContext): received is SupertestResponse => { - const { - isNot, - matcherHint, - matcherName, - // eslint-disable-next-line @typescript-eslint/naming-convention - RECEIVED_COLOR, - printReceived, - printWithType, - } = context; + const { isNot, matcherHint, matcherName, RECEIVED_COLOR, printReceived, printWithType } = context; if (typeof received !== 'object' || received === null) { throw new TypeError( matcherErrorMessage( From 60e58b5763ed14343667607a5201b5eedd878e12 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:27:35 +0300 Subject: [PATCH 39/52] test: fix test tmp dir location for ci --- tests/helpers/generators/gdal.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/helpers/generators/gdal.ts b/tests/helpers/generators/gdal.ts index f76bee0..9f1bb0d 100644 --- a/tests/helpers/generators/gdal.ts +++ b/tests/helpers/generators/gdal.ts @@ -1,12 +1,16 @@ /* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { faker } from '@faker-js/faker'; import { drivers, SpatialReference, type StringOptions } from 'gdal-async'; import type { z } from 'zod'; +import { SERVICE_NAME } from '@src/common/constants'; import type { blockSizeSchema, compressionSchema, layoutSchema, overviewsCountSchema } from '@src/common/schemas'; import type { InfoGeoTiff } from '../interfaces'; +const dirPath = mkdtempSync(join(tmpdir(), `${SERVICE_NAME}-`)); + interface GDALCreationOptions { driverName: string; xSize: number; @@ -54,7 +58,7 @@ export const createGDALGeotiffCOGRaster = async (options: CreateGDALRasterOption } = options; const driverGeoTiff = drivers.get('MEM'); - const filePath = join(__dirname, '../../integration/tmp', faker.system.commonFileName('tif')); + const filePath = join(dirPath, faker.system.commonFileName('tif')); const dataset = await driverGeoTiff.createAsync('', xSize, ySize, BAND_COUNT, dataType, creationOptions); From a02622eeba53736d8b6ebf2dc18d889d2f547921 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:44:57 +0300 Subject: [PATCH 40/52] test: tmp dir setup and teardown --- tests/configurations/tmpFolder.setup.ts | 10 ++++++++++ tests/helpers/constants.ts | 5 +++++ tests/helpers/generators/gdal.ts | 8 ++------ vitest.config.mts | 1 + 4 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 tests/configurations/tmpFolder.setup.ts create mode 100644 tests/helpers/constants.ts diff --git a/tests/configurations/tmpFolder.setup.ts b/tests/configurations/tmpFolder.setup.ts new file mode 100644 index 0000000..e0e0014 --- /dev/null +++ b/tests/configurations/tmpFolder.setup.ts @@ -0,0 +1,10 @@ +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpDirPath } from '@tests/helpers/constants'; + +export default function setup() { + if (!existsSync(tmpDirPath)) mkdirSync(tmpDirPath); + + return function teardown(): void { + rmSync(tmpDirPath, { recursive: true, force: true }); + }; +} diff --git a/tests/helpers/constants.ts b/tests/helpers/constants.ts new file mode 100644 index 0000000..2631e93 --- /dev/null +++ b/tests/helpers/constants.ts @@ -0,0 +1,5 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SERVICE_NAME } from '@src/common/constants'; + +export const tmpDirPath = join(tmpdir(), SERVICE_NAME); diff --git a/tests/helpers/generators/gdal.ts b/tests/helpers/generators/gdal.ts index 9f1bb0d..d3aee24 100644 --- a/tests/helpers/generators/gdal.ts +++ b/tests/helpers/generators/gdal.ts @@ -1,16 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { faker } from '@faker-js/faker'; import { drivers, SpatialReference, type StringOptions } from 'gdal-async'; import type { z } from 'zod'; -import { SERVICE_NAME } from '@src/common/constants'; import type { blockSizeSchema, compressionSchema, layoutSchema, overviewsCountSchema } from '@src/common/schemas'; +import { tmpDirPath } from '../constants'; import type { InfoGeoTiff } from '../interfaces'; -const dirPath = mkdtempSync(join(tmpdir(), `${SERVICE_NAME}-`)); - interface GDALCreationOptions { driverName: string; xSize: number; @@ -58,7 +54,7 @@ export const createGDALGeotiffCOGRaster = async (options: CreateGDALRasterOption } = options; const driverGeoTiff = drivers.get('MEM'); - const filePath = join(dirPath, faker.system.commonFileName('tif')); + const filePath = join(tmpDirPath, faker.system.commonFileName('tif')); const dataset = await driverGeoTiff.createAsync('', xSize, ySize, BAND_COUNT, dataType, creationOptions); diff --git a/vitest.config.mts b/vitest.config.mts index 0b54b21..47d46f5 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -41,6 +41,7 @@ export default defineConfig({ { test: { name: 'integration', + globalSetup: ['./tests/configurations/tmpFolder.setup.ts'], setupFiles: [ './tests/configurations/initConfig.setup.ts', './tests/configurations/initCustomMatchers.setup.ts', From 3be9bea317657adb1f51a11fcfb06ca58476017f Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:59:57 +0300 Subject: [PATCH 41/52] test: organize imports in info --- tests/integration/info/info.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration/info/info.spec.ts b/tests/integration/info/info.spec.ts index 8c67cca..ca4d3f1 100644 --- a/tests/integration/info/info.spec.ts +++ b/tests/integration/info/info.spec.ts @@ -3,17 +3,16 @@ import { faker } from '@faker-js/faker'; import { jsLogger } from '@map-colonies/js-logger'; import { createRequestSender, RequestSender } from '@map-colonies/openapi-helpers/requestSender'; import { trace } from '@opentelemetry/api'; -import { Dataset, DatasetBands, Driver } from 'gdal-async'; import * as gdalAsync from 'gdal-async'; +import { Dataset, DatasetBands, Driver } from 'gdal-async'; import httpStatusCodes from 'http-status-codes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { operations, paths } from '@openapi'; import { getApp } from '@src/app'; import { getConfig } from '@src/common/config'; import { RASTER_DATA_TYPES, SERVICES } from '@src/common/constants'; -import { createInfoResource } from '@tests/helpers/faker/info.faker'; -import { createInfoMetadata } from '@tests/helpers/faker/info.faker'; import { hasKey } from '@src/common/schemas'; +import { createInfoMetadata, createInfoResource } from '@tests/helpers/faker/info.faker'; vi.mock('node:fs/promises', { spy: true }); From 2ff1d6ca024a503761d3da14475a8dc58f4cf45f Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:01:22 +0300 Subject: [PATCH 42/52] test: remove unused boilerplate unit tests --- .../models/anotherResourceManager.spec.ts | 22 ------------ .../models/resourceNameModel.spec.ts | 36 ------------------- 2 files changed, 58 deletions(-) delete mode 100644 tests/unit/anotherResource/models/anotherResourceManager.spec.ts delete mode 100644 tests/unit/resourceName/models/resourceNameModel.spec.ts diff --git a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts b/tests/unit/anotherResource/models/anotherResourceManager.spec.ts deleted file mode 100644 index 8b89300..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(function () { - anotherResourceManager = new AnotherResourceManager(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/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts deleted file mode 100644 index d2fce9e..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 DEMManager(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 12112e164d3030d7ddef814aab0c01b127c13e58 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:29:41 +0300 Subject: [PATCH 43/52] refactor: move hasKey into utility module --- src/common/schemas.ts | 4 ---- src/common/utils.ts | 3 +++ src/info/fileHandlers/gdal.ts | 2 +- tests/integration/info/info.spec.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 src/common/utils.ts diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 185346e..17f597c 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -10,10 +10,6 @@ const resolutionDegree = config.get('application.validation.resolutionDegree'); const resolutionMeter = config.get('application.validation.resolutionMeter'); const supportedSrsIds = config.get('application.validation.supportedSrsIds'); -export const hasKey = >(x: PropertyKey, object: T): x is keyof T => { - return Object.keys(object).includes(String(x)); -}; - export const areaOrPointSchema = z.literal(['Area', 'Point']); export const blockSizeSchema = z.object({ x: z.literal(blockSize), y: z.literal(blockSize) }); export const compressionSchema = z.literal(compression); diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 0000000..b01725e --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,3 @@ +export const hasKey = >(x: PropertyKey, object: T): x is keyof T => { + return Object.keys(object).includes(String(x)); +}; diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 1f34606..1df89c8 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -15,7 +15,6 @@ import { areaOrPointSchema, blockSizeSchema, compressionSchema, - hasKey, layoutSchema, noDataValueSchema, overviewsCountSchema, @@ -23,6 +22,7 @@ import { srsIdSchema, srsNameSchema, } from '@src/common/schemas'; +import { hasKey } from '@src/common/utils'; import type { FileHandler, InfoResponse } from '@src/info/models/infoManager'; @injectable() diff --git a/tests/integration/info/info.spec.ts b/tests/integration/info/info.spec.ts index ca4d3f1..08d1aa0 100644 --- a/tests/integration/info/info.spec.ts +++ b/tests/integration/info/info.spec.ts @@ -11,7 +11,7 @@ import { operations, paths } from '@openapi'; import { getApp } from '@src/app'; import { getConfig } from '@src/common/config'; import { RASTER_DATA_TYPES, SERVICES } from '@src/common/constants'; -import { hasKey } from '@src/common/schemas'; +import { hasKey } from '@src/common/utils'; import { createInfoMetadata, createInfoResource } from '@tests/helpers/faker/info.faker'; vi.mock('node:fs/promises', { spy: true }); From 721f8d68c5055a93d9bd81cd87664d145c7cfc16 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:25:43 +0300 Subject: [PATCH 44/52] test: add unit tests for InfoManager functionality --- tests/unit/info/models/infoManager.spec.ts | 80 ++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/unit/info/models/infoManager.spec.ts diff --git a/tests/unit/info/models/infoManager.spec.ts b/tests/unit/info/models/infoManager.spec.ts new file mode 100644 index 0000000..686bdc2 --- /dev/null +++ b/tests/unit/info/models/infoManager.spec.ts @@ -0,0 +1,80 @@ +import { faker } from '@faker-js/faker'; +import { UnprocessableEntityError } from '@map-colonies/error-types'; +import { jsLogger } from '@map-colonies/js-logger'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RASTER_DATA_TYPES } from '@src/common/constants'; +import { generateInfoResponse } from '@tests/helpers/faker/info.faker'; +import { FileHandler, InfoManager, InfoResponse } from '../../../../src/info/models/infoManager'; + +describe('InfoManager', () => { + let infoManager: InfoManager; + let mockFileHandler: FileHandler; + + beforeEach(async () => { + mockFileHandler = { + name: 'mockHandler', + supports: vi.fn(), + getInfo: vi.fn(), + }; + infoManager = new InfoManager(await jsLogger({ enabled: false }), [mockFileHandler, mockFileHandler]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('#info', () => { + const format = faker.helpers.objectKey(RASTER_DATA_TYPES); + + it('should return info response when handler supports file', async () => { + const demFilePath = '/path/to/file.tif'; + const expectedResponse: InfoResponse = generateInfoResponse(format); + vi.mocked(mockFileHandler.supports).mockReturnValueOnce(true); + vi.mocked(mockFileHandler.getInfo).mockResolvedValueOnce(expectedResponse); + + const response = await infoManager.info({ demFilePath }); + + expect(response).toStrictEqual(expectedResponse); + expect(mockFileHandler.supports).toHaveBeenCalledOnce(); + expect(mockFileHandler.getInfo).toHaveBeenCalledOnce(); + expect.assertions(3); + }); + + it('should use first matching handler when multiple handlers exist', async () => { + const demFilePath = '/path/to/file.tif'; + const expectedResponse: InfoResponse = generateInfoResponse(format); + vi.mocked(mockFileHandler.supports).mockReturnValueOnce(true); + vi.mocked(mockFileHandler.getInfo).mockResolvedValueOnce(expectedResponse); + vi.mocked(mockFileHandler.supports).mockReturnValueOnce(true); + + const response = await infoManager.info({ demFilePath }); + + expect(response).toStrictEqual(expectedResponse); + expect(mockFileHandler.supports).toHaveBeenCalledOnce(); + expect(mockFileHandler.getInfo).toHaveBeenCalledOnce(); + expect.assertions(3); + }); + + it('should throw UnprocessableEntityError when no handler supports file', async () => { + const demFilePath = '/path/to/file.unknown'; + vi.mocked(mockFileHandler.supports).mockReturnValue(false); + + const response = infoManager.info({ demFilePath }); + + await expect(response).rejects.toThrow(new UnprocessableEntityError(`No handler found for file: ${demFilePath}`)); + expect.assertions(1); + }); + + it('should throw an error when getting info throws an error', async () => { + const demFilePath = '/path/to/file.tif'; + const expectedError = new Error('info error'); + vi.mocked(mockFileHandler.supports).mockReturnValueOnce(true); + vi.mocked(mockFileHandler.getInfo).mockRejectedValueOnce(expectedError); + + const response = infoManager.info({ demFilePath }); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + }); +}); From dbbb36c8c2f742d5df59c1f41f7971b1bf4366a0 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:29:13 +0300 Subject: [PATCH 45/52] test: add unit tests for GDALHandler functionality --- tests/unit/info/fileHandlers/gdal.spec.ts | 537 ++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tests/unit/info/fileHandlers/gdal.spec.ts diff --git a/tests/unit/info/fileHandlers/gdal.spec.ts b/tests/unit/info/fileHandlers/gdal.spec.ts new file mode 100644 index 0000000..9f9bf9d --- /dev/null +++ b/tests/unit/info/fileHandlers/gdal.spec.ts @@ -0,0 +1,537 @@ +import * as fsPromises from 'node:fs/promises'; +import { join } from 'node:path'; +import { faker } from '@faker-js/faker'; +import { NotFoundError } from '@map-colonies/error-types'; +import { jsLogger } from '@map-colonies/js-logger'; +import { + CoordinateTransformation, + SpatialReference, + type Dataset, + type DatasetBands, + type Driver, + type GDALDrivers, + type RasterBand, + type RasterBandOverviews, + type xyz, +} from 'gdal-async'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mocked } from 'vitest'; +import { getConfig } from '@src/common/config'; +import { type GdalAsync, getPixelInfo, getResolutions, getSrsInfo } from '@src/common/gdal'; +import { GDALHandler } from '@src/info/fileHandlers/gdal'; +import { hasKey } from '@src/common/utils'; + +const config = getConfig(); +const { max: resolutionDegreeMax, min: resolutionDegreeMin } = config.get('application.validation.resolutionDegree'); +const { max: resolutionMeterMax, min: resolutionMeterMin } = config.get('application.validation.resolutionMeter'); +const blockSize = config.get('application.validation.blockSize'); + +const mockClose = vi.fn<() => void>(); +const mockGeoTransform = vi.fn<() => Promise>(); +const mockSrsAsync = vi.fn<() => Dataset['srsAsync']>(); +const mockBlockSize = vi.fn<() => Promise>(); +const mockDataType = vi.fn<() => Promise>(); +const mockNoDataValue = vi.fn<() => Promise>(); +const mockTransformPoint = vi.fn().mockReturnValue({ x: 100, y: 100, z: 0 }); + +vi.mock('node:fs/promises'); +vi.mock('@src/common/gdal'); +vi.mock('@src/common/utils'); + +const mockAccess = vi.mocked(fsPromises.access).mockResolvedValue(undefined); +const mockGetSrsInfo = vi.mocked(getSrsInfo).mockReturnValue({ srsId: 4326, srsName: 'WGS 84' }); +const mockGetPixelInfo = vi.mocked(getPixelInfo).mockReturnValue({ pixelHeight: 0.01, pixelWidth: 0.01 }); +const mockGetResolutions = vi.mocked(getResolutions).mockReturnValue({ resolutionDegree: 0.01, resolutionMeter: 9999 }); +const mockHasKey = vi.mocked(hasKey).mockReturnValue(true); + +const mockOverview = { + countAsync: vi.fn().mockReturnValue(1), +} satisfies Partial as unknown as Mocked; +const mockBand = { + get blockSizeAsync(): Promise { + return mockBlockSize.mockImplementation(async () => Promise.resolve({ x: blockSize, y: blockSize }))(); + }, + get dataTypeAsync(): Promise { + return mockDataType.mockImplementation(async () => Promise.resolve('Int16'))(); + }, + get noDataValueAsync(): Promise { + return mockNoDataValue.mockImplementation(async () => Promise.resolve(-9999))(); + }, + overviews: mockOverview, + size: { x: blockSize * 2, y: blockSize * 2 }, +} satisfies Partial as unknown as Mocked; +const mockBands = { + getAsync: vi.fn().mockResolvedValue(mockBand), + getEnvelope: vi.fn().mockReturnValue({ + minX: 0, + minY: 0, + maxX: 100, + maxY: 100, + }), +} satisfies Partial as unknown as Mocked; +const mockCoordinateTransformation = { + transformPoint: vi.fn().mockImplementation(() => {}), +} satisfies Partial as unknown as Mocked; +const mockSpatialReference = { + isGeographic: vi.fn().mockReturnValue(true), + isProjected: vi.fn().mockReturnValue(false), + // eslint-disable-next-line @typescript-eslint/naming-convention + EPSGTreatsAsLatLong: vi.fn().mockReturnValue(false), + // eslint-disable-next-line @typescript-eslint/naming-convention + EPSGTreatsAsNorthingEasting: vi.fn().mockReturnValue(false), + getAuthorityCode: vi.fn().mockReturnValue('4326'), + getAttrValue: vi.fn().mockReturnValue('WGS 84'), +} satisfies Partial as unknown as Mocked; +const mockDataset = { + getMetadataAsync: vi.fn().mockImplementation(async (...args: Parameters) => { + const domain = args[0]; + const resposnse = await Promise.resolve( + domain === 'IMAGE_STRUCTURE' + ? { + // eslint-disable-next-line @typescript-eslint/naming-convention + LAYOUT: 'COG', + // eslint-disable-next-line @typescript-eslint/naming-convention + COMPRESSION: 'LZW', + } + : { + // eslint-disable-next-line @typescript-eslint/naming-convention + AREA_OR_POINT: 'Area', + } + ); + return resposnse; + }), + bands: mockBands, + get srsAsync() { + return mockSrsAsync.mockImplementation(async () => Promise.resolve(mockSpatialReference))(); + }, + get geoTransformAsync() { + return mockGeoTransform.mockImplementation(async () => Promise.resolve([0, 0.01, 0, 0, 0, -0.01]))(); + }, + close: mockClose.mockImplementation(() => {}), +} satisfies Partial as unknown as Mocked; +const mockDriver = { + getMetadata: vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + DMD_EXTENSION: 'tif', + // eslint-disable-next-line @typescript-eslint/naming-convention + DMD_EXTENSIONS: 'tiff tif', + }), + openAsync: vi.fn().mockResolvedValue(mockDataset), +} satisfies Partial as unknown as Mocked; +const mockDrivers = { + get: vi.fn().mockReturnValue(mockDriver), +} satisfies Partial as unknown as Mocked; +const mockGdal = { + // eslint-disable-next-line @typescript-eslint/naming-convention + CoordinateTransformation: vi.fn().mockImplementation(() => mockCoordinateTransformation), + // eslint-disable-next-line @typescript-eslint/naming-convention + SpatialReference: vi.fn().mockImplementation(() => mockSpatialReference), + drivers: mockDrivers, +} satisfies Partial as unknown as Mocked; + +describe('GDALHandler', () => { + let gdalHandler: GDALHandler; + const filePath = join('/path/to/', faker.system.commonFileName('tif')); + + beforeEach(async () => { + gdalHandler = new GDALHandler(config, await jsLogger({ enabled: false }), mockGdal); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('#supports', () => { + it('should return true when input file is supported by config and gdal driver', () => { + const response = gdalHandler.supports(filePath); + + expect(response).toBe(true); + }); + + it('should return false when a configured driver is not accessible', () => { + const error = new Error('Driver not accessible'); + mockDrivers.get.mockImplementationOnce(() => { + throw error; + }); + + const response = gdalHandler.supports(filePath); + + expect(response).toBe(false); + }); + + it('should return false when input file is not supported by config', () => { + const response = gdalHandler.supports(faker.system.commonFileName('bad_file_extension')); + + expect(response).toBe(false); + }); + + it('should return false when input file format is not supported by the API', () => { + mockHasKey.mockReturnValueOnce(false); + const response = gdalHandler.supports(filePath); + + expect(response).toBe(false); + }); + + it('should return false when configured driver is not accessible', () => { + const error = new Error('Driver not accessible'); + mockDrivers.get.mockImplementationOnce(() => { + throw error; + }); + + const response = gdalHandler.supports(filePath); + + expect(response).toBe(false); + }); + + it('should return false when configured driver metadata is not accessible', () => { + const error = new Error('Driver metadata not accessible'); + mockDriver.getMetadata.mockImplementationOnce(() => { + throw error; + }); + + const response = gdalHandler.supports(filePath); + + expect(response).toBe(false); + }); + + it('should return false when input file is not supported by any gdal driver', () => { + mockDriver.getMetadata.mockReturnValueOnce({ + // eslint-disable-next-line @typescript-eslint/naming-convention + DMD_EXTENSION: 'tiff', + }); + + const response = gdalHandler.supports(filePath); + + expect(response).toBe(false); + }); + }); + + describe('#getInfo', () => { + it('should successfully return info response for a supported file', async () => { + mockTransformPoint.mockReturnValueOnce({ x: 50, y: 50, z: 0 }); + mockTransformPoint.mockReturnValueOnce({ x: 100, y: 100, z: 0 }); + + const response = await gdalHandler.getInfo(filePath); + + expect(response).toBeObject(); + expect(response.areaOrPoint).toBe('Area'); + expect(response.dataType).toBe('Int16'); + expect(response.noDataValue).toBe(-9999); + expect(response.srsId).toBe(4326); + expect(response.srsName).toBe('WGS 84'); + expect(response.resolutionMeter).toBeWithin(resolutionMeterMin, resolutionMeterMax); + expect(response.resolutionDegree).toBeWithin(resolutionDegreeMin, resolutionDegreeMax); + expect(mockClose).toHaveBeenCalled(); + expect.assertions(9); + }); + + it('should raise an error when file does not exist', async () => { + const accessError = new Error('File not found'); + mockAccess.mockRejectedValueOnce(accessError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(NotFoundError); + expect.assertions(1); + }); + + it('should raise an error when configured driver is not accessible', async () => { + const expectedError = new Error('Driver not accessible'); + mockDrivers.get.mockImplementationOnce(() => { + throw expectedError; + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file is not supported by config', async () => { + const response = gdalHandler.getInfo(faker.system.commonFileName('bad_file_extension')); + + await expect(response).rejects.toThrow('Unsupported file format'); + expect.assertions(1); + }); + + it('should raise an error when configured driver metadata is not accessible', async () => { + const expectedError = new Error('Driver metadata not accessible'); + mockDriver.getMetadata.mockImplementationOnce(() => { + throw expectedError; + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file is not supported by any gdal driver', async () => { + mockDriver.getMetadata.mockReturnValueOnce({ + // eslint-disable-next-line @typescript-eslint/naming-convention + DMD_EXTENSION: 'tiff', + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(`Unsupported file format of file: ${filePath}`); + expect.assertions(1); + }); + + it('should raise an error when input file cannot be opened gdal driver', async () => { + const expectedError = new Error('File open failed'); + mockDriver.openAsync.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file band is not accessible', async () => { + const expectedError = new Error('Band not accessible'); + mockBands.getAsync.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file metadata of image structure is not accessible', async () => { + const expectedError = new Error('Metadata of image structure not accessible'); + mockDataset.getMetadataAsync.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file metadata of image structure is not valid', async () => { + mockDataset.getMetadataAsync.mockResolvedValueOnce({ + // eslint-disable-next-line @typescript-eslint/naming-convention + LAYOUT: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + COMPRESSION: 'LZW', + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported image structure metadata (LAYOUT and COMPRESSION)'); + expect.assertions(1); + }); + + it('should raise an error when input file band block size is not accessible', async () => { + const expectedError = new Error('Block size not accessible'); + mockBlockSize.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file band block size is not valid', async () => { + mockBlockSize.mockResolvedValueOnce({ x: 0, y: 0 }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported block size'); + expect.assertions(1); + }); + + it('should raise an error when input file band overviews count is not accessible', async () => { + const expectedError = new Error('Overviews count not accessible'); + mockOverview.countAsync.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file file band overviews count is not valid', async () => { + mockOverview.countAsync.mockResolvedValueOnce(0); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Could not find overviews'); + expect.assertions(1); + }); + + it('should raise an error when input file metadata is not accessible', async () => { + const expectedError = new Error('Metadata not accessible'); + mockDataset.getMetadataAsync + .mockResolvedValueOnce({ + // eslint-disable-next-line @typescript-eslint/naming-convention + LAYOUT: 'COG', + // eslint-disable-next-line @typescript-eslint/naming-convention + COMPRESSION: 'LZW', + }) + .mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file metadata is not valid', async () => { + mockDataset.getMetadataAsync + .mockResolvedValueOnce({ + // eslint-disable-next-line @typescript-eslint/naming-convention + LAYOUT: 'COG', + // eslint-disable-next-line @typescript-eslint/naming-convention + COMPRESSION: 'LZW', + }) + .mockResolvedValueOnce({ + // eslint-disable-next-line @typescript-eslint/naming-convention + AREA_OR_POINT: '', + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Could not extract AREA_OR_POINT metadata'); + expect.assertions(1); + }); + + it('should raise an error when input file band data type is not accessible', async () => { + const expectedError = new Error('Data type not accessible'); + mockDataType.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file band data type is not valid', async () => { + mockDataType.mockResolvedValueOnce('InvalidType'); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported band data type'); + expect.assertions(1); + }); + + it('should raise an error when input file band nodata value is not accessible', async () => { + const expectedError = new Error('nodata value error'); + mockNoDataValue.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file band nodata value is not valid', async () => { + mockNoDataValue.mockResolvedValueOnce(Infinity); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported band nodata value'); + expect.assertions(1); + }); + + it('should raise an error when input file srs is not accessible', async () => { + const expectedError = new Error('Band not accessible'); + mockSrsAsync.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file srs is not defined', async () => { + mockSrsAsync.mockResolvedValueOnce(null); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported SRS'); + expect.assertions(1); + }); + + it('should raise an error when input file srs info extraction is not valid - invalid srs id', async () => { + mockGetSrsInfo.mockReturnValueOnce({ + srsId: 0, + srsName: 'name', + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported SRS'); + expect.assertions(1); + }); + + it('should raise an error when input file srs info extraction is not valid - invalid srs name', async () => { + mockGetSrsInfo.mockReturnValueOnce({ + srsId: 4326, + srsName: '', + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow('Unsupported SRS'); + expect.assertions(1); + }); + + it('should raise an error when input file srs info extraction throws an error', async () => { + const expectedError = 'srs info error'; + mockGetSrsInfo.mockImplementationOnce(() => { + throw new Error(expectedError); + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file geo transform is not accessible', async () => { + const expectedError = new Error('Geo transform not accessible'); + mockGeoTransform.mockRejectedValueOnce(expectedError); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file pixel info extraction throws an error', async () => { + const expectedError = 'pixel info error'; + mockGetPixelInfo.mockImplementationOnce(() => { + throw new Error(expectedError); + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file bands envelope is not accessible', async () => { + const expectedError = new Error('Envelope not accessible'); + mockBands.getEnvelope.mockImplementationOnce(() => { + throw expectedError; + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + + it('should raise an error when input file resolution extraction throws an error', async () => { + const expectedError = 'resolutions error'; + mockGetResolutions.mockImplementationOnce(() => { + throw new Error(expectedError); + }); + + const response = gdalHandler.getInfo(filePath); + + await expect(response).rejects.toThrow(expectedError); + expect.assertions(1); + }); + }); +}); From fb3e7e8494a4af435f8e4735b1a42fb997fa3b39 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:41:48 +0300 Subject: [PATCH 46/52] refactor: pr comment --- src/info/fileHandlers/gdal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/info/fileHandlers/gdal.ts b/src/info/fileHandlers/gdal.ts index 1df89c8..862400c 100644 --- a/src/info/fileHandlers/gdal.ts +++ b/src/info/fileHandlers/gdal.ts @@ -69,7 +69,7 @@ export class GDALHandler implements FileHandler { const { driver, format } = this.getDriver(filePath); dataset = await driver.openAsync(fullFilePath, 'r'); const band = await dataset.bands.getAsync(1); // DEMs are mostly single banded - await this.validateMetadata({ dataset, band }); + await this.validateMetadata({ band, dataset }); const metadata = await this.getMetadata({ band, dataset, format }); return metadata; } finally { From 67492f063d29094f22daa9a888e6ed550277fe6f Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:42:23 +0300 Subject: [PATCH 47/52] chore: pr comment service description in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41311cb..427fb97 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dem-gateway", "version": "1.0.0", - "description": "This is template for map colonies typescript service", + "description": "Gateway for DEM resources manipulation", "main": "./src/index.ts", "scripts": { "test:unit": "vitest run --coverage.enabled=true --project unit", From f0d2636190ddd6234eeca24581a309c8739a9cc6 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:42:59 +0300 Subject: [PATCH 48/52] test: remove unnecessary todo --- tests/integration/info/info.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/integration/info/info.spec.ts b/tests/integration/info/info.spec.ts index 08d1aa0..f9e4796 100644 --- a/tests/integration/info/info.spec.ts +++ b/tests/integration/info/info.spec.ts @@ -143,8 +143,6 @@ describe('POST /info', () => { describe('Sad Path', () => { type InfoResponseBodyNotFound = paths['/info']['post']['responses'][404]['content']['application/json']; - // TODO: ADD NEW METADATA VALIDATION TESTS - it('should return 404 status code and respond with unsuccessful message when file does not exist', async () => { const demFilePath = '/non/existent/file.tif'; const response = await requestSender.info({ requestBody: { demFilePath } }); From 91bc71452cd104509dd3103e36d2204476465c0a Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:08:21 +0300 Subject: [PATCH 49/52] chore: rename chart references from ts-server-boilerplate to dem-gateway --- helm/Chart.yaml | 4 ++-- helm/templates/_helpers.tpl | 28 ++++++++++++++-------------- helm/templates/configmap.yaml | 6 +++--- helm/templates/deployment.yaml | 20 ++++++++++---------- helm/templates/ingress.yaml | 4 ++-- helm/templates/route.yaml | 6 +++--- helm/templates/service.yaml | 10 +++++----- helm/values.yaml | 7 ++++--- 8 files changed, 43 insertions(+), 42 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index b1f2c0f..a3e9833 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: dem-gateway +description: A Helm chart for dem-gateway service type: application version: 1.0.0 appVersion: 1.0.0 diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index da7d776..6ea2118 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -1,7 +1,7 @@ {{/* Expand the name of the chart. */}} -{{- define "ts-server-boilerplate.name" -}} +{{- define "dem-gateway.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- end -}} @@ -10,7 +10,7 @@ Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} -{{- define "ts-server-boilerplate.fullname" -}} +{{- define "dem-gateway.fullname" -}} {{- if .Values.fullnameOverride -}} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} {{- else -}} @@ -26,16 +26,16 @@ If release name contains chart name it will be used as a full name. {{/* Create chart name and version as used by the chart label. */}} -{{- define "ts-server-boilerplate.chart" -}} +{{- define "dem-gateway.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} -{{- define "ts-server-boilerplate.labels" -}} -helm.sh/chart: {{ include "ts-server-boilerplate.chart" . }} -{{ include "ts-server-boilerplate.selectorLabels" . }} +{{- define "dem-gateway.labels" -}} +helm.sh/chart: {{ include "dem-gateway.chart" . }} +{{ include "dem-gateway.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} @@ -46,15 +46,15 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} {{/* Returns the tag of the chart. */}} -{{- define "ts-server-boilerplate.tag" -}} +{{- define "dem-gateway.tag" -}} {{- default (printf "v%s" .Chart.AppVersion) .Values.image.tag }} {{- end }} {{/* Selector labels */}} -{{- define "ts-server-boilerplate.selectorLabels" -}} -app.kubernetes.io/name: {{ include "ts-server-boilerplate.name" . }} +{{- define "dem-gateway.selectorLabels" -}} +app.kubernetes.io/name: {{ include "dem-gateway.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{ include "mclabels.selectorLabels" . }} {{- end }} @@ -62,7 +62,7 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{/* Returns the cloud provider name from global if exists or from the chart's values, defaults to minikube */}} -{{- define "ts-server-boilerplate.cloudProviderFlavor" -}} +{{- define "dem-gateway.cloudProviderFlavor" -}} {{- if .Values.global.cloudProvider.flavor }} {{- .Values.global.cloudProvider.flavor -}} {{- else if .Values.cloudProvider -}} @@ -75,7 +75,7 @@ Returns the cloud provider name from global if exists or from the chart's values {{/* Returns the cloud provider docker registry url from global if exists or from the chart's values */}} -{{- define "ts-server-boilerplate.cloudProviderDockerRegistryUrl" -}} +{{- define "dem-gateway.cloudProviderDockerRegistryUrl" -}} {{- if .Values.global.cloudProvider.dockerRegistryUrl }} {{- printf "%s/" .Values.global.cloudProvider.dockerRegistryUrl -}} {{- else if .Values.cloudProvider.dockerRegistryUrl -}} @@ -87,7 +87,7 @@ Returns the cloud provider docker registry url from global if exists or from the {{/* Returns the cloud provider image pull secret name from global if exists or from the chart's values */}} -{{- define "ts-server-boilerplate.cloudProviderImagePullSecretName" -}} +{{- define "dem-gateway.cloudProviderImagePullSecretName" -}} {{- if .Values.global.cloudProvider.imagePullSecretName }} {{- .Values.global.cloudProvider.imagePullSecretName -}} {{- else if .Values.cloudProvider.imagePullSecretName -}} @@ -98,7 +98,7 @@ Returns the cloud provider image pull secret name from global if exists or from {{/* Returns the tracing url from global if exists or from the chart's values */}} -{{- define "ts-server-boilerplate.tracingUrl" -}} +{{- define "dem-gateway.tracingUrl" -}} {{- if .Values.global.tracing.url }} {{- .Values.global.tracing.url -}} {{- else if .Values.cloudProvider -}} @@ -109,7 +109,7 @@ Returns the tracing url from global if exists or from the chart's values {{/* Returns the tracing url from global if exists or from the chart's values */}} -{{- define "ts-server-boilerplate.metricsUrl" -}} +{{- define "dem-gateway.metricsUrl" -}} {{- if .Values.global.metrics.url }} {{- .Values.global.metrics.url -}} {{- else -}} diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 1ccbbc5..59b2158 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -1,10 +1,10 @@ -{{- $tracingUrl := include "ts-server-boilerplate.tracingUrl" . -}} -{{- $metricsUrl := include "ts-server-boilerplate.metricsUrl" . -}} +{{- $tracingUrl := include "dem-gateway.tracingUrl" . -}} +{{- $metricsUrl := include "dem-gateway.metricsUrl" . -}} {{- if .Values.enabled -}} apiVersion: v1 kind: ConfigMap metadata: - name: {{ printf "%s-configmap" (include "ts-server-boilerplate.fullname" .) }} + name: {{ printf "%s-configmap" (include "dem-gateway.fullname" .) }} data: REQUEST_PAYLOAD_LIMIT: {{ .Values.env.requestPayloadLimit | quote }} RESPONSE_COMPRESSION_ENABLED: {{ .Values.env.responseCompressionEnabled | quote }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 745e16b..9a01c6f 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -1,19 +1,19 @@ {{- $releaseName := .Release.Name -}} -{{- $chartName := include "ts-server-boilerplate.name" . -}} -{{- $cloudProviderFlavor := include "ts-server-boilerplate.cloudProviderFlavor" . -}} -{{- $cloudProviderDockerRegistryUrl := include "ts-server-boilerplate.cloudProviderDockerRegistryUrl" . -}} -{{- $cloudProviderImagePullSecretName := include "ts-server-boilerplate.cloudProviderImagePullSecretName" . -}} -{{- $imageTag := include "ts-server-boilerplate.tag" . -}} +{{- $chartName := include "dem-gateway.name" . -}} +{{- $cloudProviderFlavor := include "dem-gateway.cloudProviderFlavor" . -}} +{{- $cloudProviderDockerRegistryUrl := include "dem-gateway.cloudProviderDockerRegistryUrl" . -}} +{{- $cloudProviderImagePullSecretName := include "dem-gateway.cloudProviderImagePullSecretName" . -}} +{{- $imageTag := include "dem-gateway.tag" . -}} {{- if .Values.enabled -}} apiVersion: apps/v1 kind: Deployment metadata: - name: {{ printf "%s-deployment" (include "ts-server-boilerplate.fullname" .) }} + name: {{ printf "%s-deployment" (include "dem-gateway.fullname" .) }} labels: app: {{ $chartName }} component: {{ $chartName }} release: {{ $releaseName }} - {{- include "ts-server-boilerplate.labels" . | nindent 4 }} + {{- include "dem-gateway.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} @@ -22,14 +22,14 @@ spec: app: {{ $chartName }} release: {{ $releaseName }} run: {{ $releaseName }}-{{ $chartName }} - {{- include "ts-server-boilerplate.selectorLabels" . | nindent 6 }} + {{- include "dem-gateway.selectorLabels" . | nindent 6 }} template: metadata: labels: app: {{ $chartName }} release: {{ $releaseName }} run: {{ $releaseName }}-{{ $chartName }} - {{- include "ts-server-boilerplate.labels" . | nindent 8 }} + {{- include "dem-gateway.labels" . | nindent 8 }} annotations: {{ include "mclabels.annotations" . | nindent 8 }} {{- if .Values.resetOnConfigChange }} @@ -84,7 +84,7 @@ spec: {{- end }} envFrom: - configMapRef: - name: {{ printf "%s-configmap" (include "ts-server-boilerplate.fullname" .) }} + name: {{ printf "%s-configmap" (include "dem-gateway.fullname" .) }} ports: - name: http containerPort: {{ .Values.env.targetPort }} diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml index 94f92f8..86de0dc 100644 --- a/helm/templates/ingress.yaml +++ b/helm/templates/ingress.yaml @@ -1,5 +1,5 @@ {{- $releaseName := .Release.Name -}} -{{- $chartName := include "ts-server-boilerplate.name" . -}} +{{- $chartName := include "dem-gateway.name" . -}} {{- if and (.Values.enabled) (.Values.ingress.enabled) -}} apiVersion: networking.k8s.io/v1 kind: Ingress @@ -27,7 +27,7 @@ spec: pathType: Prefix backend: service: - name: {{ printf "%s-service" (include "ts-server-boilerplate.fullname" .) }} + name: {{ printf "%s-service" (include "dem-gateway.fullname" .) }} port: number: {{ .Values.env.port }} host: {{ .Values.ingress.host | quote }} diff --git a/helm/templates/route.yaml b/helm/templates/route.yaml index ab73b56..696b76c 100644 --- a/helm/templates/route.yaml +++ b/helm/templates/route.yaml @@ -1,6 +1,6 @@ {{- $releaseName := .Release.Name -}} -{{- $chartName := include "ts-server-boilerplate.name" . -}} -{{- $cloudProviderFlavor := include "ts-server-boilerplate.cloudProviderFlavor" . -}} +{{- $chartName := include "dem-gateway.name" . -}} +{{- $cloudProviderFlavor := include "dem-gateway.cloudProviderFlavor" . -}} {{- if and (and (.Values.enabled) (eq $cloudProviderFlavor "openshift")) (.Values.route.enabled) -}} apiVersion: route.openshift.io/v1 kind: Route @@ -20,7 +20,7 @@ spec: path: {{ .Values.route.path | default "/" }} to: kind: Service - name: {{ printf "%s-service" (include "ts-server-boilerplate.fullname" .) }} + name: {{ printf "%s-service" (include "dem-gateway.fullname" .) }} {{- if .Values.route.tls.enabled }} tls: termination: {{ .Values.route.tls.termination | quote }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml index be44a66..b62bbc8 100644 --- a/helm/templates/service.yaml +++ b/helm/templates/service.yaml @@ -1,16 +1,16 @@ {{- $releaseName := .Release.Name -}} -{{- $chartName := include "ts-server-boilerplate.name" . -}} -{{- $cloudProviderFlavor := include "ts-server-boilerplate.cloudProviderFlavor" . -}} +{{- $chartName := include "dem-gateway.name" . -}} +{{- $cloudProviderFlavor := include "dem-gateway.cloudProviderFlavor" . -}} {{- if .Values.enabled -}} apiVersion: v1 kind: Service metadata: - name: {{ printf "%s-service" (include "ts-server-boilerplate.fullname" .) }} + name: {{ printf "%s-service" (include "dem-gateway.fullname" .) }} labels: app: {{ $chartName }} component: {{ $chartName }} release: {{ $releaseName }} - {{- include "ts-server-boilerplate.labels" . | nindent 4 }} + {{- include "dem-gateway.labels" . | nindent 4 }} spec: {{- if eq $cloudProviderFlavor "minikube" }} type: NodePort @@ -27,5 +27,5 @@ spec: app: {{ $chartName }} release: {{ $releaseName }} run: {{ $releaseName }}-{{ $chartName }} - {{- include "ts-server-boilerplate.selectorLabels" . | nindent 4 }} + {{- include "dem-gateway.selectorLabels" . | nindent 4 }} {{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index a239b98..749432e 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -5,8 +5,9 @@ global: mclabels: component: backend - partOf: boilerplates - owner: common + partOf: core + owner: dem + gisDomain: dem prometheus: enabled: true @@ -64,7 +65,7 @@ caPath: '/usr/local/share/ca-certificates' caKey: 'ca.crt' image: - repository: ts-server-boilerplate + repository: dem-gateway # If commented, appVersion will be taken. See: _helpers.tpl # tag: 'latest' pullPolicy: IfNotPresent From 0237977a40162b9bcea6f2a779ab647ae11934e6 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:08:49 +0300 Subject: [PATCH 50/52] chore: update catalog-info.yaml --- catalog-info.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/catalog-info.yaml b/catalog-info.yaml index 55a51de..a65b352 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: dem-gateway + description: Gateway for DEM resources manipulation annotations: - github.com/project-slug: MapColonies/ts-server-boilerplate + github.com/project-slug: MapColonies/dem-gateway tags: - nodejs - typescript - expressjs - - boilerplate + - dem spec: type: service lifecycle: production - owner: DevInfra - system: boilerplate + owner: DEM + system: dem-ingestion From 2f5cff687baf51afe1a0adffa242ac6a1811433e Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:15:21 +0300 Subject: [PATCH 51/52] docs: remove todo --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 8993977..03f0855 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ When in development you should use the command `npm run start:dev`. The main ben ### Adding a New Handler - Before all, check if existing handlers can fulfill your need. For example, [`gdal handler`](src/info//fileHandlers/gdal.ts) can handle most of raster file formats. Add a new file handler under `src/info/fileHandlers`. The file should contain a class implementing the `FileHandler` interface. From 5caa5f5f1cc6c763606d007695065f346c74f3e6 Mon Sep 17 00:00:00 2001 From: vitaligi <54726763+vitaligi@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:56:16 +0300 Subject: [PATCH 52/52] refactor: pr comment --- src/info/models/infoManager.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/info/models/infoManager.ts b/src/info/models/infoManager.ts index 12a0888..e91e6a8 100644 --- a/src/info/models/infoManager.ts +++ b/src/info/models/infoManager.ts @@ -23,21 +23,16 @@ export class InfoManager { const { demFilePath } = options; this.logger.debug({ msg: 'Handling info request', resource: options }); - const response = await this.process(demFilePath); - this.logger.debug({ msg: 'Info response', response }); - - return response; - } - - private async process(filePath: string): Promise { - const handler = this.fileHandlers.find((handler) => handler.supports(filePath)); + const handler = this.fileHandlers.find((handler) => handler.supports(demFilePath)); if (!handler) { - throw new UnprocessableEntityError(`No handler found for file: ${filePath}`); + throw new UnprocessableEntityError(`No handler found for file: ${demFilePath}`); } this.logger.debug({ msg: `Using handler '${handler.name}'` }); - const info = await handler.getInfo(filePath); - return info; + const response = await handler.getInfo(demFilePath); + this.logger.debug({ msg: 'Info response', response }); + + return response; } }