diff --git a/cypress/integration/validation.js b/cypress/integration/validation.js index 2161406..bd2073c 100644 --- a/cypress/integration/validation.js +++ b/cypress/integration/validation.js @@ -1,1026 +1,1026 @@ /** * Copyright (C) 2020-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ /* * Tests the basic features of the application. */ "use strict"; describe('Document validation', function() { it('accepts empty document', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", })) ); cy.get('#validateCodemeta').click(); cy.get('#name').should('have.value', ''); cy.get('#errorMessage').should('have.text', ''); }); it('accepts all main fields', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "license": "https://spdx.org/licenses/AGPL-3.0", "dateCreated": "2019-10-02", "datePublished": "2020-01-01", "name": "My Test Software", "description": "This is a\ngreat piece of software", })) ); cy.get('#validateCodemeta').click(); cy.get('#name').should('have.value', ''); cy.get('#errorMessage').should('have.text', ''); }); it('accepts anything in non-validated fields', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "programmingLanguage": "foo", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "programmingLanguage": 21, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "programmingLanguage": {}, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on invalid type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "foo", "name": "My Test Software", })) ); cy.get('#validateCodemeta').click(); cy.get('#name').should('have.value', ''); cy.get('#errorMessage').should('have.text', 'Wrong document type: must be "SoftwareSourceCode"/"SoftwareApplication", not "foo"'); }); it('errors on invalid field name', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "foobar": "baz", })) ); cy.get('#validateCodemeta').click(); cy.get('#name').should('have.value', ''); cy.get('#errorMessage').should('have.text', 'Unknown field "foobar".'); }); }); describe('URLs validation', function() { it('accepts valid URL', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": "http://example.org/", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts empty list of URLs', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": [], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts list of valid URLs', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": ["http://example.org/", "http://example.com/"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on invalid URL', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": "foo", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', 'Invalid URL in field "codeRepository": "foo"'); }); it('errors on non-string instead of URL', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": {}, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"codeRepository" must be an URL (or a list of URLs), not: {}'); }); it('errors on list with an invalid URL at the end', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": ["http://example.org/", "foo"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', 'Invalid URL in field "codeRepository": "foo"'); }); it('errors on list with an invalid URL at the beginning', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": ["http://example.org/", "foo"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', 'Invalid URL in field "codeRepository": "foo"'); }); it('errors on non-string in URL list', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "codeRepository": ["http://example.org/", {}], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"codeRepository" must be a list of URLs (or a single URL), but it contains: {}'); }); }); describe('Things or URLs validation', function() { it('accepts valid Thing', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": { "@type": "SoftwareApplication", "name": "Example Soft", } })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts valid URL', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": "http://example.org/", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts empty list of Things', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": [], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts list of Things', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": [ { "@type": "SoftwareApplication", "name": "Example Soft", }, { "@type": "SoftwareApplication", "name": "Test Soft", } ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on non-URL string', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "license": "Copyright 2021 Myself", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"license" must be an URL or a CreativeWork/SoftwareSourceCode/SoftwareApplication object, not: "Copyright 2021 Myself"'); }); it('errors on wrong type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": 42, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"isPartOf" must be a CreativeWork/SoftwareSourceCode/SoftwareApplication object or URI, not 42'); }); it('errors on non-Thing object', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": {}, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"isPartOf" must be a (list of) CreativeWork/SoftwareSourceCode/SoftwareApplication object(s) or an URI, but is missing a type/@type.'); }); it('errors on list with an invalid Thing at the beginning', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": [ {}, { "@type": "SoftwareApplication", "name": "Example Soft", } ], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"isPartOf" must be a (list of) CreativeWork/SoftwareSourceCode/SoftwareApplication object(s) or an URI, but is missing a type/@type.'); }); it('errors on list with an invalid Thing at the end', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": [ { "@type": "SoftwareApplication", "name": "Example Soft", }, {} ], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"isPartOf" must be a (list of) CreativeWork/SoftwareSourceCode/SoftwareApplication object(s) or an URI, but is missing a type/@type.'); }); }); describe('Texts or URLs validation', function() { it('accepts valid Text', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": "foo", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts valid URL', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": "http://example.org/", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts empty list of Texts', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": [], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts list of Texts', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": ["foo", "bar"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on non-string instead of Text', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": {}, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"applicationCategory" must be a text/URL (or a list of texts/URLs), not: {}'); }); it('errors on list with an invalid Text at the beginning', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": [{}, "foo"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"applicationCategory" must be a list of texts/URLs (or a single text/URL), but it contains: {}'); }); it('errors on list with an invalid Text at the end', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "applicationCategory": ["foo", {}], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"applicationCategory" must be a list of texts/URLs (or a single text/URL), but it contains: {}'); }); }); describe('Text validation', function() { it('accepts valid Text', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "description": "foo", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on empty list of Texts', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "description": [], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"description" must be text, not []'); }); it('errors on list of Texts', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "description": ["foo", "bar"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"description" must be text, not ["foo","bar"]'); }); it('errors on non-string instead of Text', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "description": {}, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"description" must be text, not {}'); }); }); describe('Date validation', function() { it('accepts valid Date', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "dateCreated": "2020-03-18", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on empty list of Dates', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "dateCreated": [], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"dateCreated" must be a date, not []'); }); it('errors on list of Dates', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "dateCreated": ["2020-03-18", "2020-03-19"], })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"dateCreated" must be a date, not ["2020-03-18","2020-03-19"]'); }); it('errors on non-string instead of Date', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "dateCreated": {}, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"dateCreated" must be a date, not {}'); }); it('errors on non-Date string', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "dateCreated": "foo", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"dateCreated" must be a date in the format YYYY-MM-DD, not "foo"'); }); }); describe('Person validation', function() { it('accepts URI', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": "http://example.org/~jdoe", })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts valid complete Person', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "@type": "Person", "@id": "http://example.org/~jdoe", "url": "http://example.org/~jdoe", "name": "Jane Doe", "givenName": "Jane", "familyName": "Doe", "email": "jdoe@example.org", "affiliation": { "@type": "Organization", "@id": "http://example.org/", } } })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on Person with missing type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" must be a (list of) Role/Person/Organization object(s) or an URI, but is missing a type/@type.'); }); it('errors on Person with wrong type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "type": "SoftwareSourceCode", }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" type must be a (list of) Role/Person/Organization object(s), not "SoftwareSourceCode"'); }); it('errors on Person with unknown field', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "type": "Person", "foo": "bar", }, })) ); cy.get('#validateCodemeta').click(); - cy.get('#errorMessage').should('have.text', 'Unknown field "foo" in "author".'); + cy.get('#errorMessage').should('have.text', 'Unknown field "foo".'); }); it('errors on Person with invalid field', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "type": "Person", "email": 32, }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"email" must be text, not 32'); }); it('accepts URI in list', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ "http://example.org/~jadoe", { "@type": "Person", "@id": "http://example.org/~jodoe", "givenName": "John", "familyName": "Doe", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts list of valid Person', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ { "@type": "Person", "@id": "http://example.org/~jadoe", "givenName": "Jane", "familyName": "Doe", }, { "@type": "Person", "@id": "http://example.org/~jodoe", "givenName": "John", "familyName": "Doe", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('accepts Person with multiple affiliations', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "@type": "Person", "@id": "http://example.org/~jdoe", "name": "Jane Doe", "affiliation": [ { "@type": "Organization", "@id": "http://example.org/", }, { "@type": "Organization", "@id": "http://example.com/", }, ] } })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on list with invalid Person at the beginning', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ { "givenName": "Jane", "familyName": "Doe", }, { "@type": "Person", "@id": "http://example.org/~jodoe", "name": "John Doe", "givenName": "John", "familyName": "Doe", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" must be a (list of) Role/Person/Organization object(s) or an URI, but is missing a type/@type.'); }); it('errors on list with invalid Person at the end', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ { "@type": "Person", "@id": "http://example.org/~jadoe", "givenName": "Jane", "familyName": "Doe", }, { "givenName": "John", "familyName": "Doe", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" must be a (list of) Role/Person/Organization object(s) or an URI, but is missing a type/@type.'); }); }); describe('Organization validation', function() { it('accepts valid complete Organization', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "@type": "Organization", "@id": "http://example.org/", "url": "https://example.org/", "name": "Example Org", "identifier": "http://example.org/", "address": "Nowhere", } })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on Organization with missing type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" must be a (list of) Role/Person/Organization object(s) or an URI, but is missing a type/@type.'); }); it('errors on Organization with wrong type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "type": "SoftwareSourceCode", }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" type must be a (list of) Role/Person/Organization object(s), not "SoftwareSourceCode"'); }); it('errors on Organization with unknown field', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "type": "Organization", "foo": "bar", }, })) ); cy.get('#validateCodemeta').click(); - cy.get('#errorMessage').should('have.text', 'Unknown field "foo" in "author".'); + cy.get('#errorMessage').should('have.text', 'Unknown field "foo".'); }); it('errors on Organization with invalid field', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": { "type": "Organization", "email": 32, }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"email" must be text, not 32'); }); it('accepts list of valid Organization', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ { "@type": "Organization", "@id": "http://example.org/", "name": "Example Org", }, { "@type": "Organization", "@id": "http://example.org/~jodoe", "name": "Example Org", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on list with invalid Organization at the beginning', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ { "name": "Example Org", }, { "@type": "Organization", "name": "Example Org", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" must be a (list of) Role/Person/Organization object(s) or an URI, but is missing a type/@type.'); }); it('errors on list with invalid Organization at the end', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "author": [ { "@type": "Organization", "name": "Example Org", }, { "name": "Example Org", }, ] })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"author" must be a (list of) Role/Person/Organization object(s) or an URI, but is missing a type/@type.'); }); }); describe('CreativeWork validation', function() { it('accepts valid CreativeWork', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "name": "Small Software", "isPartOf": { "type": "CreativeWork", "name": "Big Creative Work", "author": "http://example.org/~jdoe", "keywords": ["foo", "bar"], } })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); }); it('errors on CreativeWork with missing type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": { } })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"isPartOf" must be a (list of) CreativeWork/SoftwareSourceCode/SoftwareApplication object(s) or an URI, but is missing a type/@type.'); }); it('errors on CreativeWork with wrong type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": { "type": "Person", }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"isPartOf" type must be a (list of) CreativeWork/SoftwareSourceCode/SoftwareApplication object(s), not "Person"'); }); it('errors on CreativeWork with unknown field', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": { "type": "CreativeWork", "foo": "bar", }, })) ); cy.get('#validateCodemeta').click(); - cy.get('#errorMessage').should('have.text', 'Unknown field "foo" in "isPartOf".'); + cy.get('#errorMessage').should('have.text', 'Unknown field "foo".'); }); it('errors on CreativeWork with invalid field', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "isPartOf": { "type": "CreativeWork", "url": 32, }, })) ); cy.get('#validateCodemeta').click(); cy.get('#errorMessage').should('have.text', '"url" must be an URL (or a list of URLs), not: 32'); }); }); diff --git a/index.html b/index.html index cda6dc8..9ea85b4 100644 --- a/index.html +++ b/index.html @@ -1,394 +1,395 @@ CodeMeta generator +

CodeMeta generator v3.0

Most fields are optional. Mandatory fields will be highlighted when generating Codemeta.

The software itself

the software title


from SPDX licence list

Discoverability and citation


such as ISBNs, GTIN codes, UUIDs etc.. http://schema.org/identifier


grant funding software development


organization funding software development

Authors and contributors can be added below
Development community / tools


Run-time environment


Current version of the software


Editorial review

Additional Info


see www.repostatus.org for details

Authors
Contributors

Order of contributors does not matter.

codemeta.json:


   
diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index 6dd111d..195e9a0 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -1,437 +1,439 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ "use strict"; -const LOCAL_CONTEXT_PATH = "./data/contexts/codemeta-local.jsonld"; -const LOCAL_CONTEXT_URL = "local"; +const INTERNAL_CONTEXT_PATH = "./data/contexts/codemeta-local.jsonld"; +const INTERNAL_CONTEXT_URL = "internal"; const CODEMETA_CONTEXTS = { "2.0": { path: "./data/contexts/codemeta-2.0.jsonld", url: "https://doi.org/10.5063/schema/codemeta-2.0" }, "3.0": { path: "./data/contexts/codemeta-3.0.jsonld", url: "https://w3id.org/codemeta/3.0" } } const SPDX_PREFIX = 'https://spdx.org/licenses/'; const loadContextData = async () => { const [contextLocal, contextV2, contextV3] = await Promise.all([ - fetch(LOCAL_CONTEXT_PATH).then(response => response.json()), + fetch(INTERNAL_CONTEXT_PATH).then(response => response.json()), fetch(CODEMETA_CONTEXTS["2.0"].path).then(response => response.json()), fetch(CODEMETA_CONTEXTS["3.0"].path).then(response => response.json()) ]); return { - [LOCAL_CONTEXT_URL]: contextLocal, + [INTERNAL_CONTEXT_URL]: contextLocal, [CODEMETA_CONTEXTS["2.0"].url]: contextV2, [CODEMETA_CONTEXTS["3.0"].url]: contextV3 } } const getJsonldCustomLoader = contexts => { return url => { const xhrDocumentLoader = jsonld.documentLoaders.xhr(); if (url in contexts) { return { contextUrl: null, document: contexts[url], documentUrl: url }; } return xhrDocumentLoader(url); } }; const initJsonldLoader = contexts => { jsonld.documentLoader = getJsonldCustomLoader(contexts); }; function emptyToUndefined(v) { if (v == null || v == "") return undefined; else return v; } function getIfSet(query) { return emptyToUndefined(document.querySelector(query).value); } function setIfDefined(query, value) { if (value !== undefined) { document.querySelector(query).value = value; } } function getLicenses() { let selectedLicenses = Array.from(document.getElementById("selected-licenses").children); return selectedLicenses.map(licenseDiv => SPDX_PREFIX + licenseDiv.children[0].innerText); } // Names of codemeta properties with a matching HTML field name const directCodemetaFields = [ 'codeRepository', 'contIntegration', 'dateCreated', 'datePublished', 'dateModified', 'downloadUrl', 'issueTracker', 'name', 'version', 'identifier', 'description', 'applicationCategory', 'releaseNotes', 'funding', 'developmentStatus', 'isSourceCodeOf', 'isPartOf', 'referencePublication' ]; const splittedCodemetaFields = [ ['keywords', ','], ['programmingLanguage', ','], ['runtimePlatform', ','], ['operatingSystem', ','], ['softwareRequirements', '\n'], ['relatedLink', '\n'], ] // Names of codemeta properties with a matching HTML field name, // in a Person object const directPersonCodemetaFields = [ 'givenName', 'familyName', 'email', 'affiliation', ]; const directRoleCodemetaFields = [ 'roleName', 'startDate', 'endDate', ]; const directReviewCodemetaFields = [ 'reviewAspect', 'reviewBody' ]; const crossCodemetaFields = { "contIntegration": ["contIntegration", "continuousIntegration"], // "embargoDate": ["embargoDate", "embargoEndDate"], Not present in the form yet TODO ? }; function generateShortOrg(fieldName) { var affiliation = getIfSet(fieldName); if (affiliation !== undefined) { if (isUrl(affiliation)) { return { "@type": "Organization", "@id": affiliation, }; } else { return { "@type": "Organization", "name": affiliation, }; } } else { return undefined; } } function generatePerson(idPrefix) { var doc = { "@type": "Person", } var id = getIfSet(`#${idPrefix}_id`); if (id !== undefined) { doc["@id"] = id; } directPersonCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet(`#${idPrefix}_${item}`); }); doc["affiliation"] = generateShortOrg(`#${idPrefix}_affiliation`); return doc; } function generateRole(id) { const doc = { "@type": "Role" }; directRoleCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet(`#${id} .${item}`); }); return doc; } function generateRoles(idPrefix, person) { const roles = []; const roleNodes = document.querySelectorAll(`ul[id^=${idPrefix}_role_`); roleNodes.forEach(roleNode => { const role = generateRole(roleNode.id); role["schema:author"] = person; // Prefix with "schema:" to prevent it from expanding into a list roles.push(role); }); return roles; } function generatePersons(prefix) { var persons = []; var nbPersons = getNbPersons(prefix); for (let personId = 1; personId <= nbPersons; personId++) { const idPrefix = `${prefix}_${personId}`; const person = generatePerson(idPrefix); persons.push(person); const roles = generateRoles(idPrefix, person); if (roles.length > 0) { persons = persons.concat(roles); } } return persons; } function generateReview() { const doc = { "@type": "Review" }; directReviewCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet(`#${item}`); }); return doc; } async function buildExpandedJson() { var doc = { - "@context": LOCAL_CONTEXT_URL, + "@context": INTERNAL_CONTEXT_URL, "@type": "SoftwareSourceCode", }; let licenses = getLicenses(); if (licenses.length > 0) { doc["license"] = licenses; } // Generate most fields directCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet('#' + item) }); doc["funder"] = generateShortOrg('#funder', doc["affiliation"]); const review = generateReview(); if (review["reviewAspect"] || review["reviewBody"]) { doc["review"] = generateReview(); } // Generate simple fields parsed simply by splitting splittedCodemetaFields.forEach(function (item, index) { const id = item[0]; const separator = item[1]; const value = getIfSet('#' + id); if (value !== undefined) { doc[id] = value.split(separator).map(trimSpaces); } }); // Generate dynamic fields var authors = generatePersons('author'); if (authors.length > 0) { doc["author"] = authors; } var contributors = generatePersons('contributor'); if (contributors.length > 0) { doc["contributor"] = contributors; } for (const [key, items] of Object.entries(crossCodemetaFields)) { items.forEach(item => { doc[item] = doc[key]; }); } return await jsonld.expand(doc); } // v2.0 is still default version for generation, for now async function generateCodemeta(codemetaVersion = "2.0") { var inputForm = document.querySelector('#inputForm'); var codemetaText, errorHTML; + let compacted; if (inputForm.checkValidity()) { const expanded = await buildExpandedJson(); - const compacted = await jsonld.compact(expanded, CODEMETA_CONTEXTS[codemetaVersion].url); + compacted = await jsonld.compact(expanded, CODEMETA_CONTEXTS[codemetaVersion].url); codemetaText = JSON.stringify(compacted, null, 4); errorHTML = ""; } else { codemetaText = ""; errorHTML = "invalid input (see error above)"; inputForm.reportValidity(); } document.querySelector('#codemetaText').innerText = codemetaText; setError(errorHTML); // Run validator on the exported value, for extra validation. // If this finds a validation, it means there is a bug in our code (either // generation or validation), and the generation MUST NOT generate an // invalid codemeta file, regardless of user input. - if (codemetaText && !validateDocument(JSON.parse(codemetaText))) { + const isValid = codemetaText && (await parseAndValidateCodemeta(false)); + if (!isValid) { alert('Bug detected! The data you wrote is correct; but for some reason, it seems we generated an invalid codemeta.json. Please report this bug at https://github.com/codemeta/codemeta-generator/issues/new and copy-paste the generated codemeta.json file. Thanks!'); } if (codemetaText) { // For restoring the form state on page reload sessionStorage.setItem('codemetaText', codemetaText); } } // Imports a single field (name or @id) from an Organization. function importShortOrg(fieldName, doc) { if (doc !== undefined) { // Use @id if set, else use name setIfDefined(fieldName, doc["name"]); setIfDefined(fieldName, getDocumentId(doc)); } } function importReview(doc) { if (doc !== undefined) { directReviewCodemetaFields.forEach(item => { setIfDefined('#' + item, doc[item]); }); } } function authorsEqual(author1, author2) { // TODO should test more properties for equality? return author1.givenName === author2.givenName && author1.familyName === author2.familyName && author1.email === author2.email; } function getSingleAuthorsFromRoles(docs) { return docs.filter(doc => getDocumentType(doc) === "Role") .map(doc => doc["schema:author"]) .reduce((authorSet, currentAuthor) => { const foundAuthor = authorSet.find(author => authorsEqual(author, currentAuthor)); if (!foundAuthor) { return authorSet.concat([currentAuthor]); } else { return authorSet; } }, []); } function importRoles(personPrefix, roles) { roles.forEach(role => { const roleId = addRole(`${personPrefix}`); directRoleCodemetaFields.forEach(item => { setIfDefined(`#${personPrefix}_${item}_${roleId}`, role[item]); }); }); } function importPersons(prefix, legend, docs) { if (docs === undefined) { return; } const authors = docs.filter(doc => getDocumentType(doc) === "Person"); const authorsFromRoles = getSingleAuthorsFromRoles(docs); const allAuthorDocs = authors.concat(authorsFromRoles) .reduce((authors, currentAuthor) => { if (!authors.find(author => authorsEqual(author, currentAuthor))) { authors.push(currentAuthor); } return authors; }, []); allAuthorDocs.forEach(function (doc, index) { var personId = addPerson(prefix, legend); setIfDefined(`#${prefix}_${personId}_id`, getDocumentId(doc)); directPersonCodemetaFields.forEach(function (item, index) { setIfDefined(`#${prefix}_${personId}_${item}`, doc[item]); }); importShortOrg(`#${prefix}_${personId}_affiliation`, doc['affiliation']); const roles = docs.filter(currentDoc => getDocumentType(currentDoc) === "Role") .filter(currentDoc => authorsEqual(currentDoc["schema:author"], doc)); importRoles(`${prefix}_${personId}`, roles); }); } async function importCodemeta() { var inputForm = document.querySelector('#inputForm'); var doc = await parseAndValidateCodemeta(false); resetForm(); if (doc['license'] !== undefined) { if (typeof doc['license'] === 'string') { doc['license'] = [doc['license']]; } doc['license'].forEach(l => { if (l.indexOf(SPDX_PREFIX) !== 0) { return; } let licenseId = l.substring(SPDX_PREFIX.length); insertLicenseElement(licenseId); }); } directCodemetaFields.forEach(function (item, index) { setIfDefined('#' + item, doc[item]); }); importShortOrg('#funder', doc["funder"]); importReview(doc["review"]); // Import simple fields by joining on their separator splittedCodemetaFields.forEach(function (item, index) { const id = item[0]; const separator = item[1]; let value = doc[id]; if (value !== undefined) { if (Array.isArray(value)) { value = value.join(separator); } setIfDefined('#' + id, value); } }); for (const [key, items] of Object.entries(crossCodemetaFields)) { let value = ""; items.forEach(item => { value = doc[item] || value; }); setIfDefined(`#${key}`, value); } importPersons('author', 'Author', doc['author']) importPersons('contributor', 'Contributor', doc['contributor']) } function loadStateFromStorage() { var codemetaText = sessionStorage.getItem('codemetaText') if (codemetaText) { document.querySelector('#codemetaText').innerText = codemetaText; importCodemeta(); } } diff --git a/js/validation/index.js b/js/validation/index.js index 4035f8c..fdfad61 100644 --- a/js/validation/index.js +++ b/js/validation/index.js @@ -1,96 +1,254 @@ /** * Copyright (C) 2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ /* * Reads a Codemeta file and shows human-friendly errors on it. * * This validator intentionaly does not use a schema, in order to show errors * that are easy to understand for users with no understanding of JSON-LD. */ +const softwareFieldValidators = { + "@id": validateUrl, + "id": validateUrl, -function validateDocument(doc) { - if (!Array.isArray(doc) && typeof doc != 'object') { + "codeRepository": validateUrls, + "programmingLanguage": noValidation, + "runtimePlatform": validateTexts, + "targetProduct": noValidation, // TODO: validate SoftwareApplication + "applicationCategory": validateTextsOrUrls, + "applicationSubCategory": validateTextsOrUrls, + "downloadUrl": validateUrls, + "fileSize": validateText, // TODO + "installUrl": validateUrls, + "memoryRequirements": validateTextsOrUrls, + "operatingSystem": validateTexts, + "permissions": validateTexts, + "processorRequirements": validateTexts, + "releaseNotes": validateTextsOrUrls, + "softwareHelp": validateCreativeWorks, + "softwareRequirements": noValidation, // TODO: validate SoftwareSourceCode + "softwareVersion": validateText, // TODO? + "storageRequirements": validateTextsOrUrls, + "supportingData": noValidation, // TODO + "author": validateActors, + "citation": validateCreativeWorks, // TODO + "contributor": validateActors, + "copyrightHolder": validateActors, + "copyrightYear": validateNumbers, + "creator": validateActors, // TODO: still in codemeta 2.0, but removed from master + "dateCreated": validateDate, + "dateModified": validateDate, + "datePublished": validateDate, + "editor": validatePersons, + "encoding": noValidation, + "fileFormat": validateTextsOrUrls, + "funder": validateActors, // TODO: may be other types + "keywords": validateTexts, + "license": validateCreativeWorks, + "producer": validateActors, + "provider": validateActors, + "publisher": validateActors, + "sponsor": validateActors, + "version": validateNumberOrText, + "isAccessibleForFree": validateBoolean, + "isSourceCodeOf": validateTextsOrUrls, + "isPartOf": validateCreativeWorks, + "hasPart": validateCreativeWorks, + "position": noValidation, + "identifier": noValidation, // TODO + "description": validateText, + "name": validateText, + "sameAs": validateUrls, + "url": validateUrls, + "relatedLink": validateUrls, + "review": validateReview, + + "softwareSuggestions": noValidation, // TODO: validate SoftwareSourceCode + "maintainer": validateActors, + "contIntegration": validateUrls, + "continuousIntegration": validateUrls, + "buildInstructions": validateUrls, + "developmentStatus": validateText, // TODO: use only repostatus strings? + "embargoDate": validateDate, + "embargoEndDate": validateDate, + "funding": validateText, + "issueTracker": validateUrls, + "referencePublication": noValidation, // TODO? + "readme": validateUrls, +}; + +const creativeWorkFieldValidators = { + "@id": validateUrl, + "id": validateUrl, + + "author": validateActors, + "citation": validateCreativeWorks, // TODO + "contributor": validateActors, + "copyrightHolder": validateActors, + "copyrightYear": validateNumbers, + "creator": validateActors, // TODO: still in codemeta 2.0, but removed from master + "dateCreated": validateDate, + "dateModified": validateDate, + "datePublished": validateDate, + "editor": validatePersons, + "encoding": noValidation, + "funder": validateActors, // TODO: may be other types + "keywords": validateTexts, + "license": validateCreativeWorks, + "producer": validateActors, + "provider": validateActors, + "publisher": validateActors, + "sponsor": validateActors, + "version": validateNumberOrText, + "isAccessibleForFree": validateBoolean, + "isPartOf": validateCreativeWorks, + "hasPart": validateCreativeWorks, + "position": noValidation, + "identifier": noValidation, // TODO + "description": validateText, + "name": validateText, + "sameAs": validateUrls, + "url": validateUrls, +}; + +const roleFieldValidators = { + "roleName": validateText, + "startDate": validateDate, + "endDate": validateDate, + + "schema:author": validateActor +}; + +const personFieldValidators = { + "@id": validateUrl, + "id": validateUrl, + + "givenName": validateText, + "familyName": validateText, + "email": validateText, + "affiliation": validateOrganizations, + "identifier": validateUrls, + "name": validateText, // TODO: this is technically valid, but should be allowed here? + "url": validateUrls, +}; + +const organizationFieldValidators = { + "@id": validateUrl, + "id": validateUrl, + + "email": validateText, + "identifier": validateUrls, + "name": validateText, + "address": validateText, + "sponsor": validateActors, + "funder": validateActors, // TODO: may be other types + "isPartOf": validateOrganizations, + "url": validateUrls, + + // TODO: add more? +}; + +const reviewFieldValidators = { + "reviewAspect": validateText, + "reviewBody": validateText, +} + +function switchCodemetaContext(codemetaJSON, contextUrl) { + const previousCodemetaContext = codemetaJSON["@context"]; + codemetaJSON["@context"] = contextUrl; + return previousCodemetaContext; +} + +async function validateTerms(codemetaJSON) { + try { + await jsonld.expand(codemetaJSON, { safe: true }); + } catch (validationError) { + if (validationError.details.event.code === "invalid property") { + setError(`Unknown field "${validationError.details.event.details.property}".`); + return false; + } + } + return true; +} + +function validateCodemetaJSON(codemetaJSON) { + if (!Array.isArray(codemetaJSON) && typeof codemetaJSON != 'object') { setError("Document must be an object (starting and ending with { and }), not ${typeof doc}.") return false; } // TODO: validate id/@id // TODO: check there is either type or @type but not both - var type = getDocumentType(doc); + var type = getDocumentType(codemetaJSON); if (type === undefined) { setError("Missing type (must be SoftwareSourceCode or SoftwareApplication).") return false; } else if (!isCompactTypeEqual(type, "SoftwareSourceCode") && !isCompactTypeEqual(type, "SoftwareApplication")) { // Check this before other fields, as a wrong type error is more // understandable than "invalid field". setError(`Wrong document type: must be "SoftwareSourceCode"/"SoftwareApplication", not ${JSON.stringify(type)}`) return false; } - else { - return Object.entries(doc).every((entry) => { - var fieldName = entry[0]; - var subdoc = entry[1]; - if (fieldName == "@context") { - // Was checked before - return true; - } - else if (fieldName == "type" || fieldName == "@type") { - // Was checked before - return true; - } - else if (isFieldFromOtherVersionToIgnore(fieldName)) { - // Do not check fields from other versions FIXME - return true; - } - else { - var validator = softwareFieldValidators[fieldName]; - if (validator === undefined) { - // TODO: find if it's a field that belongs to another type, - // and suggest that to the user - setError(`Unknown field "${fieldName}".`) - return false; - } - else { - return validator(fieldName, subdoc); - } + return true; +} + +function validateDocument(doc) { + return Object.entries(doc) + .filter(([fieldName]) => !isKeyword(fieldName)) + .every(([fieldName, subdoc]) => { + const compactedFieldName = getCompactType(fieldName); + var validator = softwareFieldValidators[compactedFieldName]; + if (validator === undefined) { + // TODO: find if it's a field that belongs to another type, + // and suggest that to the user + setError(`Unknown field "${compactedFieldName}".`) + return false; + } else { + return validator(compactedFieldName, subdoc); } }); - } } - async function parseAndValidateCodemeta(showPopup) { var codemetaText = document.querySelector('#codemetaText').innerText; - let parsed, doc; + let parsed; try { parsed = JSON.parse(codemetaText); } catch (e) { setError(`Could not read codemeta document because it is not valid JSON (${e}). Check for missing or extra quote, colon, or bracket characters.`); return; } setError(""); - var isValid = validateDocument(parsed); + let isJSONValid = validateCodemetaJSON(parsed); + + const previousCodemetaContext = switchCodemetaContext(parsed, INTERNAL_CONTEXT_URL); + + let areTermsValid = await validateTerms(parsed); + + const expanded = await jsonld.expand(parsed); + const doc = await jsonld.compact(expanded, INTERNAL_CONTEXT_URL); + + switchCodemetaContext(parsed, previousCodemetaContext) + + let isDocumentValid = validateDocument(doc); if (showPopup) { - if (isValid) { + if (isJSONValid && areTermsValid && isDocumentValid) { alert('Document is valid!') } else { alert('Document is invalid.'); } } - parsed["@context"] = LOCAL_CONTEXT_URL; - const expanded = await jsonld.expand(parsed); - doc = await jsonld.compact(expanded, LOCAL_CONTEXT_URL); return doc; } diff --git a/js/validation/primitives.js b/js/validation/primitives.js index 2dc1cd0..6488d14 100644 --- a/js/validation/primitives.js +++ b/js/validation/primitives.js @@ -1,146 +1,150 @@ /** * Copyright (C) 2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ /* * Validators for native schema.org data types. */ +function noValidation(fieldName, doc) { + return true; +} + // Validates an URL or an array of URLs function validateUrls(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'string') { if (inList) { setError(`"${fieldName}" must be a list of URLs (or a single URL), but it contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be an URL (or a list of URLs), not: ${JSON.stringify(subdoc)}`); } return false; } else { return validateUrl(fieldName, subdoc); } }); } // Validates a single URL function validateUrl(fieldName, doc) { if (!isUrl(doc)) { setError(`Invalid URL in field "${fieldName}": ${JSON.stringify(doc)}`) return false; } else { return true; } } // Validates a Text/URL or an array of Texts/URLs function validateTextsOrUrls(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'string') { if (inList) { setError(`"${fieldName}" must be a list of texts/URLs (or a single text/URL), but it contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be a text/URL (or a list of texts/URLs), not: ${JSON.stringify(subdoc)}`); } return false; } else { return true; } }); } // Validates a Text or an array of Texts function validateTexts(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'string') { if (inList) { setError(`"${fieldName}" must be a list of texts (or a single text), but it contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be a text (or a list of texts), not: ${JSON.stringify(subdoc)}`); } return false; } else { return true; } }); } // Validates a single Text function validateText(fieldName, doc) { if (typeof doc != 'string') { setError(`"${fieldName}" must be text, not ${JSON.stringify(doc)}`); return false; } else { return true; } } // Validates a Number or list of Number function validateNumbers(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'number') { if (inList) { setError(`"${fieldName}" must be an array of numbers (or a single number), but contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be a number or an array of numbers, not: ${JSON.stringify(subdoc)}`); } return false; } else { return true; } }); } // Validates a single Text or Number function validateNumberOrText(fieldName, doc) { if (typeof doc == 'string') { return true; } else if (typeof doc == 'number') { return true; } else { setError(`"${fieldName}" must be text or a number, not ${JSON.stringify(doc)}`); return false; } } // Validates a single Boolean function validateBoolean(fieldName, doc) { if (typeof doc != 'boolean') { setError(`"${fieldName}" must be a boolean (ie. "true" or "false"), not ${JSON.stringify(subdoc)}`); return false; } else { return true; } } // Validates a single Date function validateDate(fieldName, doc) { let re = /^\d{4}-\d{2}-\d{2}$/; if (typeof doc != 'string') { setError(`"${fieldName}" must be a date, not ${JSON.stringify(doc)}`); return false; } else if (!doc.match(re)) { setError(`"${fieldName}" must be a date in the format YYYY-MM-DD, not ${JSON.stringify(doc)}`); return false; } else { return true; } } diff --git a/js/validation/things.js b/js/validation/things.js index c790bb0..542b5bb 100644 --- a/js/validation/things.js +++ b/js/validation/things.js @@ -1,344 +1,158 @@ /** * Copyright (C) 2020-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ /* * Validators for codemeta objects derived from http://schema.org/Thing. */ -function getDocumentType(doc) { - // TODO: check there is at most one. - // FIXME: is the last variant allowed? - return doc["type"] || doc["@type"] || doc["codemeta:type"] -} - -function getDocumentId(doc) { - return doc["id"] || doc["@id"]; -} - -function isCompactTypeEqual(type, compactedType) { - // FIXME: are all variants allowed? - return (type == `${compactedType}` - || type == `schema:${compactedType}` - || type == `codemeta:${compactedType}` - || type == `http://schema.org/${compactedType}` - ); -} - -function isFieldFromOtherVersionToIgnore(fieldName) { - return ["codemeta:contIntegration", "codemeta:continuousIntegration", "codemeta:isSourceCodeOf", - "schema:review", "schema:reviewAspect", "schema:reviewBody"].includes(fieldName); -} - -function noValidation(fieldName, doc) { - return true; -} - - // Validates subtypes of Thing, or URIs // // typeFieldValidators is a map: {type => {fieldName => fieldValidator}} function validateThingOrId(parentFieldName, typeFieldValidators, doc) { var acceptedTypesString = Object.keys(typeFieldValidators).join('/'); if (typeof doc == 'string') { if (!isUrl(doc)) { setError(`"${parentFieldName}" must be an URL or a ${acceptedTypesString} object, not: ${JSON.stringify(doc)}`); return false; } else { return true; } } else if (!Array.isArray(doc) && typeof doc == 'object') { return validateThing(parentFieldName, typeFieldValidators, doc); } else { setError(`"${parentFieldName}" must be a ${acceptedTypesString} object or URI, not ${JSON.stringify(doc)}`); return false; } } // Validates subtypes of Thing // // typeFieldValidators is a map: {type => {fieldName => fieldValidator}} function validateThing(parentFieldName, typeFieldValidators, doc) { // TODO: check there is either id or @id but not both // TODO: check there is either type or @type but not both var acceptedTypesString = Object.keys(typeFieldValidators).join('/'); var documentType = getDocumentType(doc); var id = getDocumentId(doc); if (id !== undefined && !isUrl(id)) { setError(`"${fieldName}" has an invalid URI as id: ${JSON.stringify(id)}"`); return false; } if (documentType === undefined) { if (id === undefined) { setError(`"${parentFieldName}" must be a (list of) ${acceptedTypesString} object(s) or an URI, but is missing a type/@type.`); return false; } else { // FIXME: we have an @id but no @type, what should we do? return true; } } for (expectedType in typeFieldValidators) { if (isCompactTypeEqual(documentType, expectedType)) { var fieldValidators = typeFieldValidators[expectedType]; - return Object.entries(doc).every((entry) => { - var fieldName = entry[0]; - var subdoc = entry[1]; - if (fieldName == "type" || fieldName == "@type") { - // Was checked before - return true; - } - else if (isFieldFromOtherVersionToIgnore(fieldName)) { - // Do not check fields from other versions FIXME - return true; - } - else { - var validator = fieldValidators[fieldName]; + + return Object.entries(doc) + .filter(([fieldName]) => !isKeyword(fieldName)) + .every(([fieldName, subdoc]) => { + const compactedFieldName = getCompactType(fieldName); + var validator = fieldValidators[compactedFieldName]; if (validator === undefined) { // TODO: find if it's a field that belongs to another type, // and suggest that to the user - setError(`Unknown field "${fieldName}" in "${parentFieldName}".`) + setError(`Unknown field "${compactedFieldName}".`) return false; + } else { + return validator(compactedFieldName, subdoc); } - else { - return validator(fieldName, subdoc); - } - } - }); + }); } } setError(`"${parentFieldName}" type must be a (list of) ${acceptedTypesString} object(s), not ${JSON.stringify(documentType)}`); return false; } // Helper function to validate a field is either X or a list of X. function validateListOrSingle(fieldName, doc, validator) { if (Array.isArray(doc)) { return doc.every((subdoc) => validator(subdoc, true)); } else { return validator(doc, false); } } // Validates a CreativeWork or an array of CreativeWork function validateCreativeWorks(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validateCreativeWork(fieldName, subdoc); }); } // Validates a single CreativeWork function validateCreativeWork(fieldName, doc) { return validateThingOrId(fieldName, { "CreativeWork": creativeWorkFieldValidators, "SoftwareSourceCode": softwareFieldValidators, "SoftwareApplication": softwareFieldValidators, }, doc); } // Validates a Person, Organization or an array of these function validateActors(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validateActor(fieldName, subdoc); }); } // Validates a Person or an array of Person function validatePersons(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validatePerson(fieldName, subdoc); }); } // Validates an Organization or an array of Organization function validateOrganizations(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validateOrganization(fieldName, subdoc); }); } // Validates a single Person or Organization function validateActor(fieldName, doc) { return validateThingOrId(fieldName, { "Role": roleFieldValidators, "Person": personFieldValidators, "Organization": organizationFieldValidators, }, doc); } // Validates a single Person object function validatePerson(fieldName, doc) { return validateThingOrId(fieldName, {"Person": personFieldValidators}, doc); } // Validates a single Organization object function validateOrganization(fieldName, doc) { return validateThingOrId(fieldName, {"Organization": organizationFieldValidators}, doc); } function validateReview(fieldName, doc) { return validateThingOrId(fieldName, {"Review": reviewFieldValidators}, doc); } - - -var softwareFieldValidators = { - "@id": validateUrl, - "id": validateUrl, - - "codeRepository": validateUrls, - "programmingLanguage": noValidation, - "runtimePlatform": validateTexts, - "targetProduct": noValidation, // TODO: validate SoftwareApplication - "applicationCategory": validateTextsOrUrls, - "applicationSubCategory": validateTextsOrUrls, - "downloadUrl": validateUrls, - "fileSize": validateText, // TODO - "installUrl": validateUrls, - "memoryRequirements": validateTextsOrUrls, - "operatingSystem": validateTexts, - "permissions": validateTexts, - "processorRequirements": validateTexts, - "releaseNotes": validateTextsOrUrls, - "softwareHelp": validateCreativeWorks, - "softwareRequirements": noValidation, // TODO: validate SoftwareSourceCode - "softwareVersion": validateText, // TODO? - "storageRequirements": validateTextsOrUrls, - "supportingData": noValidation, // TODO - "author": validateActors, - "citation": validateCreativeWorks, // TODO - "contributor": validateActors, - "copyrightHolder": validateActors, - "copyrightYear": validateNumbers, - "creator": validateActors, // TODO: still in codemeta 2.0, but removed from master - "dateCreated": validateDate, - "dateModified": validateDate, - "datePublished": validateDate, - "editor": validatePersons, - "encoding": noValidation, - "fileFormat": validateTextsOrUrls, - "funder": validateActors, // TODO: may be other types - "keywords": validateTexts, - "license": validateCreativeWorks, - "producer": validateActors, - "provider": validateActors, - "publisher": validateActors, - "sponsor": validateActors, - "version": validateNumberOrText, - "isAccessibleForFree": validateBoolean, - "isSourceCodeOf": validateTextsOrUrls, - "isPartOf": validateCreativeWorks, - "hasPart": validateCreativeWorks, - "position": noValidation, - "identifier": noValidation, // TODO - "description": validateText, - "name": validateText, - "sameAs": validateUrls, - "url": validateUrls, - "relatedLink": validateUrls, - "review": validateReview, - - "softwareSuggestions": noValidation, // TODO: validate SoftwareSourceCode - "maintainer": validateActors, - "contIntegration": validateUrls, - "continuousIntegration": validateUrls, - "buildInstructions": validateUrls, - "developmentStatus": validateText, // TODO: use only repostatus strings? - "embargoDate": validateDate, - "embargoEndDate": validateDate, - "funding": validateText, - "issueTracker": validateUrls, - "referencePublication": noValidation, // TODO? - "readme": validateUrls, -}; - -var creativeWorkFieldValidators = { - "@id": validateUrl, - "id": validateUrl, - - "author": validateActors, - "citation": validateCreativeWorks, // TODO - "contributor": validateActors, - "copyrightHolder": validateActors, - "copyrightYear": validateNumbers, - "creator": validateActors, // TODO: still in codemeta 2.0, but removed from master - "dateCreated": validateDate, - "dateModified": validateDate, - "datePublished": validateDate, - "editor": validatePersons, - "encoding": noValidation, - "funder": validateActors, // TODO: may be other types - "keywords": validateTexts, - "license": validateCreativeWorks, - "producer": validateActors, - "provider": validateActors, - "publisher": validateActors, - "sponsor": validateActors, - "version": validateNumberOrText, - "isAccessibleForFree": validateBoolean, - "isPartOf": validateCreativeWorks, - "hasPart": validateCreativeWorks, - "position": noValidation, - "identifier": noValidation, // TODO - "description": validateText, - "name": validateText, - "sameAs": validateUrls, - "url": validateUrls, -}; - -var roleFieldValidators = { - "roleName": validateText, - "startDate": validateDate, - "endDate": validateDate, - - "schema:author": validateActor -}; - -var personFieldValidators = { - "@id": validateUrl, - "id": validateUrl, - - "givenName": validateText, - "familyName": validateText, - "email": validateText, - "affiliation": validateOrganizations, - "identifier": validateUrls, - "name": validateText, // TODO: this is technically valid, but should be allowed here? - "url": validateUrls, -}; - - -var organizationFieldValidators = { - "@id": validateUrl, - "id": validateUrl, - - "email": validateText, - "identifier": validateUrls, - "name": validateText, - "address": validateText, - "sponsor": validateActors, - "funder": validateActors, // TODO: may be other types - "isPartOf": validateOrganizations, - "url": validateUrls, - - // TODO: add more? -}; - -const reviewFieldValidators = { - "reviewAspect": validateText, - "reviewBody": validateText, -} diff --git a/js/validation/utils.js b/js/validation/utils.js new file mode 100644 index 0000000..4f91c86 --- /dev/null +++ b/js/validation/utils.js @@ -0,0 +1,35 @@ +/** + * Copyright (C) 2020-2021 The Software Heritage developers + * See the AUTHORS file at the top-level directory of this distribution + * License: GNU Affero General Public License version 3, or any later version + * See top-level LICENSE file for more information + */ + +function getDocumentType(doc) { + // TODO: check there is at most one. + // FIXME: is the last variant allowed? + return doc["type"] || doc["@type"] || doc["codemeta:type"] +} + +function getDocumentId(doc) { + return doc["id"] || doc["@id"]; +} + +function getCompactType(type) { + return type + .replace("schema:", "") + .replace("codemeta:", ""); +} + +function isCompactTypeEqual(type, compactedType) { + // FIXME: are all variants allowed? + return (type == `${compactedType}` + || type == `schema:${compactedType}` + || type == `codemeta:${compactedType}` + || type == `http://schema.org/${compactedType}` + ); +} + +function isKeyword(term) { + return ["@context", "type"].includes(term); +}