diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e46384c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,41 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - master + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + - name: Install dependencies + run: npm install + - name: Build browser bundle + run: npm run build + - name: Prepare deployment directory + run: | + mkdir -p deploy/dist + cp website/* deploy/ + cp dist/* deploy/dist/ + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: deploy + user_name: 'moreGeo CI' + user_email: ci@moregeo.it + cname: check-stac.moregeo.it + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..589edd6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Release + +on: + release: + types: [published] + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + - name: Install dependencies + run: npm install + - name: Run tests + run: npm test + - name: Build browser bundle + run: npm run build + - name: Publish to npm + run: npm publish --provenance --tag latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a2ee37b..a67761d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,34 +1,42 @@ +name: Tests + on: push: branches: - master pull_request: - types: [opened, synchronize] + types: + - opened + - synchronize jobs: test: strategy: fail-fast: false matrix: - runner: [ - 'macos-11', - 'macos-12', - 'macos-13', - 'macos-latest', - 'ubuntu-20.04', - 'ubuntu-22.04', - 'ubuntu-latest', - 'windows-2019', - 'windows-2022', - 'windows-latest', - ] - node: [ '16', '18', '20', '22', 'lts/*' ] + runner: + - macos-latest + - ubuntu-latest + - windows-latest + node: + - '20' + - '22' + - '23' + - '24' + - 'lts/*' runs-on: ${{ matrix.runner }} name: ${{ matrix.runner }} runner with Node.js ${{ matrix.node }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - - run: npm install - - run: npm test + - name: Install dependencies + run: npm install + - name: Run tests + run: npm test + - name: Build browser bundle + run: npm run build + diff --git a/.gitignore b/.gitignore index 443c406..3a58165 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ Thumbs.db .npm /node_modules/ /package-lock.json + +# Build artifacts +/dist/ diff --git a/COMPARISON.md b/COMPARISON.md deleted file mode 100644 index 36b4f3b..0000000 --- a/COMPARISON.md +++ /dev/null @@ -1,46 +0,0 @@ -# Comparison of STAC validators - -There are - as far as I know - three maintained validators available for STAC: - -1. The original [Python validator](https://github.com/sparkgeo/stac-validator) -3. The validation functionality shipped with [PySTAC](https://github.com/stac-utils/pystac) -4. [This Node/JavaScript validator](https://github.com/stac-utils/stac-node-validator) - -Additionally I found some tools that seem to be unmaintained: [1](https://github.com/brianbancroft/stac-validator-cli) [2](https://github.com/JamesOConnor/stac-validator) - -Here I'd like to give an overview of what the validators are capable of and what they are missing so that you can make a well-informed choice. - -## Environment - -| | Python Validator | PySTAC | STAC Node Validator | -| :------------------------- | ------------------------------------------ | ------------------- | ------------------- | -| Validator Version | 1.0.1 | 0.5.2 | 1.3.x | -| Language | Python 3.6 | Python 3 | NodeJS | -| CLI | Yes | No | Yes | -| Programmatic | Yes | Yes | Planned | -| Online | Yes, [staclint.com](https://staclint.com/) | No | Planned | -| Protocols supported (Read) | HTTP(S), Filesystem | HTTP(S), Filesystem | HTTP(S), Filesystem | -| Gives | HTML / CLI / Python Output | Python Dict | CLI output | - -## Specifications supported - -| | Python Validator | PySTAC | STAC Node Validator | -| ---------------------------------------- | ------------------- | ------------------- | ------------------------------------------- | -| STAC Versions supported | >= 0.7.0 | >= 0.4.0 | >= 1.0.0-beta.1 | -| Protocols supported | HTTP(S), Filesystem | HTTP(S), Filesystem | HTTP(S), Filesystem | -| Validates Items / Catalogs / Collections | Yes | Yes | Yes | -| Validates Core Extensions | Yes | Yes | Yes | -| Validates External / Custom Extensions | No | No | Yes | -| Validates STAC API responses | No | No | Partially (only items/collections in lists) | -| Validates STAC API conformance classes | No | No | No | - -## Other Features - -| | Python Validator | PySTAC | STAC Node Validator | -| :----------------------------- | ---------------------- | ------------------------------------------------- | ------------------- | -| Can follow links | Yes | Yes | No | -| Parallelization | Yes | No | No | -| Validate against local schemas | Yes | Planned | Yes | -| Lint JSON files | No | No | Yes | -| Format/Pretty-print JSON files | No | Yes | Yes | -| Other comments | Uses PySTAC validation | General Python library to work with STAC catalogs | - | diff --git a/README.md b/README.md index 62a0515..972e530 100644 --- a/README.md +++ b/README.md @@ -6,26 +6,33 @@ See the [STAC Validator Comparison](COMPARISON.md) for the features supported by ## Versions -**Current version:** 1.3.2 +**Current version:** 2.0.0-rc.1 | STAC Node Validator Version | Supported STAC Versions | | --------------------------- | ----------------------- | -| 1.1.0 / 1.2.x | >= 1.0.0-rc.1 | +| 1.1.0 / 1.2.x / 2.x.x | >= 1.0.0-rc.1 | | 0.4.x / 1.0.x | >= 1.0.0-beta.2 and < 1.0.0-rc.3 | | 0.3.0 | 1.0.0-beta.2 | | 0.2.1 | 1.0.0-beta.1 | ## Quick Start -1. Install a recent version of [node and npm](https://nodejs.org) +Two options: + +1. Go to [check-stac.moregeo.it](https://check-stac.moregeo.it) for an online validator. 2. `npx stac-node-validator /path/to/your/file-or-folder` to temporarily install the library and validate the provided file for folder. See the chapters below for advanced usage options. -## Setup +## Usage -1. Install [node and npm](https://nodejs.org) - should run with any version >= 16. Older versions may still work, but no guarantee. -2. `npm install -g stac-node-validator` to install the library permanently +### CLI -## Usage +Install a recent version of [node](https://nodejs.org) (>= 22.1.0) and npm. + +Then install the CLI on your computer: + +```bash +npm install -g stac-node-validator +``` - Validate a single file: `stac-node-validator /path/to/your/file.json` - Validate multiple files: `stac-node-validator /path/to/your/catalog.json /path/to/your/item.json` @@ -44,51 +51,144 @@ Further options to add to the commands above: - Add `--strict` to enable strict mode in validation for schemas and numbers (as defined by [ajv](https://ajv.js.org/strict-mode.html) for options `strictSchema`, `strictNumbers` and `strictTuples`) - To lint local JSON files: `--lint` (add `--verbose` to get a diff with the changes required) - To format / pretty-print local JSON files: `--format` (Attention: this will override the source files without warning!) +- To run custom validation code: `--custom ./path/to/validation.js` - The validation.js needs to contain a class that implements the `BaseValidator` interface. See [custom.example.js](./custom.example.js) for an example. **Note on API support:** Validating lists of STAC items/collections (i.e. `GET /collections` and `GET /collections/:id/items`) is partially supported. It only checks the contained items/collections, but not the other parts of the response (e.g. `links`). -### Config file - You can also pass a config file via the `--config` option. Simply pass a file path as value. -Parameters set via CLI will override the corresponding setting in the config file. -Make sure to use the value `false` to override boolean flags that are set to `true` in the config file. +Parameters set via CLI will not override the corresponding setting in the config file. The config file uses the same option names as above. To specify the files to be validated, add an array with paths. The schema map is an object instead of string separated with a `=` character. -**Example:** -```json +### Programmatic + +You can also use the validator programmatically in your JavaScript/NodeJS applications. + +Install it into an existing project: + +```bash +npm install stac-node-validator +``` + +#### For browsers + +Then in your code, you can for example do the following: + +```javascript +const validate = require('stac-node-validator'); + +// Add any options, e.g. strict mode +const config = { + strict: true +}; + +// Validate a STAC file from a URL +const result = await validate('https://example.com/catalog.json', config); + +// Check if validation passed +if (result.valid) { + console.log('STAC file is valid!'); +} else { + console.log('STAC file has errors:'); +} +``` + +#### For NodeJS + +```javascript +const validate = require('stac-node-validator'); +const nodeLoader = require('stac-node-validator/src/loader/node'); + +// Add any options +const config = { + loader: nodeLoader +}; + +// Validate a STAC file from a URL +const result = await validate('https://example.com/catalog.json', config); + +// Check if validation passed +if (result.valid) { + console.log('STAC file is valid!'); +} else { + console.log('STAC file has errors:'); +} +``` + +#### Validation Results + +The `validate` function returns a `Report` object with the following structure: + +```javascript { - "files": [ - "/path/to/your/catalog.json", - "/path/to/your/item.json" - ], - "schemas": "/path/to/stac/folder", - "schemaMap": { - "https://stac-extensions.github.io/foobar/v1.0.0/schema.json": "./json-schema/schema.json" + id: "catalog.json", // File path or STAC ID + type: "Catalog", // STAC type (Catalog, Collection, Feature) + version: "1.0.0", // STAC version + valid: true, // Overall validation result + skipped: false, // Whether validation was skipped + messages: [], // Info/warning messages + children: [], // Child reports for collections/API responses + results: { + core: [], // Core schema validation errors + extensions: {}, // Extension validation errors (by schema URL) + custom: [] // Custom validation errors }, - "ignoreCerts": false, - "verbose": false, - "lint": true, - "format": false, - "strict": true, - "all": false + apiList: false // Whether this is an API collection response } ``` -You could now override some options as follows in CLI: `stac-node-validator example.json --config /path/to/config.json --lint false` +### Browser + +The validator is also available as a browser bundle for client-side validation. + +#### CDN Usage + +```html + + + + + + +``` -### Development +## Development -1. `git clone https://github.com/stac-utils/stac-node-validator` to clone the repo +1. `git clone https://github.com/moregeo-it/stac-node-validator` to clone the repo 2. `cd stac-node-validator` to switch into the new folder created by git 3. `npm install` to install dependencies 4. Run the commands as above, but replace `stac-node-validator` with `node bin/cli.js`, for example `node bin/cli.js /path/to/your/file.json` -### Test +### Tests Simply run `npm test` in a working [development environment](#development). -If you want to disable tests for your fork of the repository, simply delete `.github/workflows/test.yaml`. +### Browser Bundle + +To work on the browser bundle build it: `npm run build` + +Then you can import it from the `dist` folder. diff --git a/bin/cli.js b/bin/cli.js index 642c81e..e234b07 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,2 +1,3 @@ #!/usr/bin/env node -require('../index.js')() +const runCLI = require('../src/cli.js'); +runCLI(); diff --git a/custom.example.js b/custom.example.js new file mode 100644 index 0000000..9d6c6a2 --- /dev/null +++ b/custom.example.js @@ -0,0 +1,25 @@ +const BaseValidator = require('stac-node-validator/src/baseValidator.js'); + +class CustomValidator extends BaseValidator { + + /** + * Any custom validation routines you want to run. + * + * You can either create a list of errors using the test interface + * or just throw on the first error. + * + * @param {STAC} data + * @param {Test} test + * @param {import('.').Report} report + * @param {import('.').Config} config + * @throws {Error} + */ + async afterValidation(data, test, report, config) { + if (data.id === 'solid-earth') { + test.deepEqual(data.example, [1,2,3]); + } + } + +} + +module.exports = CustomValidator; diff --git a/index.js b/index.js deleted file mode 100644 index fe082de..0000000 --- a/index.js +++ /dev/null @@ -1,417 +0,0 @@ -const Ajv = require('ajv'); -const axios = require('axios'); -const addFormats = require('ajv-formats'); -const iriFormats = require('./iri.js'); -const fs = require('fs-extra'); -const klaw = require('klaw'); -const path = require('path') -const minimist = require('minimist'); -const versions = require('compare-versions'); -const {diffStringsUnified} = require('jest-diff'); -const { version } = require('./package.json'); - -let DEBUG = false; - -let ajv = new Ajv({ - formats: iriFormats, - allErrors: true, - strict: false, - logger: DEBUG ? console : false, - loadSchema: loadJsonFromUri -}); -addFormats(ajv); - -let verbose = false; -let schemaMap = {}; -let schemaFolder = null; - -let booleanArgs = ['verbose', 'ignoreCerts', 'lint', 'format', 'version', 'strict', 'all']; - -async function run(config) { - try { - let args = config || minimist(process.argv.slice(2)); - - if (args.version) { - console.log(version); - process.exit(0); - } - else { - console.log(`STAC Node Validator v${version}\n`); - } - - // Show minimal help output - if (args.help) { - console.log("For more information on using this command line tool, please visit"); - console.log("https://github.com/stac-utils/stac-node-validator/blob/master/README.md#usage"); - - process.exit(0); - } - - // Read config from file - if (typeof args.config === 'string') { - let configFile; - try { - configFile = await fs.readFile(args.config, "utf8"); - } catch (error) { - throw new Error('Config file does not exist.'); - } - try { - config = JSON.parse(configFile); - } catch (error) { - throw new Error('Config file is invalid JSON.'); - } - } - - // Merge CLI parameters into config - if (!config) { - config = {}; - } - for(let key in args) { - let value = args[key]; - if (key === '_' && Array.isArray(value) && value.length > 0) { - config.files = value; - } - else if (booleanArgs.includes(key)) { - if (typeof value === 'string' && value.toLowerCase() === 'false') { - config[key] = false; - } - else { - config[key] = Boolean(value); - } - } - else { - config[key] = value; - } - } - - verbose = Boolean(config.verbose); - - let files = Array.isArray(config.files) ? config.files : []; - if (files.length === 0) { - throw new Error('No path or URL specified.'); - } - else if (files.length === 1 && !isUrl(files[0])) { - // Special handling for reading directories - let stat = await fs.lstat(files[0]); - if (stat.isDirectory()) { - if (config.all) { - files = await readFolder(files[0], /.+\.json$/i); - } - else { - files = await readFolder(files[0], /(^|\/|\\)examples(\/|\\).+\.json$/i); - } - } - } - - if (config.ignoreCerts) { - process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; - } - if (config.strict) { - ajv.opts.strictSchema = true; - ajv.opts.strictNumbers = true; - ajv.opts.strictTuples = true; - } - - if (typeof config.schemas === 'string') { - let stat = await fs.lstat(config.schemas); - if (stat.isDirectory()) { - schemaFolder = normalizePath(config.schemas); - } - else { - throw new Error('Schema folder is not a valid STAC directory'); - } - } - - let schemaMapArgs = []; - if (config.schemaMap && typeof config.schemaMap === 'object') { - // Recommended way - schemaMapArgs = config.schemaMap; - } - else if (typeof config.schemaMap === 'string') { - // Backward compliance - schemaMapArgs = config.schemaMap.split(';'); - } - for(let url in schemaMapArgs) { - let path = schemaMapArgs[url]; - if (typeof url === 'string') { // from CLI - [url, path] = path.split("="); - } - let stat = await fs.lstat(path); - if (stat.isFile()) { - schemaMap[url] = path; - } - else { - console.error(`Schema mapping for ${url} is not a valid file: ${normalizePath(path)}`); - } - } - - const doLint = Boolean(config.lint); - const doFormat = Boolean(config.format); - - let stats = { - files: files.length, - invalid: 0, - valid: 0, - malformed: 0 - } - for(let file of files) { - // Read STAC file - let json; - console.log(`- ${normalizePath(file)}`); - try { - let fileIsUrl = isUrl(file); - if (!fileIsUrl && (doLint || doFormat)) { - let fileContent = await fs.readFile(file, "utf8"); - json = JSON.parse(fileContent); - const expectedContent = JSON.stringify(json, null, 2); - if (!matchFile(fileContent, expectedContent)) { - stats.malformed++; - if (doLint) { - console.warn("-- Lint: File is malformed -> use `--format` to fix the issue"); - if (verbose) { - console.log(diffStringsUnified(fileContent, expectedContent)); - } - } - if (doFormat) { - console.warn("-- Format: File was malformed -> fixed the issue"); - await fs.writeFile(file, expectedContent); - } - } - else if (doLint && verbose) { - console.warn("-- Lint: File is well-formed"); - } - } - else { - json = await loadJsonFromUri(file); - if (fileIsUrl && (doLint || doFormat)) { - let what = []; - doLint && what.push('Linting'); - doFormat && what.push('Formatting'); - console.warn(`-- ${what.join(' and ')} not supported for remote files`); - } - } - } - catch(error) { - stats.invalid++; - stats.malformed++; - console.error("-- " + error.message + "\n"); - continue; - } - - let isApiList = false; - let entries; - if (Array.isArray(json.collections)) { - entries = json.collections; - isApiList = true; - if (verbose) { - console.log(`-- The file is a /collections endpoint. Validating all ${entries.length} collections, but ignoring the other parts of the response.`); - if (entries.length > 1) { - console.log(''); - } - } - } - else if (Array.isArray(json.features)) { - entries = json.features; - isApiList = true; - if (verbose) { - console.log(`-- The file is a /collections/:id/items endpoint. Validating all ${entries.length} items, but ignoring the other parts of the response.`); - if (entries.length > 1) { - console.log(''); - } - } - } - else { - entries = [json]; - } - - let fileValid = true; - for(let data of entries) { - let id = ''; - if (isApiList) { - id = `${data.id}: `; - } - if (typeof data.stac_version !== 'string') { - console.error(`-- ${id}Skipping; No STAC version found\n`); - fileValid = false; - continue; - } - else if (versions.compare(data.stac_version, '1.0.0-rc.1', '<')) { - console.error(`-- ${id}Skipping; Can only validate STAC version >= 1.0.0-rc.1\n`); - continue; - } - else if (verbose) { - console.log(`-- ${id}STAC Version: ${data.stac_version}`); - } - - switch(data.type) { - case 'FeatureCollection': - console.warn(`-- ${id}Skipping; STAC ItemCollections not supported yet\n`); - continue; - case 'Catalog': - case 'Collection': - case 'Feature': - break; - default: - console.error(`-- ${id}Invalid; Can't detect type of the STAC object. Is the 'type' field missing or invalid?\n`); - fileValid = false; - continue; - } - - // Get all schema to validate against - let schemas = [data.type]; - if (Array.isArray(data.stac_extensions)) { - schemas = schemas.concat(data.stac_extensions); - // Convert shortcuts supported in 1.0.0 RC1 into schema URLs - if (versions.compare(data.stac_version, '1.0.0-rc.1', '=')) { - schemas = schemas.map(ext => ext.replace(/^(eo|projection|scientific|view)$/, 'https://schemas.stacspec.org/v1.0.0-rc.1/extensions/$1/json-schema/schema.json')); - } - } - - for(let schema of schemas) { - try { - let schemaId; - let core = false; - switch(schema) { - case 'Feature': - schema = 'Item'; - case 'Catalog': - case 'Collection': - let type = schema.toLowerCase(); - schemaId = `https://schemas.stacspec.org/v${data.stac_version}/${type}-spec/json-schema/${type}.json`; - core = true; - break; - default: // extension - if (isUrl(schema)) { - schemaId = schema; - } - else { - throw new Error("'stac_extensions' must contain a valid schema URL, not a shortcut."); - } - } - let validate = await loadSchema(schemaId); - let valid = validate(data); - if (!valid) { - console.log(`--- ${schema}: invalid`); - console.warn(validate.errors); - console.log("\n"); - fileValid = false; - if (core && !DEBUG) { - if (verbose) { - console.warn("-- Validation error in core, skipping extension validation"); - } - break; - } - } - else if (verbose) { - console.log(`--- ${schema}: valid`); - } - } catch (error) { - fileValid = false; - console.error(`--- ${schema}: ${error.message}`); - if (DEBUG) { - console.trace(error); - } - } - } - if (!fileValid || verbose) { - console.log(''); - } - } - fileValid ? stats.valid++ : stats.invalid++; - } - console.info("Files: " + stats.files); - console.info("Valid: " + stats.valid); - console.info("Invalid: " + stats.invalid); - if (doLint || doFormat) { - console.info("Malformed: " + stats.malformed); - } - let errored = (stats.invalid > 0 || (doLint && !doFormat && stats.malformed > 0)) ? 1 : 0; - process.exit(errored); - } - catch(error) { - console.error(error); - process.exit(1); - } -} - -const SUPPORTED_PROTOCOLS = ['http', 'https']; - -function matchFile(given, expected) { - return normalizeNewline(given) === normalizeNewline(expected); -} - -function normalizePath(path) { - return path.replace(/\\/g, '/').replace(/\/$/, ""); -} - -function normalizeNewline(str) { - // 2 spaces, *nix newlines, newline at end of file - return str.trimRight().replace(/(\r\n|\r)/g, "\n") + "\n"; -} - -function isUrl(uri) { - if (typeof uri === 'string') { - let part = uri.match(/^(\w+):\/\//i); - if(part) { - if (!SUPPORTED_PROTOCOLS.includes(part[1].toLowerCase())) { - throw new Error(`Given protocol "${part[1]}" is not supported.`); - } - return true; - } - } - return false; -} - -async function readFolder(folder, pattern) { - var files = []; - for await (let file of klaw(folder, {depthLimit: -1})) { - let relPath = path.relative(folder, file.path); - if (relPath.match(pattern)) { - files.push(file.path); - } - } - return files; -} - -async function loadJsonFromUri(uri) { - if (schemaMap[uri]) { - uri = schemaMap[uri]; - } - else if (schemaFolder) { - uri = uri.replace(/^https:\/\/schemas\.stacspec\.org\/v[^\/]+/, schemaFolder); - } - if (isUrl(uri)) { - let response = await axios.get(uri); - return response.data; - } - else { - return JSON.parse(await fs.readFile(uri, "utf8")); - } -} - -async function loadSchema(schemaId) { - let schema = ajv.getSchema(schemaId); - if (schema) { - return schema; - } - - try { - json = await loadJsonFromUri(schemaId); - } catch (error) { - if (DEBUG) { - console.trace(error); - } - throw new Error(`-- Schema at '${schemaId}' not found. Please ensure all entries in 'stac_extensions' are valid.`); - } - - schema = ajv.getSchema(json.$id); - if (schema) { - return schema; - } - - return await ajv.compileAsync(json); -} - -module.exports = async config => { - await run(config); -}; diff --git a/jest.config.js b/jest.config.js index 4148bba..95322fc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,201 +1,10 @@ -/* - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ +// https://jestjs.io/docs/configuration module.exports = { - // All imported modules in your tests should be mocked automatically - // automock: false, - - // Stop running tests after `n` failures - // bail: 0, - - // The directory where Jest should store its cached dependency information - // cacheDirectory: "/run/user/1000/jest_rs", - - // Automatically clear mock calls, instances and results before every test - // clearMocks: false, - - // Indicates whether the coverage information should be collected while executing the test collectCoverage: true, - - // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, - - // The directory where Jest should output its coverage files - // coverageDirectory: undefined, - - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "/node_modules/" - // ], - - // Indicates which provider should be used to instrument code for coverage coverageProvider: "v8", - - // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], - - // An object that configures minimum threshold enforcement for coverage results - "coverageThreshold": { - "global": { - "statements": 80, - "branches": 70, - "functions": 100, - "lines": 80 - } - } - - // A path to a custom dependency extractor - // dependencyExtractor: undefined, - - // Make calling deprecated APIs throw helpful error messages - // errorOnDeprecated: false, - - // Force coverage collection from ignored files using an array of glob patterns - // forceCoverageMatch: [], - - // A path to a module which exports an async function that is triggered once before all test suites - // globalSetup: undefined, - - // A path to a module which exports an async function that is triggered once after all test suites - // globalTeardown: undefined, - - // A set of global variables that need to be available in all test environments - // globals: {}, - - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - // maxWorkers: "50%", - - // An array of directory names to be searched recursively up from the requiring module's location - // moduleDirectories: [ - // "node_modules" - // ], - - // An array of file extensions your modules use - // moduleFileExtensions: [ - // "js", - // "jsx", - // "ts", - // "tsx", - // "json", - // "node" - // ], - - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, - - // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], - - // Activates notifications for test results - // notify: false, - - // An enum that specifies notification mode. Requires { notify: true } - // notifyMode: "failure-change", - - // A preset that is used as a base for Jest's configuration - // preset: undefined, - - // Run tests from one or more projects - // projects: undefined, - - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, - - // Automatically reset mock state before every test - // resetMocks: false, - - // Reset the module registry before running each individual test - // resetModules: false, - - // A path to a custom resolver - // resolver: undefined, - - // Automatically restore mock state and implementation before every test - // restoreMocks: false, - - // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, - - // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "" - // ], - - // Allows you to use a custom runner instead of Jest's default test runner - // runner: "jest-runner", - - // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], - - // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], - - // The number of seconds after which a test is considered as slow and reported as such in the results. - // slowTestThreshold: 5, - - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], - - // The test environment that will be used for testing - // testEnvironment: "jest-environment-node", - - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, - - // Adds a location field to test results - // testLocationInResults: false, - - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], - - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], - - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], - - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, - - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", - - // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href - // testURL: "http://localhost", - - // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" - // timers: "real", - - // A map from regular expressions to paths to transformers - // transform: undefined, - - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "/node_modules/", - // "\\.pnp\\.[^\\/]+$" - // ], - - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, - - // Indicates whether each individual test should be reported during the run - // verbose: undefined, - - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], - - // Whether to use watchman for file crawling - // watchman: true, + testMatch: [ + "**/tests/*.test.js", + ], + }; diff --git a/package.json b/package.json index 209cb6d..09aa963 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stac-node-validator", - "version": "1.3.2", + "version": "2.0.0-rc.1", "description": "STAC Validator for NodeJS", "author": "Matthias Mohr", "license": "Apache-2.0", @@ -8,38 +8,51 @@ "stac", "validator" ], - "homepage": "https://github.com/stac-utils/stac-node-validator", + "homepage": "https://github.com/moregeo-it/stac-node-validator", "bugs": { - "url": "https://github.com/stac-utils/stac-node-validator/issues" + "url": "https://github.com/moregeo-it/stac-node-validator/issues" }, "repository": { "type": "git", - "url": "https://github.com/stac-utils/stac-node-validator.git" + "url": "git+https://github.com/moregeo-it/stac-node-validator.git" }, - "main": "index.js", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/m-mohr" + }, + "main": "src/index.js", "bin": { - "stac-node-validator": "./bin/cli.js" + "stac-node-validator": "bin/cli.js" }, "files": [ "bin/cli.js", - "iri.js", - "index.js" + "src/", + "dist/*.js" ], "dependencies": { "ajv": "^8.8.2", "ajv-formats": "^2.1.1", - "axios": "^1.1.3", + "axios": "^1.7.4", "compare-versions": "^6.1.0", "fs-extra": "^10.0.0", "jest-diff": "^29.0.1", "klaw": "^4.0.1", - "minimist": "^1.2.5", - "uri-js": "^4.4.1" + "stac-js": "^0.1.4", + "uri-js": "^4.4.1", + "yargs": "^17.7.2" }, "devDependencies": { - "jest": "^29.0.1" + "@babel/core": "^7.23.0", + "@babel/preset-env": "^7.23.0", + "babel-loader": "^9.1.3", + "jest": "^29.0.1", + "path-browserify": "^1.0.1", + "process": "^0.11.10", + "webpack": "^5.89.0", + "webpack-cli": "^5.1.4" }, "scripts": { - "test": "jest" + "test": "jest", + "build": "webpack --mode production" } } diff --git a/src/baseValidator.js b/src/baseValidator.js new file mode 100644 index 0000000..af68b91 --- /dev/null +++ b/src/baseValidator.js @@ -0,0 +1,78 @@ +// const { STAC } = require('stac-js'); +const Test = require('./test'); + +class BaseValidator { + + /** + * + */ + constructor() { + } + + async createAjv(ajv) { + return ajv; + } + + /** + * Any preprocessing work you want to do on the data. + * + * @param {Object} data + * @param {import('.').Report} report + * @param {import('.').Config} config + * @returns {Object} + */ + async afterLoading(data, report, config) { + return data; + } + + /** + * Bypass the STAC validation, do something different but still return a report. + * + * Could be used to validate against a different schema, e.g. OGC API - Records. + * + * Return a Report to bypass validation, or null to continue with STAC validation. + * + * @param {Object} data + * @param {import('.').Report} report + * @param {import('.').Config} config + * @returns {import('.').Report|null} + */ + async bypassValidation(data, report, config) { + return null; + } + + /** + * Any custom validation routines you want to run. + * + * You can either create a list of errors using the test interface + * or just throw on the first error. + * + * @param {STAC} data + * @param {Test} test + * @param {import('.').Report} report + * @param {import('.').Config} config + * @throws {Error} + */ + async afterValidation(data, test, report, config) { + + } + + async testFn(report, fn) { + let errors = []; + try { + const test = new Test(); + await fn(report, test); + errors = test.errors; + } catch (error) { + errors = [error]; + } finally { + report.results.custom = (report.results.custom || []).concat(errors); + if (errors.length > 0) { + report.valid = false; + } + } + } + +} + +module.exports = BaseValidator; diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..4b42389 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,111 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { version } = require('../package.json'); +const ConfigSource = require('./config.js'); +const validate = require('../src/index.js'); +const { printConfig, printSummary, resolveFiles, printReport, abort } = require('./nodeUtils'); +const nodeLoader = require('./loader/node'); +const { getSummary, normalizePath } = require('./utils'); +const lint = require('./lint'); + + +async function run() { + console.log(`STAC Node Validator v${version}`); + console.log(); + + // Read config from CLI and config file (if any) + let config = ConfigSource.fromCLI(); + if (typeof config.config === 'string') { + Object.assign(config, await ConfigSource.fromFile(config.config)); + } + if (!config.loader) { + config.loader = nodeLoader; + } + + // Handle ignoreCerts option in Node + if (config.ignoreCerts) { + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; + } + + // Abort if no files have been provided + if (config.files.length === 0) { + abort('No path or URL specified.'); + } + + config.depth = config.depth >= 0 ? config.depth : -1; + + // Verify files exist / read folders + const files = await resolveFiles(config.files, config.depth); + delete config.files; + for (let file in files.error) { + const error = files.error[file]; + console.warn(`${file}: Can't be validated for the following reason: ${error}`); + } + if (files.files.length === 0) { + abort('No files found that are suitable for validation.'); + } + else if (files.files.length === 1) { + data = files.files[0]; + } + else { + data = files.files; + } + + // Resolve schema folder + if (config.schemas) { + let stat = await fs.lstat(config.schemas); + if (stat.isDirectory()) { + config.schemas = normalizePath(config.schemas); + } + else { + abort('Schema folder is not a directory'); + } + } + + // Print config + if (config.verbose) { + printConfig(config); + console.log(); + } + + if (config.lint || config.format) { + config.lintFn = async (data, report, config) => { + if (!report.apiList) { + report.lint = await lint(data, config); + if (report.lint && !report.lint.valid) { + report.valid = false; + } + } + return report; + } + } + + if (config.custom) { + const absPath = path.resolve(process.cwd(), config.custom); + const validator = require(absPath); + config.customValidator = new validator(); + } + + // Finally run validation + const result = await validate(data, config); + + // Print not a "supported error" once for API lists + if (result.apiList) { + printLint(null, config); + } + + // Print report and summary + printReport(result, config); + if (config.verbose || !result.valid) { + console.log(); + } + + const summary = getSummary(result, config); + printSummary(summary); + + // Exit with error code or report success + let errored = (summary.invalid > 0 || (config.lint && !config.format && summary.malformed > 0)) ? 1 : 0; + process.exit(errored); +} + +module.exports = run; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..ec52ae9 --- /dev/null +++ b/src/config.js @@ -0,0 +1,106 @@ +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); +const fs = require('fs-extra'); +const path = require('path'); + +const { strArrayToObject } = require('./nodeUtils'); + +function fromCLI() { + let config = yargs(hideBin(process.argv)) + .parserConfiguration({ + 'camel-case-expansion': false, + 'boolean-negation': false, + 'strip-aliased': true + }) + .option('lint', { + alias: 'l', + type: 'boolean', + default: false, + description: 'Check whether the JSON files are well-formatted, based on the JavaScript implementation with a 2-space indentation.' + }) + .option('format', { + alias: 'f', + type: 'boolean', + default: false, + description: 'Writes the JSON files according to the linting rules.\nATTENTION: Overrides the source files!' + }) + .option('schemas', { + alias: 's', + type: 'string', + default: null, + requiresArg: true, + description: 'Validate against schemas in a local or remote STAC folder.' + }) + .option('schemaMap', { + type: 'array', + default: [], + requiresArg: true, + description: 'Validate against a specific local schema (e.g. an external extension). Provide the schema URI and the local path separated by an equal sign.\nExample: https://stac-extensions.github.io/foobar/v1.0.0/schema.json=./json-schema/schema.json\nThis can also be a partial URL and path so that all children are also mapped.\nExample: https://stac-extensions.github.io/foobar/=./json-schema/', + coerce: strArrayToObject + }) + .option('custom', { + type: 'string', + default: null, + description: 'Load a custom validation routine from a JavaScript file.' + }) + .option('ignoreCerts', { + type: 'boolean', + default: false, + description: 'Disable verification of SSL/TLS certificates.' + }) + .option('depth', { + type: 'integer', + default: -1, + description: 'The number of levels to recurse into when looking for files in folders. 0 = no subfolders, -1 = unlimited' + }) + .option('strict', { + type: 'boolean', + default: false, + description: 'Enable strict mode in validation for schemas and numbers (as defined by ajv for options `strictSchema`, `strictNumbers` and `strictTuples`.' + }) + .option('verbose', { + alias: 'v', + type: 'boolean', + default: false, + description: 'Run with verbose logging and a diff for linting.' + }) + .option('config', { + alias: 'c', + type: 'string', + default: null, + description: 'Load the options from a config file (.js or .json). CLI options override config options.' + }) + .version() + .parse() + + delete config.$0; + config.files = config._; + delete config._; + + return config; +} + +async function fromFile(filepath) { + filepath = path.resolve(filepath); + if (filepath.endsWith('.js')) { + return require(filepath); + } + else { + let configFile; + try { + configFile = await fs.readFile(filepath, "utf8"); + } catch (error) { + throw new Error('Config file does not exist.'); + } + try { + return JSON.parse(configFile); + } catch (error) { + throw new Error('Config file is invalid JSON.'); + } + } +} + +module.exports = { + fromCLI, + fromFile +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7543f0f --- /dev/null +++ b/src/index.js @@ -0,0 +1,289 @@ +const versions = require('compare-versions'); + +const { createAjv, isHttpUrl, loadSchema, normalizePath, isObject } = require('./utils'); +const defaultLoader = require('./loader/default'); +const BaseValidator = require('./baseValidator'); + +/** + * @typedef Config + * @type {Object} + * @property {function|null} [loader=null] A function that loads the JSON from the given files. + * @property {string|null} [schemas=null] Validate against schemas in a local or remote STAC folder. + * @property {Object.} [schemaMap={}] Validate against a specific local schema (e.g. an external extension). Provide the schema URI as key and the local path as value. + * @property {boolean} [strict=false] Enable strict mode in validation for schemas and numbers (as defined by ajv for options `strictSchema`, `strictNumbers` and `strictTuples + * @property {BaseValidator} [customValidator=null] A validator with custom rules. + */ + +/** + * @typedef Report + * @type {Object} + * @property {string} id + * @property {string} type + * @property {string} version + * @property {boolean} valid + * @property {Array.} messages + * @property {Array.} children + * @property {Results} results + * @property {boolean} apiList + */ + +/** + * @typedef Results + * @type {Object} + * @property {OArray.} core + * @property {Object.>} extensions + * @property {Array.} custom + */ + +/** + * @returns {Report} + */ +function createReport() { + let result = { + id: null, + type: null, + version: null, + valid: null, + skipped: false, + messages: [], + children: [], + results: { + core: [], + extensions: {}, + custom: [] + }, + apiList: false + }; + return result; +} + +async function loadAndReport(config, data, report) { + try { + data = await config.loader(data); + } catch (error) { + report.valid = false; + report.type = "File"; + report.results.core.push({ + instancePath: "", + message: error.message + }); + } + return data; +} + +/** + * @param {Array.|Array.|Object|string} data The data to validate + * @param {Config} config The configuration object + * @returns {Report|null} + */ +async function validate(data, config) { + const defaultConfig = { + loader: defaultLoader, + schemas: null, + schemaMap: {}, + strict: false + }; + config = Object.assign({}, defaultConfig, config); + config.ajv = createAjv(config); + if (config.customValidator) { + config.ajv = await config.customValidator.createAjv(config.ajv); + } + + let report = createReport(); + if (typeof data === 'string') { + report.id = normalizePath(data); + data = await loadAndReport(config, data, report); + } + + if (isObject(data)) { + report.id = report.id || data.id; + report.version = data.stac_version; + report.type = data.type; + + if (Array.isArray(data.collections)) { + data = data.collections; + report.apiList = true; + if (config.verbose) { + report.messages.push(`The file is a CollectionCollection. Validating all ${data.length} collections, but ignoring the other parts of the response.`); + } + } + else if (Array.isArray(data.features)) { + data = data.features; + report.apiList = true; + if (config.verbose) { + report.messages.push(`The file is a ItemCollection. Validating all ${data.length} items, but ignoring the other parts of the response.`); + } + } + else { + return validateOne(data, config, report); + } + } + + if (Array.isArray(data) && data.length > 0) { + for(const obj of data) { + const subreport = await validateOne(obj, config); + report.children.push(subreport); + } + return summarizeResults(report); + } + else { + return report; + } +} + + +/** + * @param {Object|string} source The data to validate + * @param {Config} config The configuration object + * @param {Report} report Parent report + * @returns {Report} + */ +async function validateOne(source, config, report = null) { + if (!report) { + report = createReport(); + } + + let data = source; + if (!report.id) { + if (typeof data === 'string') { + report.id = normalizePath(data); + data = await loadAndReport(config, data, report); + if (report.valid === false) { + return report; + } + } + else { + report.id = data.id; + } + } + report.version = data.stac_version; + report.type = data.type; + + if (config.customValidator) { + data = await config.customValidator.afterLoading(data, report, config); + } + + if (typeof config.lintFn === 'function') { + report = await config.lintFn(source, report, config); + } + + if (config.customValidator) { + const bypass = await config.customValidator.bypassValidation(data, report, config); + if (bypass) { + return bypass; + } + } + + // Check stac_version + if (typeof data.stac_version !== 'string') { + report.skipped = true; + report.messages.push('No STAC version found'); + return report; + } + else if (versions.compare(data.stac_version, '1.0.0-rc.1', '<')) { + report.skipped = true; + report.messages.push('Can only validate STAC version >= 1.0.0-rc.1'); + return report; + } + + // Check type field + switch(data.type) { + case 'FeatureCollection': + report.skipped = true; + report.messages.push('STAC ItemCollections not supported yet'); + return report; + case 'Catalog': + case 'Collection': + case 'Feature': + // pass + break; + default: + report.valid = false; + report.results.core.push({ + instancePath: "/type", + message: "Can't detect type of the STAC object. Is the 'type' field missing or invalid?" + }); + return report; + } + + // Validate against the core schemas + await validateSchema('core', data.type, data, report, config); + + // Get all extension schemas to validate against + let schemas = []; + if (Array.isArray(data.stac_extensions)) { + schemas = schemas.concat(data.stac_extensions); + // Convert shortcuts supported in 1.0.0 RC1 into schema URLs + if (versions.compare(data.stac_version, '1.0.0-rc.1', '=')) { + schemas = schemas.map(ext => ext.replace(/^(eo|projection|scientific|view)$/, 'https://schemas.stacspec.org/v1.0.0-rc.1/extensions/$1/json-schema/schema.json')); + } + } + for(const schema of schemas) { + await validateSchema('extensions', schema, data, report, config); + } + + if (config.customValidator) { + const { default: create } = await import('stac-js'); + const stac = create(data, false, false); + await config.customValidator.testFn(report, async (report, test) => await config.customValidator.afterValidation(stac, test, report, config)) + } + + return report; +} + +async function validateSchema(key, schema, data, report, config) { + // Get schema identifier/uri + let schemaId; + switch(schema) { + case 'Feature': + schema = 'Item'; + case 'Catalog': + case 'Collection': + let type = schema.toLowerCase(); + schemaId = `https://schemas.stacspec.org/v${report.version}/${type}-spec/json-schema/${type}.json`; + break; + default: // extension + if (isHttpUrl(schema)) { + schemaId = schema; + } + } + + // Validate + const setValidity = (errors = []) => { + if (report.valid !== false) { + report.valid = errors.length === 0; + } + if (key === 'core') { + report.results.core = errors; + } + else { + report.results.extensions[schema] = errors; + } + }; + try { + if (key !== 'core' && !schemaId) { + throw new Error("'stac_extensions' must contain a valid HTTP(S) URL to a schema."); + } + const validate = await loadSchema(config, schemaId); + const valid = validate(data); + if (valid) { + setValidity(); + } + else { + setValidity(validate.errors); + } + } catch (error) { + setValidity([{ + message: error.message + }]); + } +} + +function summarizeResults(report) { + if (report.children.length > 0) { + report.valid = Boolean(report.children.every(result => result.valid)); + } + return report; +} + +module.exports = validate; diff --git a/iri.js b/src/iri.js similarity index 91% rename from iri.js rename to src/iri.js index cfcc6fc..e3b69d5 100644 --- a/iri.js +++ b/src/iri.js @@ -1,10 +1,10 @@ const { parse } = require('uri-js'); // We don't allow empty URIs, same-document and mailto here -module.exports = { +const IRI = { 'iri': value => { if (typeof value !== 'string' || value.length === 0) { - return; + return false; } const iri = parse(value); @@ -16,7 +16,7 @@ module.exports = { }, 'iri-reference': value => { if (typeof value !== 'string' || value.length === 0) { - return; + return false; } const iri = parse(value); @@ -27,3 +27,5 @@ module.exports = { return (iri.path && (iri.reference === 'relative' || iri.reference === 'uri')); } }; + +module.exports = IRI; diff --git a/src/lint.js b/src/lint.js new file mode 100644 index 0000000..e2930e3 --- /dev/null +++ b/src/lint.js @@ -0,0 +1,58 @@ +const fs = require('fs-extra'); +const { diffStringsUnified } = require('jest-diff'); +const { isHttpUrl, isObject } = require('./utils'); + +/** + * @typedef LintResult + * @type {Object} + * @property {boolean} valid + * @property {boolean} fixed + * @property {Error|null} error + * @property {string|null} diff + */ + +/** + * @param {string} file + * @param {Object} config + * @returns {LintResult} + */ +async function lint(file, config) { + if (isObject(file)) { + return null; + } + else if (isHttpUrl(file)) { + return null; + } + + let result = { + valid: false, + fixed: false, + error: null, + diff: null + }; + try { + const fileContent = await fs.readFile(file, "utf8"); + const expectedContent = JSON.stringify(JSON.parse(fileContent), null, 2); + result.valid = normalizeNewline(fileContent) === normalizeNewline(expectedContent); + + if (!result.valid) { + if (config.verbose) { + result.diff = diffStringsUnified(fileContent, expectedContent); + } + if (config.format) { + await fs.writeFile(file, expectedContent); + result.fixed = true; + } + } + } catch (error) { + result.error = error; + } + return result; +} + +function normalizeNewline(str) { + // 2 spaces, *nix newlines, newline at end of file + return str.trimRight().replace(/(\r\n|\r)/g, "\n") + "\n"; +} + +module.exports = lint; diff --git a/src/loader/default.js b/src/loader/default.js new file mode 100644 index 0000000..2a95417 --- /dev/null +++ b/src/loader/default.js @@ -0,0 +1,8 @@ +const axios = require('axios'); + +async function loader(uri) { + let response = await axios.get(uri); + return response.data; +} + +module.exports = loader; diff --git a/src/loader/node.js b/src/loader/node.js new file mode 100644 index 0000000..379caf5 --- /dev/null +++ b/src/loader/node.js @@ -0,0 +1,28 @@ +const axios = require('axios'); +const fs = require('fs-extra'); +const { isHttpUrl } = require('../utils'); + +async function loader(uri) { + if (isHttpUrl(uri)) { + const response = await axios.get(uri); + return response.data; + } + else { + if (await fs.exists(uri)) { + return JSON.parse(await fs.readFile(uri, "utf8")); + } + else { + const url = URL.parse(uri); + // url.protocol.length > 2 check that it's not a Windows path, e.g. c: as in c://foo/bar + if (url && url.protocol && url.protocol.length > 2 && url.protocol !== 'file:') { + throw new Error(`Protocol not supported: ${url.protocol}`); + } + else { + const path = require('path'); + throw new Error(`File not found: ${uri}`); + } + } + } +} + +module.exports = loader; diff --git a/src/nodeUtils.js b/src/nodeUtils.js new file mode 100644 index 0000000..0ac45b0 --- /dev/null +++ b/src/nodeUtils.js @@ -0,0 +1,235 @@ +const klaw = require('klaw'); +const fs = require('fs-extra'); +const path = require('path'); + +const { makeAjvErrorMessage, isHttpUrl, SUPPORTED_PROTOCOLS } = require('./utils'); + +const SCHEMA_CHOICE = ['anyOf', 'oneOf']; + +function abort(message) { + console.error(message); + process.exit(1); +} + +function printConfig(config) { + console.group("Config"); + console.dir(config); + console.groupEnd(); +} + +function printSummary(summary) { + console.group(`Summary (${summary.total})`); + console.log("Valid: " + summary.valid); + console.log("Invalid: " + summary.invalid); + if (summary.malformed !== null) { + console.log("Malformed: " + summary.malformed); + } + console.log("Skipped: " + summary.skipped); + console.groupEnd(); +} + +function printLint(lint, config) { + const what = []; + config.lint && what.push('Linting'); + config.format && what.push('Formatting'); + const title = what.join(' and '); + + if (!lint) { + if (config.lint || config.format) { + console.group(title); + console.log('Not supported for remote files'); + console.groupEnd(); + } + return; + } + + if (config.verbose) { + console.group(title); + if (lint.valid) { + console.log('File is well-formed'); + } + else { + if (lint.fixed) { + console.log('File was malformed -> fixed the issue'); + } + else { + console.log('File is malformed -> use `--format` to fix the issue'); + } + } + if (lint.error) { + console.log(lint.error); + } + if (lint.diff) { + console.groupCollapsed("File Diff"); + console.log(lint.diff); + console.groupEnd(); + } + console.groupEnd(); + } + else if (!lint.valid && !lint.fixed) { + console.group(title); + console.log('File is malformed -> use `--format` to fix the issue'); + if (lint.error) { + console.log(lint.error); + } + console.groupEnd(); + } +} + +function printReport(report, config) { + if (report.valid && !config.verbose) { + return; + } + + console.group(report.id || "Report"); + + if (config.verbose && report.version) { + console.log(`STAC Version: ${report.version}`); + } + + if (report.messages) { + report.messages.forEach(str => console.log(str)); + } + + if (!report.apiList) { + printLint(report.lint, config); + } + + if (!report.valid || config.verbose) { + printAjvValidationResult(report.results.core, report.type, report.valid, config); + if (report.type) { + const count = Object.keys(report.results.extensions).length; + if (count > 0) { + console.group("Extensions"); + Object.entries(report.results.extensions) + .forEach(([ext, result]) => printAjvValidationResult(result, ext, report.valid, config)); + console.groupEnd(); + } + else { + console.log("Extensions: None"); + } + } + if (config.custom && (report.results.custom.length > 0 || report.children.length === 0)) { + printAjvValidationResult(report.results.custom, 'Custom', report.valid, config); + } + } + + report.children.forEach(child => printReport(child, config)); + + console.groupEnd(); +} + +function printAjvValidationResult(result, category, reportValid, config) { + if (!category) { + return; + } + if (!config.verbose && isHttpUrl(category)) { + const match = category.match(/^https?:\/\/stac-extensions\.github\.io\/([^/]+)\/v?([^/]+)(?:\/([^/.]+))?\/schema/); + if (match) { + let title = match[1]; + if (match[3]) { + title += ' - ' + formatKey(match[3]); + } + category = `${title} (${match[2]})`; + } + } + if (result.length > 0) { + console.group(category); + if (config.verbose) { + console.dir(result); + } + else { + result + .filter(error => result.length === 1 || !SCHEMA_CHOICE.includes(error.keyword)) // Remove messages that are usually not so important (anyOf/oneOf) + .sort((a,b) => { + // Sort so that anyOf/oneOf related messages come last, these are usually not so important + let aa = isSchemaChoice(a.schemaPath); + let bb = isSchemaChoice(b.schemaPath); + if (aa && bb) { + return 0; + } + else if (aa) { + return 1; + } + else if (bb) { + return -1; + } + else { + return 0; + } + }) + .map(error => makeAjvErrorMessage(error)) // Convert to string + .filter((value, i, array) => array.indexOf(value) === i) // Remove duplicates + .forEach((msg, i) => console.log(`${i+1}. ${msg}`)); // Print it as list + } + console.groupEnd(); + } + else if (!reportValid || config.verbose) { + console.log(`${category}: valid`); + } +} + +function isSchemaChoice(schemaPath) { + return typeof schemaPath === 'string' && schemaPath.match(/\/(one|any)Of\/\d+\//); +} + +async function resolveFiles(files, depth = -1) { + const result = { + files: [], + error: {} + }; + const extensions = [".geojson", ".json"]; + const klawOptions = { + depthLimit: depth + } + for (const file of files) { + const url = URL.parse(file); + // url.protocol.length > 2 check that it's not a Windows path, e.g. c: as in c://foo/bar + if (url && url.protocol && url.protocol.length > 2 && url.protocol !== 'file:') { + if (SUPPORTED_PROTOCOLS.includes(url.protocol)) { + result.files.push(file); + continue; + } + else { + result.error[file] = new Error(`Protocol "${url.protocol}" is not supported.`); + } + } else { + try { + const stat = await fs.lstat(file); + if (stat.isDirectory()) { + // Special handling for reading directories + for await (const child of klaw(file, klawOptions)) { + const ext = path.extname(child.path).toLowerCase(); + if (extensions.includes(ext)) { + result.files.push(child.path); + } + } + } + else { + result.files.push(file); + } + } catch (error) { + result.error[file] = error; + } + } + } + return result; +} + +function strArrayToObject(list, sep = "=") { + let map = {}; + for (let str of list) { + let [key, value] = str.split(sep, 2); + map[key] = value; + } + return map; +} + +module.exports = { + abort, + printConfig, + printReport, + printSummary, + resolveFiles, + strArrayToObject +}; diff --git a/src/test.js b/src/test.js new file mode 100644 index 0000000..c17d01b --- /dev/null +++ b/src/test.js @@ -0,0 +1,155 @@ +const assert = require('assert'); + +class Test { + + constructor() { + this.errors = []; + } + + truthy(...args) { + try { + assert(...args); + } catch (error) { + this.errors.push(error); + } + } + + deepEqual(...args) { + try { + assert.deepEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + deepStrictEqual(...args) { + try { + assert.deepStrictEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + doesNotMatch(...args) { + try { + assert.doesNotMatch(...args); + } catch (error) { + this.errors.push(error); + } + } + + async doesNotReject(...args) { + try { + await assert.doesNotReject(...args); + } catch (error) { + this.errors.push(error); + } + } + + doesNotThrow(...args) { + try { + assert.doesNotThrow(...args); + } catch (error) { + this.errors.push(error); + } + } + + equal(...args) { + try { + assert.equal(...args); + } catch (error) { + this.errors.push(error); + } + } + + fail(...args) { + try { + assert.fail(...args); + } catch (error) { + this.errors.push(error); + } + } + + ifError(...args) { + try { + assert.ifError(...args); + } catch (error) { + this.errors.push(error); + } + } + + match(...args) { + try { + assert.match(...args); + } catch (error) { + this.errors.push(error); + } + } + + notDeepEqual(...args) { + try { + assert.notDeepEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + notDeepStrictEqual(...args) { + try { + assert.notDeepStrictEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + notEqual(...args) { + try { + assert.notEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + notStrictEqual(...args) { + try { + assert.notStrictEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + ok(...args) { + try { + assert.ok(...args); + } catch (error) { + this.errors.push(error); + } + } + + async rejects(...args) { + try { + await assert.rejects(...args); + } catch (error) { + this.errors.push(error); + } + } + + strictEqual(...args) { + try { + assert.strictEqual(...args); + } catch (error) { + this.errors.push(error); + } + } + + throws(...args) { + try { + assert.throws(...args); + } catch (error) { + this.errors.push(error); + } + } + +} + +module.exports = Test; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..7a8146f --- /dev/null +++ b/src/utils.js @@ -0,0 +1,146 @@ +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); +const iriFormats = require('./iri'); +const { join } = require('path'); + +const SUPPORTED_PROTOCOLS = ['http:', 'https:']; + +function isObject(obj) { + return (typeof obj === 'object' && obj === Object(obj) && !Array.isArray(obj)); +} + +function isHttpUrl(url) { + const parsed = URL.parse(url); + if (parsed && SUPPORTED_PROTOCOLS.includes(parsed.protocol)) { + return true; + } + return false; +} + +function createAjv(config) { + let instance = new Ajv({ + formats: iriFormats, + allErrors: config.verbose, + strict: false, + logger: config.verbose ? console : false, + loadSchema: async (uri) => await loadSchemaFromUri(uri, config) + }); + addFormats(instance); + if (config.strict) { + instance.opts.strictSchema = true; + instance.opts.strictNumbers = true; + instance.opts.strictTuples = true; + } + return instance; +} + +async function loadSchema(config, schemaId) { + let schema = config.ajv.getSchema(schemaId); + if (schema) { + return schema; + } + + try { + json = await loadSchemaFromUri(schemaId, config); + } catch (error) { + throw new Error(`Schema at '${schemaId}' not found. Please ensure all entries in 'stac_extensions' are valid.`); + } + + schema = config.ajv.getSchema(json.$id); + if (schema) { + return schema; + } + + return await config.ajv.compileAsync(json); +} + +async function loadSchemaFromUri(uri, config) { + let newUri = uri; + if (isObject(config.schemaMap)) { + const patterns = Object.entries(config.schemaMap); + const match = patterns.find(map => uri.startsWith(map[0])); + if (match) { + const [pattern, target] = match; + newUri = join(target, uri.substring(pattern.length)); + } + } + if (config.schemas) { + newUri = newUri.replace(/^https:\/\/schemas\.stacspec\.org\/v[^\/]+/, config.schemas); + } + const schema = await config.loader(newUri); + if (!schema.$id) { + schema.$id = uri; + } + return schema; +} + +function normalizePath(p) { + return p.replace(/\\/g, '/').replace(/\/$/, ""); +} + +function getSummary(result, config) { + let summary = { + total: 0, + valid: 0, + invalid: 0, + malformed: null, + skipped: 0 + }; + if (result.children.length > 0) { + // todo: speed this up by computing in one iteration + summary.total = result.children.length; + summary.valid = result.children.filter(c => c.valid === true).length; + summary.invalid = result.children.filter(c => c.valid === false).length; + if (config.lint || config.format) { + summary.malformed = result.children.filter(c => c.lint && !c.lint.valid).length; + } + summary.skipped = result.children.filter(c => c.skipped).length; + } + else { + summary.total = 1; + summary.valid = result.valid === true ? 1 : 0; + summary.invalid = result.valid === false ? 1 : 0; + if (result.lint) { + summary.malformed = result.lint.valid ? 0 : 1; + } + summary.skipped = result.skipped ? 1 : 0; + } + return summary; +} + +function makeAjvErrorMessage(error) { + let message = error.message; + if (isObject(error.params) && Object.keys(error.params).length > 0) { + let params = Object.entries(error.params) + .map(([key, value]) => { + let label = key.replace(/([^A-Z]+)([A-Z])/g, "$1 $2").toLowerCase(); + return `${label}: ${value}`; + }) + .join(', ') + message += ` (${params})`; + } + if (error.instancePath) { + return `${error.instancePath} ${message}`; + } + else if (error.schemaPath) { + return `${message}, for schema ${error.schemaPath}`; + } + else if (message) { + return message; + } + else { + return String(error); + } +} + +module.exports = { + createAjv, + getSummary, + isObject, + isHttpUrl, + loadSchema, + loadSchemaFromUri, + makeAjvErrorMessage, + normalizePath, + SUPPORTED_PROTOCOLS +}; diff --git a/tests/cli.test.js b/tests/cli.test.js deleted file mode 100644 index 7a21677..0000000 --- a/tests/cli.test.js +++ /dev/null @@ -1,394 +0,0 @@ -const app = require('../index'); -const { version } = require('../package.json'); -const fs = require('fs/promises'); -const { exec } = require("child_process"); - -let consoleErrSpy, consoleWarnSpy, consoleInfSpy, consoleLogSpy, mockExit; -const initString = `STAC Node Validator v${version}`; - -const validCatalogPath = 'tests/examples/catalog.json'; -const invalidCatalogPath = 'tests/examples/invalid-catalog.json'; -const invalidSchemaPath = 'tests/invalid-schema.json'; -const invalidSchemaCatalogPath = 'tests/examples/catalog-with-invalid-schema.json'; -const apiItemsPath = 'tests/api/items.json'; -const apiCollectionsPath = 'tests/api/collections.json'; - -beforeEach(() => { - mockExit = jest.spyOn(process, 'exit').mockImplementation(); - consoleInfSpy = jest.spyOn(console, 'info').mockImplementation(); - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - consoleErrSpy = jest.spyOn(console, 'error').mockImplementation(); -}); - -it('Should be executable via shell', done => { - exec("node ./bin/cli.js --version", (error, stdout, stderr) => { - expect(error).toBe(null); - expect(stderr).toBe(""); - expect(stdout).toContain(version); - done(); - }); -}); - -it('Should print init string', async () => { - await app(); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); -}); - -it('Should print version number', async () => { - await app({version: true}); - - expect(consoleLogSpy.mock.calls[0][0]).toBe(version); - expect(mockExit).toHaveBeenCalledWith(0); -}); - -it('Should print help', async () => { - await app({help: true}); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain("For more information on using this command line tool, please visit"); - expect(consoleLogSpy.mock.calls[2][0]).toContain("https://github.com/stac-utils/stac-node-validator/blob/master/README.md#usage"); - expect(mockExit).toHaveBeenCalledWith(0); -}); - -describe('Running without parameters or configuration', () => { - it('Should return exit code 1', async () => { - await app(); - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print an error message', async () => { - await app(); - expect(consoleErrSpy.mock.calls[0][0] instanceof Error).toBeTruthy(); - expect(consoleErrSpy.mock.calls[0][0].message).toContain('No path or URL specified.'); - }); -}); - -describe('Validate a valid catalog', () => { - let files = [validCatalogPath]; - - it('Should return exit code 0', async () => { - await app({files}); - - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('Should print informational messages', async () => { - await app({files}); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(validCatalogPath); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 0'); - }); -}); - -describe('Validate a whole folder', () => { - let files = ['tests/']; - - it('Should return exit code 1', async () => { - await app({files}); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - await app({files}); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 3'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 2'); - }); -}); - -describe('Validate an partially invalid API Items (Item Collection)', () => { - let config = { - files: [apiItemsPath], - verbose: true - }; - - it('Should return exit code 1', async () => { - await app(config); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - await app(config); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(apiItemsPath); - expect(consoleLogSpy.mock.calls[2][0]).toContain('The file is a /collections/:id/items endpoint. Validating all 2 items, but ignoring the other parts of the response.'); - expect(consoleLogSpy.mock.calls[4][0]).toContain('20201211_223832_CS2: STAC Version: 1.0.0'); - expect(consoleLogSpy.mock.calls[5][0]).toContain('Item: valid'); - expect(consoleLogSpy.mock.calls[7][0]).toContain('invalid: STAC Version: 1.0.0'); - expect(consoleLogSpy.mock.calls[8][0]).toContain('Item: invalid'); - - expect(Array.isArray(consoleWarnSpy.mock.calls[0][0])).toBeTruthy(); - expect(consoleWarnSpy.mock.calls[1][0]).toContain('Validation error in core, skipping extension validation'); - - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 0'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 1'); - }); -}); - -describe('Validate valid API Collections', () => { - let files = [apiCollectionsPath]; - - it('Should return exit code 0', async () => { - await app({files}); - - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('Should print informational messages', async () => { - await app({files}); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(apiCollectionsPath); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 0'); - }); -}); - -describe('Validate an invalid catalog', () => { - let files = [invalidCatalogPath]; - - it('Should return exit code 1', async () => { - await app({files}); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - await app({files}); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(invalidCatalogPath); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 0'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 1'); - }); - - it('Should print validation warnings', async () => { - await app({files}); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleWarnSpy.mock.calls[0][0]).toEqual([{ - "instancePath": "", - "keyword": "required", - "message": "must have required property 'links'", - "params": {"missingProperty": "links"}, - "schemaPath": "#/required" - }]); - }); -}); - -describe('Validate a catalog passed as config file via CLI (verbose)', () => { - let argv = ['node_executable', 'app_script', '--config', 'tests/example-config.json']; - - it('Should return exit code 0', async () => { - process.argv = argv; - await app(); - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('Should print informational messages', async () => { - process.argv = argv; - await app(); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(validCatalogPath); - expect(consoleLogSpy.mock.calls[2][0]).toContain('STAC Version: 1.0.0'); - expect(consoleLogSpy.mock.calls[3][0]).toContain('Catalog: valid'); - - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 0'); - }); -}); - -describe('Run with a non-existing config file', () => { - let argv = ['node_executable', 'app_script', '--config', 'does-not-exist.json']; - - it('Should return exit code 1', async () => { - process.argv = argv; - await app(); - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - process.argv = argv; - await app(); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleErrSpy.mock.calls[0][0] instanceof Error).toBeTruthy(); - expect(consoleErrSpy.mock.calls[0][0].message).toContain('Config file does not exist.'); - }); -}); - -describe('Validate a catalog passed via CLI', () => { - let argv = ['node_executable', 'app_script', validCatalogPath]; - - it('Should return exit code 0', async () => { - process.argv = argv; - await app(); - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('Should print informational messages', async () => { - process.argv = argv; - await app(); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(validCatalogPath); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 0'); - }); -}); - -describe('Validate an invalid schema via config', () => { - let config = { - schemaMap: `https://example.org/invalid-schema.json=${invalidSchemaPath}`, - files: [invalidSchemaCatalogPath] - }; - - it('Should return exit code 1', async () => { - await app(config); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - await app(config); - - expect(consoleErrSpy).toHaveBeenCalled(); - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(invalidSchemaCatalogPath); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 0'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 1'); - }); -}); - -describe('Validate an invalid schema via CLI', () => { - let argv = ['node_executable', 'app_script', invalidSchemaCatalogPath, '--schemaMap', `https://example.org/invalid-schema.json=${invalidSchemaPath}`]; - - it('Should return exit code 1', async () => { - process.argv = argv; - await app(); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - process.argv = argv; - await app(); - - expect(consoleErrSpy).toHaveBeenCalled(); - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(invalidSchemaCatalogPath); - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 0'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 1'); - }); -}); - -describe('Linting a valid catalog with invalid formatting', () => { - let config = { - files: [validCatalogPath], - lint: true - }; - - it('Should return exit code 1', async () => { - await app(config); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('Should print informational messages', async () => { - await app(config); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(validCatalogPath); - - expect(consoleWarnSpy.mock.calls[0][0]).toContain('Lint: File is malformed'); - - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 0'); - expect(consoleInfSpy.mock.calls[3][0]).toContain('Malformed: 1'); - }); -}); - -describe('Formatting a valid catalog with invalid formatting (verbose)', () => { - let formatCatalogPath = 'tests/catalog-to-format.ignore'; - let config = { - files: [formatCatalogPath], - format: true, - verbose: true - }; - - beforeEach(async () => await fs.writeFile(formatCatalogPath, await fs.readFile(validCatalogPath))); - afterEach(async () => await fs.rm(formatCatalogPath)); - - it('Should return exit code 0', async () => { - await app(config); - - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('Should print informational messages', async () => { - await app(config); - - expect(consoleErrSpy.mock.calls).toEqual([]); - - expect(consoleLogSpy.mock.calls[0][0]).toContain(initString); - expect(consoleLogSpy.mock.calls[1][0]).toContain(formatCatalogPath); - expect(consoleLogSpy.mock.calls[2][0]).toContain('STAC Version: 1.0.0'); - expect(consoleLogSpy.mock.calls[3][0]).toContain('Catalog: valid'); - - expect(consoleWarnSpy.mock.calls[0][0]).toContain('Format: File was malformed -> fixed the issue'); - - expect(consoleInfSpy.mock.calls[0][0]).toContain('Files: 1'); - expect(consoleInfSpy.mock.calls[1][0]).toContain('Valid: 1'); - expect(consoleInfSpy.mock.calls[2][0]).toContain('Invalid: 0'); - expect(consoleInfSpy.mock.calls[3][0]).toContain('Malformed: 1'); - }); -}); - -afterEach(() => { - process.argv = []; - mockExit.mockClear(); - consoleInfSpy.mockClear(); - consoleLogSpy.mockClear(); - consoleWarnSpy.mockClear(); - consoleErrSpy.mockClear(); -}); - -afterAll(() => { - mockExit.mockRestore(); - consoleInfSpy.mockRestore(); - consoleLogSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - consoleErrSpy.mockRestore(); -}); diff --git a/tests/validate.test.js b/tests/validate.test.js new file mode 100644 index 0000000..583eb02 --- /dev/null +++ b/tests/validate.test.js @@ -0,0 +1,223 @@ +const validate = require('../src/index'); +const nodeLoader = require('../src/loader/node'); +const fs = require('fs/promises'); +const path = require('path'); + +const validCatalogPath = 'tests/examples/catalog.json'; +const invalidCatalogPath = 'tests/examples/invalid-catalog.json'; +const invalidSchemaPath = 'tests/invalid-schema.json'; +const invalidSchemaCatalogPath = 'tests/examples/catalog-with-invalid-schema.json'; +const apiItemsPath = 'tests/api/items.json'; +const apiCollectionsPath = 'tests/api/collections.json'; + +describe('Validate Function Tests', () => { + + describe('Validate a valid catalog', () => { + it('Should return valid=true for a valid catalog', async () => { + const config = { loader: nodeLoader }; + const result = await validate(validCatalogPath, config); + + expect(result.valid).toBe(true); + expect(result.type).toBe('Catalog'); + expect(result.version).toBe('1.0.0'); + expect(result.id).toBe('tests/examples/catalog.json'); + expect(result.results.core).toEqual([]); + expect(Object.keys(result.results.extensions)).toEqual([]); + expect(result.results.custom).toEqual([]); + }); + }); + + describe('Validate an invalid catalog', () => { + it('Should return valid=false for an invalid catalog', async () => { + const config = { loader: nodeLoader }; + const result = await validate(invalidCatalogPath, config); + + expect(result.valid).toBe(false); + expect(result.type).toBe('Catalog'); + expect(result.version).toBe('1.0.0'); + expect(result.id).toBe('tests/examples/invalid-catalog.json'); + expect(result.results.core).toHaveLength(1); + expect(result.results.core[0].keyword).toBe('required'); + expect(result.results.core[0].params.missingProperty).toBe('links'); + }); + }); + + describe('Validate valid API Collections', () => { + it('Should return valid=true for valid API collections', async () => { + const config = { loader: nodeLoader }; + const result = await validate(apiCollectionsPath, config); + + expect(result.valid).toBe(true); + expect(result.apiList).toBe(true); + expect(result.children).toHaveLength(1); + expect(result.children[0].valid).toBe(true); + expect(result.children[0].type).toBe('Collection'); + expect(result.children[0].version).toBe('1.0.0'); + expect(result.children[0].id).toBe('simple-collection'); + }); + }); + + describe('Validate partially invalid API Items (Item Collection)', () => { + it('Should return valid=false for partially invalid API items', async () => { + const config = { loader: nodeLoader }; + const result = await validate(apiItemsPath, config); + + expect(result.valid).toBe(false); + expect(result.apiList).toBe(true); + expect(result.children).toHaveLength(2); + + // First item should be valid + expect(result.children[0].valid).toBe(true); + expect(result.children[0].type).toBe('Feature'); + expect(result.children[0].version).toBe('1.0.0'); + expect(result.children[0].id).toBe('20201211_223832_CS2'); + + // Second item should be invalid + expect(result.children[1].valid).toBe(false); + expect(result.children[1].type).toBe('Feature'); + expect(result.children[1].version).toBe('1.0.0'); + expect(result.children[1].id).toBe('invalid'); + }); + }); + + describe('Validate with custom schema mapping', () => { + it('Should return valid=false when using invalid schema', async () => { + const config = { + loader: nodeLoader, + schemaMap: { + 'https://example.org/invalid-schema.json': invalidSchemaPath + } + }; + + const result = await validate(invalidSchemaCatalogPath, config); + + expect(result.valid).toBe(false); + expect(result.type).toBe('Catalog'); + expect(result.version).toBe('1.0.0'); + expect(result.id).toBe('tests/examples/catalog-with-invalid-schema.json'); + expect(result.results.extensions['https://example.org/invalid-schema.json']).toBeDefined(); + }); + }); + + describe('Validate with verbose messaging', () => { + it('Should include verbose messages for API collections', async () => { + const config = { loader: nodeLoader, verbose: true }; + const result = await validate(apiCollectionsPath, config); + + expect(result.valid).toBe(true); + expect(result.messages).toContain('The file is a CollectionCollection. Validating all 1 collections, but ignoring the other parts of the response.'); + }); + + it('Should include verbose messages for API items', async () => { + const config = { loader: nodeLoader, verbose: true }; + const result = await validate(apiItemsPath, config); + + expect(result.valid).toBe(false); + expect(result.messages).toContain('The file is a ItemCollection. Validating all 2 items, but ignoring the other parts of the response.'); + }); + }); + + describe('Validate with JSON objects directly', () => { + it('Should validate a STAC catalog object directly', async () => { + const catalogData = { + "stac_version": "1.0.0", + "id": "test-catalog", + "type": "Catalog", + "title": "Test Catalog", + "description": "A test catalog for validation", + "links": [] + }; + + const result = await validate(catalogData); + + expect(result.valid).toBe(true); + expect(result.type).toBe('Catalog'); + expect(result.version).toBe('1.0.0'); + expect(result.id).toBe('test-catalog'); + }); + + it('Should validate an invalid STAC catalog object directly', async () => { + const catalogData = { + "stac_version": "1.0.0", + "id": "test-catalog", + "type": "Catalog", + "title": "Test Catalog", + "description": "A test catalog for validation" + // missing required 'links' field + }; + + const result = await validate(catalogData); + + expect(result.valid).toBe(false); + expect(result.type).toBe('Catalog'); + expect(result.version).toBe('1.0.0'); + expect(result.id).toBe('test-catalog'); + expect(result.results.core).toHaveLength(1); + expect(result.results.core[0].keyword).toBe('required'); + expect(result.results.core[0].params.missingProperty).toBe('links'); + }); + }); + + describe('Version compatibility', () => { + it('Should skip validation for unsupported STAC versions', async () => { + const oldCatalogData = { + "stac_version": "0.9.0", + "id": "old-catalog", + "type": "Catalog", + "title": "Old Catalog", + "description": "An old catalog", + "links": [] + }; + + const result = await validate(oldCatalogData); + + expect(result.skipped).toBe(true); + expect(result.messages).toContain('Can only validate STAC version >= 1.0.0-rc.1'); + }); + + it('Should skip validation for missing STAC version', async () => { + const noVersionCatalogData = { + "id": "no-version-catalog", + "type": "Catalog", + "title": "No Version Catalog", + "description": "A catalog without version", + "links": [] + }; + + const result = await validate(noVersionCatalogData); + + expect(result.skipped).toBe(true); + expect(result.messages).toContain('No STAC version found'); + }); + }); + + describe('Type validation', () => { + it('Should return error for invalid type', async () => { + const invalidTypeData = { + "stac_version": "1.0.0", + "id": "invalid-type", + "type": "InvalidType", + "title": "Invalid Type", + "description": "An object with invalid type" + }; + + const result = await validate(invalidTypeData); + + expect(result.valid).toBe(false); + expect(result.results.core).toHaveLength(1); + expect(result.results.core[0].instancePath).toBe('/type'); + expect(result.results.core[0].message).toContain("Can't detect type of the STAC object"); + }); + }); + + describe('Strict mode validation', () => { + it('Should validate with strict mode enabled', async () => { + const config = { loader: nodeLoader, strict: true }; + const result = await validate(validCatalogPath, config); + + expect(result.valid).toBe(true); + expect(result.type).toBe('Catalog'); + expect(result.version).toBe('1.0.0'); + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..8319380 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,71 @@ +const path = require('path'); +const webpack = require('webpack'); + +module.exports = { + entry: './src/index.js', + output: { + filename: 'index.js', + path: path.resolve(__dirname, 'dist'), + library: 'validate', + libraryTarget: 'umd', + globalObject: 'this', + }, + mode: 'production', + externals: { + 'axios': 'axios' + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify('production'), + }), + new webpack.ProvidePlugin({ + process: 'process/browser', + }) + ], + resolve: { + fallback: { + // Only polyfill what we actually need + 'path': 'path-browserify', + // Disable everything else + 'fs': false, + 'fs-extra': false, + 'child_process': false, + 'net': false, + 'tls': false, + 'os': false, + 'crypto': false, + 'stream': false, + 'util': false, + 'url': false, + 'querystring': false, + 'http': false, + 'https': false, + 'zlib': false, + 'assert': false, + 'buffer': false, + 'events': false, + 'timers': false, + 'string_decoder': false, + 'constants': false, + 'vm': false + } + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env'] + } + } + } + ] + }, + target: 'web', + optimization: { + minimize: true + } +}; diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..d00a267 --- /dev/null +++ b/website/index.html @@ -0,0 +1,155 @@ + + + + + + STAC Online Checker + + + + + + + + + + + + + + + STAC Online Checker + + + Finds problems in SpatioTemporal Asset Catalog (STAC) files + Powered by stac-node-validator + + + + + + + + + + + + + + + + + + URL + + + + JSON + + + + + + + + + + + + + Enter the URL of a STAC Catalog, Collection, or Item to validate + + + + + STAC JSON + + + Paste the JSON content of a STAC Catalog, Collection, or + Item + + + + + + Check STAC + + + + + + + Loading... + + Fetching and validating STAC file... + + + + + + + + + Validation Results + + + + + + + + + + + + + + + + + + + + diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..82010d7 --- /dev/null +++ b/website/styles.css @@ -0,0 +1,256 @@ +/* Custom styles for STAC Validator - moreGeo color scheme */ +:root { + --moregeo-blue: #0086FF; + --moregeo-blue-light: #4da6ff; + --moregeo-orange: #FFA600; + --moregeo-gray: #495057; + --moregeo-gradient: linear-gradient(135deg, #0086FF 0%, #FFA600 100%); +} + +body { + background: var(--moregeo-gradient); + min-height: 100vh; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +} + +.card { + border: none; + border-radius: 15px; + overflow: hidden; +} + +.card-header { + background: var(--moregeo-gray) !important; + border-bottom: none; + padding: 2rem; +} + +.card-title { + font-size: 2rem; + font-weight: 600; +} + +/* Logo styling */ +.header-logo { + display: flex; + align-items: center; +} + +.header-logo a { + text-decoration: none !important; +} + +.header-logo a:hover { + text-decoration: none !important; +} + +.header-logo a:focus { + outline: 2px solid white; + outline-offset: 2px; + border-radius: 4px; +} + +.logo-img { + height: 60px; + width: auto; + transition: transform 0.3s ease; +} + +.logo-img:hover { + transform: scale(1.05); +} + +/* Link styling */ +a { + color: var(--moregeo-blue); + text-decoration: underline; + transition: color 0.3s ease; +} + +a:hover { + color: var(--moregeo-orange); + text-decoration: underline; +} + +a:focus { + outline: 2px solid var(--moregeo-blue); + outline-offset: 2px; + border-radius: 2px; +} + +/* Header links remain white with underline */ +.card-header a { + color: white; + text-decoration: underline; +} + +.card-header a:hover { + color: var(--moregeo-orange); + text-decoration: underline; +} + +.card-header a:focus { + outline: 2px solid white; + outline-offset: 2px; +} + +.card-body { + padding: 2rem; +} + +.input-group-text { + background-color: #f8f9fa; + border-color: #dee2e6; +} + +.form-control { + border-color: #dee2e6; + padding: 0.75rem 1rem; +} + +.form-control:focus { + border-color: var(--moregeo-blue); + box-shadow: 0 0 0 0.2rem rgba(0, 134, 255, 0.25); +} + +.alert { + border: none; + border-radius: 10px; + padding: 1rem 1.5rem; +} + +.alert-danger { + background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%); + color: #c62828; + border: 1px solid #f44336; +} + +.spinner-border { + width: 3rem; + height: 3rem; + color: var(--moregeo-blue); +} + +.fade-in { + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .card-header { + padding: 1.5rem; + } + + .card-title { + font-size: 1.5rem; + } + + .card-body { + padding: 1.5rem; + } +} + +/* JSON textarea styles */ +#stacJson { + font-family: "Courier New", monospace; + font-size: 0.9rem; + background-color: #f8f9fa; + border: 2px solid #dee2e6; + border-radius: 8px; + resize: vertical; + min-height: 200px; +} + +#stacJson:focus { + border-color: var(--moregeo-blue); + box-shadow: 0 0 0 0.2rem rgba(0, 134, 255, 0.25); + background-color: #fff; +} + +/* Validation method toggle */ +.btn-check:checked + .btn-outline-primary { + background: var(--moregeo-blue) !important; + border-color: var(--moregeo-blue) !important; + color: white !important; +} + +.btn-outline-primary { + border-color: var(--moregeo-blue) !important; + color: var(--moregeo-blue) !important; + background-color: white !important; + transition: all 0.3s ease; +} + +.btn-outline-primary:hover { + background: var(--moregeo-blue-light) !important; + border-color: var(--moregeo-blue-light) !important; + color: white !important; +} + +.btn-outline-primary:focus { + box-shadow: 0 0 0 0.2rem rgba(0, 134, 255, 0.25) !important; + background: var(--moregeo-blue-light) !important; + border-color: var(--moregeo-blue-light) !important; + color: white !important; +} + +.btn-outline-primary:active { + background: var(--moregeo-blue) !important; + border-color: var(--moregeo-blue) !important; + color: white !important; +} + +/* Enhanced validation error styling */ +.validation-error { + background-color: #fff5f5; + border-left: 4px solid #dc3545; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0 8px 8px 0; +} + +.validation-error h6 { + color: #dc3545; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.validation-error p { + color: #2d3748; + margin-bottom: 0.25rem; +} + +.validation-error small { + color: #718096; + font-style: italic; +} + +/* Validation results header styling */ +#results .card-header { + background: var(--moregeo-gray) !important; + color: white !important; + transition: background-color 0.3s ease; +} + +#results .card-header.validation-success { + background: #28a745 !important; +} + +#results .card-header.validation-error { + background: #dc3545 !important; +} + +#results .card-header h5 { + color: white !important; +} diff --git a/website/validator.js b/website/validator.js new file mode 100644 index 0000000..7fa182b --- /dev/null +++ b/website/validator.js @@ -0,0 +1,318 @@ +// STAC Validator JavaScript using stac-node-validator bundle +class STACValidator { + constructor() { + console.log("STACValidator initialized"); + this.init(); + } + + init() { + this.setupEventListeners(); + this.populateFromQueryParams(); + } + + populateFromQueryParams() { + const urlParams = new URLSearchParams(window.location.search); + const urlParam = urlParams.get('url'); + + if (urlParam) { + const urlInput = document.getElementById("stacUrl"); + urlInput.value = urlParam.trim(); + + // Trigger the background JSON population + this.populateJsonFromUrl(); + } + } + + setupEventListeners() { + const form = document.getElementById("validationForm"); + const urlMethodRadio = document.getElementById("urlMethod"); + const jsonMethodRadio = document.getElementById("jsonMethod"); + + console.log("Setting up event listeners"); + + // Handle form submission + form.addEventListener("submit", (e) => { + e.preventDefault(); + if (urlMethodRadio.checked) { + this.populateJsonFromUrl(); + } + this.validateInput(); + }); + + // Handle method toggle + urlMethodRadio.addEventListener("change", () => { + if (urlMethodRadio.checked) { + document.getElementById("urlInput").classList.remove("d-none"); + document.getElementById("jsonInput").classList.add("d-none"); + } + }); + + jsonMethodRadio.addEventListener("change", () => { + if (jsonMethodRadio.checked) { + document.getElementById("urlInput").classList.add("d-none"); + document.getElementById("jsonInput").classList.remove("d-none"); + } + }); + } + + async validateInput() { + const method = document.querySelector( + 'input[name="validationMethod"]:checked' + ).value; + + if (method === "url") { + await this.validateURL(); + } else { + await this.validateJSON(); + } + } + + async validateURL() { + const url = document.getElementById("stacUrl").value.trim(); + if (!url) { + this.showError("Please enter a valid URL"); + return; + } + + this.showLoading(); + + try { + // Fetch the STAC content from the URL + const response = await axios.get(url); + const stacData = response.data; + + // Validate the fetched data + await this.performValidation(stacData); + } catch (error) { + this.showError(`Error fetching URL: ${error.message}`); + } + } + + async validateJSON() { + const jsonText = document.getElementById("stacJson").value.trim(); + + if (!jsonText) { + this.showError("Please enter valid JSON"); + return; + } + + this.showLoading(); + + try { + // Parse the JSON + const stacData = JSON.parse(jsonText); + + // Validate the parsed data + await this.performValidation(stacData); + } catch (error) { + this.showError(`Error parsing JSON: ${error.message}`); + } + } + + async performValidation(stacData) { + try { + // Check if the validate function is available from the bundle + if (typeof validate === "undefined") { + throw new Error("STAC validator bundle not loaded properly"); + } + + // Use the validate function from the bundle + const report = await validate(stacData); + + // Convert the report to our results format + const results = this.convertReportToResults(report); + + this.showResults(results); + } catch (error) { + this.showError(`Validation error: ${error.message}`); + } + } + + convertReportToResults(report) { + const results = { + valid: report.valid, + errors: [], + }; + + // Collect errors from all sources + if (report.results.core && report.results.core.length > 0) { + results.errors.push( + ...report.results.core.map((error) => ({ + field: error.instancePath || error.dataPath || "core", + message: error.message, + schema: error.schemaPath || "core", + })) + ); + } + + if (report.results.extensions) { + Object.entries(report.results.extensions).forEach(([ext, errors]) => { + if (errors && errors.length > 0) { + results.errors.push( + ...errors.map((error) => ({ + field: error.instancePath || error.dataPath || ext, + message: error.message, + schema: error.schemaPath || ext, + })) + ); + } + }); + } + + if (report.results.custom && report.results.custom.length > 0) { + results.errors.push( + ...report.results.custom.map((error) => ({ + field: error.instancePath || error.dataPath || "custom", + message: error.message, + schema: error.schemaPath || "custom", + })) + ); + } + + // Add any general messages as errors if validation failed + if (!report.valid && report.messages && report.messages.length > 0) { + results.errors.push( + ...report.messages.map((message) => ({ + field: "general", + message: message, + schema: "general", + })) + ); + } + + return results; + } + + showLoading() { + document.getElementById("loadingState").classList.remove("d-none"); + document.getElementById("results").classList.add("d-none"); + document.getElementById("validateBtn").disabled = true; + } + + showResults(results) { + document.getElementById("loadingState").classList.add("d-none"); + document.getElementById("validateBtn").disabled = false; + + const resultsDiv = document.getElementById("results"); + const statusDiv = document.getElementById("resultStatus"); + const contentDiv = document.getElementById("resultContent"); + const headerDiv = resultsDiv.querySelector(".card-header"); + const headerTitle = resultsDiv.querySelector(".card-header h5"); + const cardBody = resultsDiv.querySelector(".card-body"); + + // Update header based on validation result + if (results.valid) { + headerDiv.classList.remove("validation-error"); + headerDiv.classList.add("validation-success"); + headerTitle.innerHTML = 'Checks PASSED'; + statusDiv.innerHTML = ""; // Remove badge + } else { + headerDiv.classList.remove("validation-success"); + headerDiv.classList.add("validation-error"); + headerTitle.innerHTML = + 'Checks FAILED'; + statusDiv.innerHTML = ""; // Remove badge + } + + // Build content only if there are errors + let html = ""; + if (results.errors.length > 0) { + results.errors.forEach((error) => { + html += ` + + ${ + error.field || "Unknown field" + } + ${error.message} + ${ + error.schema + ? `Schema: ${error.schema}` + : "" + } + + `; + }); + } + + // Show/hide card-body based on whether there are errors + if (results.errors.length > 0) { + contentDiv.innerHTML = html; + cardBody.classList.remove("d-none"); + } else { + contentDiv.innerHTML = ""; + cardBody.classList.add("d-none"); + } + + resultsDiv.classList.remove("d-none"); + resultsDiv.classList.add("fade-in"); + } + + showError(message) { + document.getElementById("loadingState").classList.add("d-none"); + document.getElementById("validateBtn").disabled = false; + + const resultsDiv = document.getElementById("results"); + const statusDiv = document.getElementById("resultStatus"); + const contentDiv = document.getElementById("resultContent"); + const headerDiv = resultsDiv.querySelector(".card-header"); + const headerTitle = resultsDiv.querySelector(".card-header h5"); + + // Update header for error state + headerDiv.classList.remove("validation-success"); + headerDiv.classList.add("validation-error"); + headerTitle.innerHTML = + 'Validation Results - Error'; + statusDiv.innerHTML = ""; // Remove badge + + contentDiv.innerHTML = ` + + Error + ${message} + + `; + + resultsDiv.classList.remove("d-none"); + resultsDiv.classList.add("fade-in"); + } + + async populateJsonFromUrl() { + const url = document.getElementById("stacUrl").value.trim(); + + // Only try to fetch if URL looks valid and is not empty + if (!url || !this.isValidUrl(url)) { + return; + } + + try { + // Fetch the STAC content from the URL silently + const response = await axios.get(url, { + timeout: 5000, // 5 second timeout + headers: { + Accept: "application/json", + }, + }); + + // Only populate if we got valid JSON and textarea is still empty + const jsonTextarea = document.getElementById("stacJson"); + if (response.data && typeof response.data === "object" && jsonTextarea.value.trim() === "") { + jsonTextarea.value = JSON.stringify(response.data, null, 2); + } + } catch (error) { + // Silently fail - don't show any errors to user + } + } + + isValidUrl(string) { + try { + const url = new URL(string); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (_) { + return false; + } + } +} + +// Initialize the validator when the DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + new STACValidator(); +});
+ Finds problems in SpatioTemporal Asset Catalog (STAC) files + Powered by stac-node-validator + + +
Fetching and validating STAC file...
${error.message}
${message}