diff --git a/package-lock.json b/package-lock.json index 4076a8e..755ae0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@apidevtools/json-schema-ref-parser": "^11.7.2", "fs-extra": "^11.1.1", "image-size": "^1.0.2", - "stac-node-validator": "2.0.0-beta.12" + "stac-node-validator": "2.0.0-beta.13" }, "bin": { "open-science-catalog-validation": "bin/cli.js" @@ -176,11 +176,11 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -321,9 +321,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -348,9 +348,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -800,14 +800,14 @@ } }, "node_modules/stac-node-validator": { - "version": "2.0.0-beta.12", - "resolved": "https://registry.npmjs.org/stac-node-validator/-/stac-node-validator-2.0.0-beta.12.tgz", - "integrity": "sha512-fe/HcQ33EUbDLzYXaQDAqzdDcl1hHP8rYQf4WHZaBeO9jHIuidUeKWt/juQ26EbALhkBG9UNvhfrlH5TwoPExA==", + "version": "2.0.0-beta.13", + "resolved": "https://registry.npmjs.org/stac-node-validator/-/stac-node-validator-2.0.0-beta.13.tgz", + "integrity": "sha512-JCFT03f8oUG5AC+YtMoktGCQXXgOPvWOC5LOFgXFKfymlHlUqVdsYOUY0hckW0nO6xpe9LANN6K0sbpV0qTIjw==", "dependencies": { "ajv": "^8.8.2", "ajv-formats": "^2.1.1", "assert": "^2.0.0", - "axios": "^1.1.3", + "axios": "^1.7.4", "compare-versions": "^6.1.0", "fs-extra": "^10.0.0", "jest-diff": "^29.0.1", diff --git a/package.json b/package.json index 8e2bb0a..143ea11 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@apidevtools/json-schema-ref-parser": "^11.7.2", "fs-extra": "^11.1.1", "image-size": "^1.0.2", - "stac-node-validator": "2.0.0-beta.12" + "stac-node-validator": "2.0.0-beta.13" }, "scripts": { "dereference": "node ./dereference.js", diff --git a/schemas/records.json b/schemas/records.json new file mode 100644 index 0000000..a976ac0 --- /dev/null +++ b/schemas/records.json @@ -0,0 +1,775 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/EOEPCA/metadata-profile/refs/heads/master/resource.json", + "title": "EOEPCA metadata profile", + "description": "EOEPCA metadata profile", + "allOf": [ + { + "type": "object", + "required": [ + "id", + "type", + "geometry", + "properties" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier of the catalog record." + }, + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "time": { + "oneOf": [ + { + "enum": [ + null + ] + }, + { + "type": "object", + "nullable": true, + "properties": { + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "timestamp": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$" + }, + "interval": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "oneOf": [ + { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$" + }, + { + "type": "string", + "enum": [ + ".." + ] + } + ] + } + }, + "resolution": { + "type": "string", + "description": "Minimum time period resolvable in the dataset, as an ISO 8601 duration", + "examples": [ + "P1D" + ] + } + } + } + ] + }, + "geometry": { + "oneOf": [ + { + "enum": [ + null + ] + }, + { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPoint" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "LineString" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiLineString" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "geometries" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "GeometryCollection" + ] + }, + "geometries": { + "type": "array", + "items": { + "$ref": "#/allOf/0/properties/geometry/oneOf/1" + } + } + } + } + ] + } + ] + }, + "properties": { + "allOf": [ + { + "type": "object", + "properties": { + "conformsTo": { + "type": "array", + "description": "The extensions/conformance classes used in this record.", + "items": { + "type": "string" + } + }, + "created": { + "type": "string", + "description": "The date this record was created in the server.", + "format": "date-time" + }, + "updated": { + "type": "string", + "description": "The most recent date on which the record was changed.", + "format": "date-time" + }, + "type": { + "type": "string", + "description": "The nature or genre of the resource. The value should be a code, convenient for filtering records. Where available, a link to the canonical URI of the record type resource will be added to the 'links' property." + }, + "title": { + "type": "string", + "description": "A human-readable name given to the resource." + }, + "description": { + "type": "string", + "description": "A free-text account of the resource." + }, + "keywords": { + "type": "array", + "description": "The topic or topics of the resource. Typically represented using free-form keywords, tags, key phrases, or classification codes.", + "items": { + "type": "string" + } + }, + "themes": { + "type": "array", + "description": "A knowledge organization system used to classify the resource.", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "concepts", + "scheme" + ], + "properties": { + "concepts": { + "type": "array", + "description": "One or more entity/concept identifiers from this knowledge system. it is recommended that a resolvable URI be used for each entity/concept identifier.", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "description": "An identifier for the concept." + }, + "title": { + "type": "string", + "description": "A human readable title for the concept." + }, + "description": { + "type": "string", + "description": "A human readable description for the concept." + }, + "url": { + "type": "string", + "format": "uri", + "description": "A URI providing further description of the concept." + } + } + } + }, + "scheme": { + "type": "string", + "description": "An identifier for the knowledge organization system used to classify the resource. It is recommended that the identifier be a resolvable URI. The list of schemes used in a searchable catalog can be determined by inspecting the server's OpenAPI document or, if the server implements CQL2, by exposing a queryable (e.g. named `scheme`) and enumerating the list of schemes in the queryable's schema definition." + } + } + } + }, + "language": { + "description": "The language used for textual values in this record representation.", + "$ref": "#/allOf/0/properties/properties/allOf/0/properties/languages/items" + }, + "languages": { + "type": "array", + "description": "This list of languages in which this record is available.", + "items": { + "type": "object", + "description": "The language used for textual values in this record.", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string", + "description": "The language tag as per RFC-5646.", + "examples": ["el"] + }, + "name": { + "type": "string", + "minLength": 1, + "description": "The untranslated name of the language.", + "examples": ["Ελληνικά"] + }, + "alternate": { + "type": "string", + "description": "The name of the language in another well-understood language, usually English.", + "examples": ["Greek"] + }, + "dir": { + "type": "string", + "description": "The direction for text in this language. The default, `ltr` (left-to-right), represents the most common situation. However, care should be taken to set the value of `dir` appropriately if the language direction is not `ltr`. Other values supported are `rtl` (right-to-left), `ttb` (top-to-bottom), and `btt` (bottom-to-top).", + "enum": [ + "ltr", + "rtl", + "ttb", + "btt" + ], + "default": "ltr" + } + } + } + }, + "resourceLanguages": { + "type": "array", + "description": "The list of languages in which the resource described by this record is available.", + "items": { + "$ref": "#/allOf/0/properties/properties/allOf/0/properties/languages/items" + } + }, + "externalIds": { + "type": "array", + "description": "An identifier for the resource assigned by an external (to the catalog) entity.", + "items": { + "type": "object", + "properties": { + "scheme": { + "type": "string", + "description": "A reference to an authority or identifier for a knowledge organization system from which the external identifier was obtained. It is recommended that the identifier be a resolvable URI." + }, + "value": { + "type": "string", + "description": "The value of the identifier." + } + }, + "required": [ + "value" + ] + } + }, + "formats": { + "type": "array", + "description": "A list of available distributions of the resource.", + "items": { + "type": "object", + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "mediaType" + ] + } + ], + "properties": { + "name": { + "type": "string" + }, + "mediaType": { + "type": "string" + } + } + } + }, + "contacts": { + "type": "array", + "description": "A list of contacts qualified by their role(s) in association to the record or the resource described by the record.", + "items": { + "type": "object", + "description": "Identification of, and means of communication with, person responsible\nfor the resource.", + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "organization" + ] + } + ], + "properties": { + "identifier": { + "type": "string", + "description": "A value uniquely identifying a contact." + }, + "name": { + "type": "string", + "description": "The name of the responsible person." + }, + "position": { + "type": "string", + "description": "The name of the role or position of the responsible person taken from the organization's formal organizational hierarchy or chart." + }, + "organization": { + "type": "string", + "description": "Organization/affiliation of the contact." + }, + "logo": { + "description": "Graphic identifying a contact. The link relation should be `icon` and the media type should be an image media type.", + "allOf": [ + { + "type": "object", + "required": [ + "href", + "rel" + ], + "properties": { + "href": { + "type": "string", + "examples": ["http://data.example.com/buildings/123"] + }, + "rel": { + "type": "string", + "examples": ["alternate"] + }, + "type": { + "type": "string", + "examples": ["application/geo+json"] + }, + "hreflang": { + "type": "string", + "examples": ["en"] + }, + "title": { + "type": "string", + "examples": ["Trierer Strasse 70, 53115 Bonn"] + }, + "length": { + "type": "integer" + } + } + }, + { + "type": "object", + "required": [ + "rel", + "type" + ], + "properties": { + "rel": { + "enum": [ + "icon" + ] + } + } + } + ] + }, + "phones": { + "type": "array", + "description": "Telephone numbers at which contact can be made.", + "items": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "description": "The value is the phone number itself.", + "pattern": "^\\+[1-9]{1}[0-9]{3,14}$", + "examples": ["+14165550142"] + }, + "roles": { + "description": "The type of phone number (e.g. home, work, fax, etc.).", + "$ref": "#/allOf/0/properties/properties/allOf/0/properties/contacts/items/properties/roles" + } + } + } + }, + "emails": { + "type": "array", + "description": "Email addresses at which contact can be made.", + "items": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "description": "The value is the email number itself.", + "format": "email" + }, + "roles": { + "description": "The type of email (e.g. home, work, etc.).", + "$ref": "#/allOf/0/properties/properties/allOf/0/properties/contacts/items/properties/roles" + } + } + } + }, + "addresses": { + "type": "array", + "description": "Physical location at which contact can be made.", + "items": { + "type": "object", + "properties": { + "deliveryPoint": { + "type": "array", + "description": "Address lines for the location.", + "items": { + "type": "string" + } + }, + "city": { + "type": "string", + "description": "City for the location." + }, + "administrativeArea": { + "type": "string", + "description": "State or province of the location." + }, + "postalCode": { + "type": "string", + "description": "ZIP or other postal code." + }, + "country": { + "type": "string", + "description": "Country of the physical address. ISO 3166-1 is recommended." + }, + "roles": { + "description": "The type of address (e.g. office, home, etc.).", + "$ref": "#/allOf/0/properties/properties/allOf/0/properties/contacts/items/properties/roles" + } + } + } + }, + "links": { + "type": "array", + "description": "On-line information about the contact.", + "items": { + "allOf": [ + { + "$ref": "#/allOf/0/properties/properties/allOf/0/properties/contacts/items/properties/logo/allOf/0" + }, + { + "type": "object", + "required": [ + "type" + ] + } + ] + } + }, + "hoursOfService": { + "type": "string", + "description": "Time period when the contact can be contacted.", + "examples": ["Hours: Mo-Fr 10am-7pm Sa 10am-22pm Su 10am-21pm"] + }, + "contactInstructions": { + "type": "string", + "description": "Supplemental instructions on how or when to contact the\nresponsible party." + }, + "roles": { + "description": "The set of named duties, job functions and/or permissions associated with this contact. (e.g. developer, administrator, etc.).", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + }, + "license": { + "type": "string", + "description": "A legal document under which the resource is made available. If the resource is being made available under a common license then use an SPDX license id (https://spdx.org/licenses/). If the resource is being made available under multiple common licenses then use an SPDX license expression v2.3 string (https://spdx.github.io/spdx-spec/v2.3/SPDX-license-expressions/) If the resource is being made available under one or more licenses that haven't been assigned an SPDX identifier or one or more custom licenses then use a string value of 'other' and include one or more links (rel=\"license\") in the `link` section of the record to the file(s) that contains the text of the license(s). There is also the case of a resource that is private or unpublished and is thus unlicensed; in this case do not register such a resource in the catalog in the first place since there is no point in making such a resource discoverable." + }, + "rights": { + "type": "string", + "description": "A statement that concerns all rights not addressed by the license such as a copyright statement." + } + } + }, + { + "type": "object" + } + ] + }, + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rel": { + "type": "string", + "description": "The type or semantics of the relation.", + "examples": ["alternate"] + }, + "type": { + "type": "string", + "description": "A hint indicating what the media type of the result of dereferencing the link should be.", + "examples": ["application/geo+json"] + }, + "hreflang": { + "type": "string", + "description": "A hint indicating what the language of the result of dereferencing the link should be.", + "examples": ["en"] + }, + "title": { + "type": "string", + "description": "Used to label the destination of a link such that it can be used as a human-readable identifier.", + "examples": ["Trierer Strasse 70, 53115 Bonn"] + }, + "length": { + "type": "integer" + }, + "created": { + "type": "string", + "description": "Date of creation of the resource pointed to by the link.", + "format": "date-time" + }, + "updated": { + "type": "string", + "description": "Most recent date on which the resource pointed to by the link was changed.", + "format": "date-time" + } + } + } + }, + "linkTemplates": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/allOf/0/properties/links/items" + }, + { + "type": "object", + "required": [ + "uriTemplate" + ], + "properties": { + "uriTemplate": { + "type": "string", + "description": "Supplies a resolvable URI to a remote resource (or resource fragment).", + "examples": ["http://data.example.com/buildings/(building-id}"] + }, + "varBase": { + "type": "string", + "description": "The base URI to which the variable name can be appended to retrieve the definition of the variable as a JSON Schema fragment.", + "format": "uri" + }, + "variables": { + "type": "object", + "description": "This object contains one key per substitution variable in the templated URL. Each key defines the schema of one substitution variable using a JSON Schema fragment and can thus include things like the data type of the variable, enumerations, minimum values, maximum values, etc." + } + } + } + ] + } + } + } + }, + { + "properties": { + "properties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "EOEPCA resource type", + "enum": [ + "dataset", + "service", + "process", + "workflow" + ] + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/validate.js b/validate.js index dc3e545..b393545 100644 --- a/validate.js +++ b/validate.js @@ -8,11 +8,22 @@ const { THEMES_SCHEME } = require('./definitions.js'); +const RECORDS_CONFORMANCE_CLASS = "http://wis.wmo.int/spec/wcmp/2/conf/core"; // todo + class CustomValidator extends BaseValidator { constructor() { super(); this.titles = {}; + this.recordsValidator = null; + } + + async getRecordsValidator(ajv) { + if (this.recordsValidator === null) { + const recordsSchema = await fs.readJson('./schemas/records.json'); + this.recordsValidator = await ajv.compileAsync(recordsSchema); + } + return this.recordsValidator; } async getTitleForFile(file) { @@ -33,7 +44,33 @@ class CustomValidator extends BaseValidator { } } - async afterLoading(data, report, config) { + async bypassValidation(data, report, config) { + if (Array.isArray(data.conformsTo) && data.conformsTo.includes(RECORDS_CONFORMANCE_CLASS)) { + const setValidity = (errors = []) => { + report.valid = report.valid !== false && errors.length === 0; + report.results.core = errors; + }; + const recordsValidator = await this.getRecordsValidator(config.ajv); + try { + const valid = recordsValidator(data); + if (!valid) { + setValidity(recordsValidator.errors); + } + else { + setValidity(); + } + } catch (error) { + setValidity([{ message: error.message }]); + } + + // If stac_version is present, continue with STAC validation additionally. + // Otherwise return report and abort validation. + return (typeof data.stac_version !== 'string') ? report : null; + } + return null; + } + + async afterLoading(data, report, config) { // Add UI schema to STAC extensions to validate against them additionally const match = report.id.match(/\/(eo-missions|products|projects|themes|variables)\/(catalog.json|.+)/); if (match && !Array.isArray(data.stac_extensions)) { @@ -44,7 +81,7 @@ class CustomValidator extends BaseValidator { this.registerTitle(report.id, data); return data; - } + } async afterValidation(data, test, report, config) { const isRootCatalog = report.id.endsWith('/catalog.json') && data.id === "osc";