From b13eecfcbdb3b535cac2cdfaa59fd0a3929736cb Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Wed, 18 Nov 2020 15:41:17 +0100 Subject: [PATCH] feat(api-v2): Control JSON-LD nesting via an HTTP header (DSP-1084) (#1758) --- docs/03-apis/api-v2/introduction.md | 12 + test_data/metadataE2EV2/metadata-flat.jsonld | 389 +++++++++++++++++ test_data/metadataE2EV2/metadata.ttl | 394 ++++++++++++++++++ .../org/knora/webapi/OntologySchema.scala | 28 ++ .../http/handler/KnoraExceptionHandler.scala | 4 +- .../webapi/messages/util/rdf/JsonLDUtil.scala | 340 +++++++++++---- .../messages/util/rdf/RdfFormatUtil.scala | 38 +- .../v2/responder/KnoraRequestV2.scala | 6 +- .../v2/responder/KnoraResponseV2.scala | 12 +- .../ontologymessages/OntologyMessagesV2.scala | 4 +- .../resourcemessages/ResourceMessagesV2.scala | 2 +- .../knora/webapi/routing/RouteUtilV2.scala | 36 +- .../e2e/v2/MetadataRouteV2E2ESpec.scala | 129 ++---- .../e2e/v2/ResourcesRouteV2E2ESpec.scala | 2 +- .../messages/util/rdf/JsonLDUtilSpec.scala | 83 +++- .../util/rdf/KnoraResponseV2Spec.scala | 124 +++++- .../messages/util/rdf/RdfFormatUtilSpec.scala | 2 +- .../messages/util/rdf/jenaimpl/BUILD.bazel | 4 +- .../messages/util/rdf/rdf4jimpl/BUILD.bazel | 4 +- 19 files changed, 1403 insertions(+), 210 deletions(-) create mode 100644 test_data/metadataE2EV2/metadata-flat.jsonld create mode 100644 test_data/metadataE2EV2/metadata.ttl diff --git a/docs/03-apis/api-v2/introduction.md b/docs/03-apis/api-v2/introduction.md index 6102fcb3d9..87a3b72516 100644 --- a/docs/03-apis/api-v2/introduction.md +++ b/docs/03-apis/api-v2/introduction.md @@ -74,6 +74,18 @@ set of RDF triples, an equivalent JSON-LD response can explicitly provide data in a hierarchical structure, with objects nested inside other objects. +### Hierarchical vs. Flat JSON-LD + +The client can choose between hierarchical and flat JSON-LD. In hierarchical +JSON-LD, entities with IRIs are inlined (nested) where they are used. If the +same entity is used in more than one place, it is inlined only once, and other +uses just refer to its IRI. In Knora's flat JSON-LD, all entities with IRIs are located +at the top level of the document (in a `@graph` if there is more than one of them). +This setting does not affect blank nodes, which are always inlined (unlike in standard +flat JSON-LD). Knora ontologies are always returned in the `flat` rendering; other kinds +of responses default to `hierarchical`. To use this setting, submit the HTTP header +`X-Knora-JSON-LD-Rendering` with the value `hierarchical` or `flat`. + ## Knora IRIs Resources and entities are identified by IRIs. The format of these IRIs diff --git a/test_data/metadataE2EV2/metadata-flat.jsonld b/test_data/metadataE2EV2/metadata-flat.jsonld new file mode 100644 index 0000000000..a3c33f00e2 --- /dev/null +++ b/test_data/metadataE2EV2/metadata-flat.jsonld @@ -0,0 +1,389 @@ +{ + "@graph": [ + { + "http://ns.dasch.swiss/repository#hasName": "Prof. test test, Prof. test Harbtestrecht", + "http://ns.dasch.swiss/repository#hasNumber": "0123456789", + "http://ns.dasch.swiss/repository#hasFunder": { + "@id": "http://ns.dasch.swiss/test-funder" + }, + "@type": "http://ns.dasch.swiss/repository#Grant", + "http://ns.dasch.swiss/repository#hasURL": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "http://p3.snf.ch/testproject" + }, + "@id": "http://ns.dasch.swiss/test-grant" + }, + { + "http://ns.dasch.swiss/repository#hasDocumentation": "Work in progress", + "http://ns.dasch.swiss/repository#hasAlternativeTitle": "test", + "http://ns.dasch.swiss/repository#hasAbstract": "Dies ist ein Testprojekt.", + "http://ns.dasch.swiss/repository#hasLanguage": [ + "EN", + "DE", + "FR" + ], + "http://ns.dasch.swiss/repository#hasDateModified": { + "@value": "2020-04-26", + "@type": "http://www.w3.org/2001/XMLSchema#date" + }, + "http://ns.dasch.swiss/repository#hasConditionsOfAccess": "Open Access", + "http://ns.dasch.swiss/repository#hasTypeOfData": [ + "image", + "text" + ], + "http://ns.dasch.swiss/repository#hasTitle": "Testprojekt", + "http://ns.dasch.swiss/repository#isPartOf": { + "@id": "http://ns.dasch.swiss/test-project" + }, + "http://ns.dasch.swiss/repository#hasHowToCite": "Testprojekt (test), 2002, https://test.dasch.swiss", + "http://ns.dasch.swiss/repository#hasLicense": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://creativecommons.org/licenses/by/3.0" + }, + "http://ns.dasch.swiss/repository#hasQualifiedAttribution": [ + { + "http://www.w3.org/ns/prov#agent": { + "@id": "http://ns.dasch.swiss/test-berry" + }, + "http://ns.dasch.swiss/repository#hasRole": "contributor", + "@type": "http://www.w3.org/ns/prov#Attribution" + }, + { + "http://www.w3.org/ns/prov#agent": { + "@id": "http://ns.dasch.swiss/test-hart" + }, + "http://ns.dasch.swiss/repository#hasRole": "contributor", + "@type": "http://www.w3.org/ns/prov#Attribution" + }, + { + "http://www.w3.org/ns/prov#agent": { + "@id": "http://ns.dasch.swiss/test-abraham" + }, + "http://ns.dasch.swiss/repository#hasRole": "editor", + "@type": "http://www.w3.org/ns/prov#Attribution" + }, + { + "http://www.w3.org/ns/prov#agent": { + "@id": "http://ns.dasch.swiss/test-coleman" + }, + "http://ns.dasch.swiss/repository#hasRole": "editor", + "@type": "http://www.w3.org/ns/prov#Attribution" + }, + { + "http://www.w3.org/ns/prov#agent": { + "@id": "http://ns.dasch.swiss/test-jones" + }, + "http://ns.dasch.swiss/repository#hasRole": "editor", + "@type": "http://www.w3.org/ns/prov#Attribution" + } + ], + "http://ns.dasch.swiss/repository#sameAs": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://test.dasch.swiss/" + }, + "http://ns.dasch.swiss/repository#hasDistribution": { + "@type": "https://schema.org/DataDownload", + "https://schema.org/url": "https://test.dasch.swiss" + }, + "http://ns.dasch.swiss/repository#hasStatus": "ongoing", + "@type": "http://ns.dasch.swiss/repository#Dataset", + "http://ns.dasch.swiss/repository#hasDatePublished": { + "@value": "2002-09-24", + "@type": "http://www.w3.org/2001/XMLSchema#date" + }, + "http://ns.dasch.swiss/repository#hasDateCreated": { + "@value": "2001-09-26", + "@type": "http://www.w3.org/2001/XMLSchema#date" + }, + "@id": "http://ns.dasch.swiss/test-dataset" + }, + { + "http://ns.dasch.swiss/repository#hasFamilyName": "Jones", + "http://ns.dasch.swiss/repository#hasGivenName": "Benjamin", + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "4000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "Teststrasse", + "https://schema.org/addressLocality": "Basel" + }, + "http://ns.dasch.swiss/repository#hasJobTitle": "Dr. des.", + "http://ns.dasch.swiss/repository#hasRole": "Editor", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/benjamin.jones@test.ch" + }, + "@type": "http://ns.dasch.swiss/repository#Person", + "http://ns.dasch.swiss/repository#isMemberOf": { + "@id": "http://ns.dasch.swiss/test-dasch" + }, + "@id": "http://ns.dasch.swiss/test-jones" + }, + { + "http://ns.dasch.swiss/repository#hasFamilyName": "Coleman", + "http://ns.dasch.swiss/repository#hasGivenName": "James", + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "4000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "Teststrasse", + "https://schema.org/addressLocality": "Basel" + }, + "http://ns.dasch.swiss/repository#hasJobTitle": "Dr. des.", + "http://ns.dasch.swiss/repository#hasRole": "Contributor", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/james.coleman@dasch.swiss" + }, + "@type": "http://ns.dasch.swiss/repository#Person", + "http://ns.dasch.swiss/repository#isMemberOf": { + "@id": "http://ns.dasch.swiss/test-dasch" + }, + "@id": "http://ns.dasch.swiss/test-coleman" + }, + { + "@id": "http://ns.dasch.swiss/test-plan", + "@type": "http://ns.dasch.swiss/repository#DataManagementPlan", + "http://ns.dasch.swiss/repository#isAvailable": false, + "http://ns.dasch.swiss/repository#hasURL": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://snf.ch" + } + }, + { + "http://ns.dasch.swiss/repository#hasFamilyName": "Berry", + "http://ns.dasch.swiss/repository#hasGivenName": "Lauren", + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "4000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "Teststrasse", + "https://schema.org/addressLocality": "Basel" + }, + "http://ns.dasch.swiss/repository#hasJobTitle": "Dr.", + "http://ns.dasch.swiss/repository#hasRole": "Contributor", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/lauren.berry@unibas.ch" + }, + "@type": "http://ns.dasch.swiss/repository#Person", + "http://ns.dasch.swiss/repository#isMemberOf": { + "@id": "http://ns.dasch.swiss/test-dasch" + }, + "@id": "http://ns.dasch.swiss/test-berry" + }, + { + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "40000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "University of Toronto Street", + "https://schema.org/addressLocality": "Toronto" + }, + "http://ns.dasch.swiss/repository#hasName": "University of Toronto", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/info@universityoftoronto.ca" + }, + "@type": "http://ns.dasch.swiss/repository#Organization", + "http://ns.dasch.swiss/repository#hasURL": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "http://www.utoronto.ca/" + }, + "@id": "http://ns.dasch.swiss/test-funder" + }, + { + "http://ns.dasch.swiss/repository#hasFamilyName": "Hart", + "http://ns.dasch.swiss/repository#hasGivenName": "Leonhard", + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "4000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "Teststrasse", + "https://schema.org/addressLocality": "Basel" + }, + "http://ns.dasch.swiss/repository#hasJobTitle": "Prof.", + "http://ns.dasch.swiss/repository#hasRole": "Editor", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/leonhard.hart@test.ch" + }, + "@type": "http://ns.dasch.swiss/repository#Person", + "http://ns.dasch.swiss/repository#isMemberOf": { + "@id": "http://ns.dasch.swiss/test-dasch" + }, + "@id": "http://ns.dasch.swiss/test-hart" + }, + { + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "4000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "Teststrasse", + "https://schema.org/addressLocality": "Basel" + }, + "http://ns.dasch.swiss/repository#hasName": "TEST", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/info@dasch.swiss" + }, + "@type": "http://ns.dasch.swiss/repository#Organization", + "http://ns.dasch.swiss/repository#hasURL": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://test.swiss" + }, + "@id": "http://ns.dasch.swiss/test-dasch" + }, + { + "http://ns.dasch.swiss/repository#hasContactPoint": { + "@id": "http://ns.dasch.swiss/test-abraham" + }, + "http://ns.dasch.swiss/repository#hasShortcode": "0000", + "http://ns.dasch.swiss/repository#hasName": "Testprojektname (test)", + "http://ns.dasch.swiss/repository#hasSpatialCoverage": [ + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/2017370/russian-federation.html" + } + }, + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/2658434/switzerland.html" + } + }, + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/3175395/italian-republic.html" + } + }, + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/2921044/federal-republic-of-germany.html" + } + }, + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/3017382/republic-of-france.html" + } + }, + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/6269131/england.html" + } + }, + { + "@type": "https://schema.org/Place", + "https://schema.org/url": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Geonames" + }, + "https://schema.org/url": "https://www.geonames.org/6255148/europe.html" + } + } + ], + "http://ns.dasch.swiss/repository#hasPublication": "testpublication", + "http://ns.dasch.swiss/repository#hasFunder": { + "@id": "http://ns.dasch.swiss/test-funder" + }, + "http://ns.dasch.swiss/repository#hasDiscipline": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "SKOS UNESCO Nomenclature" + }, + "https://schema.org/url": "http://skos.um.es/unesco6/11" + }, + "http://ns.dasch.swiss/repository#hasKeywords": [ + "science", + "mathematics", + "history of science", + "history of mathematics" + ], + "http://ns.dasch.swiss/repository#hasEndDate": { + "@value": "2001-01-26", + "@type": "http://www.w3.org/2001/XMLSchema#date" + }, + "http://ns.dasch.swiss/repository#hasGrant": { + "@id": "http://ns.dasch.swiss/test-grant" + }, + "http://ns.dasch.swiss/repository#hasTemporalCoverage": { + "@type": "https://schema.org/URL", + "https://schema.org/propertyID": { + "@type": "https://schema.org/PropertyValue", + "https://schema.org/propertyID": "Chronontology Dainst" + }, + "https://schema.org/url": "http://chronontology.dainst.org/period/Ef9SyESSafJ1" + }, + "http://ns.dasch.swiss/repository#hasDescription": "Dies ist ein Testprojekt...alle Properties wurden verwendet, um diese zu testen", + "@type": "http://ns.dasch.swiss/repository#Project", + "http://ns.dasch.swiss/repository#hasAlternateName": "test", + "http://ns.dasch.swiss/repository#hasDataManagementPlan": { + "@type": "http://ns.dasch.swiss/repository#DataManagementPlan", + "http://ns.dasch.swiss/repository#isAvailable": true, + "http://ns.dasch.swiss/repository#hasURL": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://snf.ch" + } + }, + "http://ns.dasch.swiss/repository#hasStartDate": { + "@value": "2000-07-26", + "@type": "http://www.w3.org/2001/XMLSchema#date" + }, + "http://ns.dasch.swiss/repository#hasURL": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://test.dasch.swiss/" + }, + "@id": "http://ns.dasch.swiss/test-project" + }, + { + "http://ns.dasch.swiss/repository#hasFamilyName": "Abraham", + "http://ns.dasch.swiss/repository#hasGivenName": "Stewart", + "http://ns.dasch.swiss/repository#hasAddress": { + "https://schema.org/postalCode": "4000", + "@type": "https://schema.org/PostalAddress", + "https://schema.org/streetAddress": "Teststrasse", + "https://schema.org/addressLocality": "Basel" + }, + "http://ns.dasch.swiss/repository#hasJobTitle": "Dr.", + "http://ns.dasch.swiss/repository#hasRole": "Editor", + "http://ns.dasch.swiss/repository#hasEmail": { + "@id": "http://ns.dasch.swiss/stewart.abraham@test.ch" + }, + "http://ns.dasch.swiss/repository#sameAs": { + "@type": "https://schema.org/URL", + "https://schema.org/url": "https://orcid.org/0000-0002-1825-0097" + }, + "@type": "http://ns.dasch.swiss/repository#Person", + "http://ns.dasch.swiss/repository#isMemberOf": { + "@id": "http://ns.dasch.swiss/test-dasch" + }, + "@id": "http://ns.dasch.swiss/test-abraham" + } + ] +} diff --git a/test_data/metadataE2EV2/metadata.ttl b/test_data/metadataE2EV2/metadata.ttl new file mode 100644 index 0000000000..9220b9ceb2 --- /dev/null +++ b/test_data/metadataE2EV2/metadata.ttl @@ -0,0 +1,394 @@ + + a ; + + "test" ; + + ; + + [ a ; + + [ a ; + "https://snf.ch" + ] ; + + true + ] ; + + "Dies ist ein Testprojekt...alle Properties wurden verwendet, um diese zu testen" ; + + [ a ; + + [ a ; + + "SKOS UNESCO Nomenclature" + ] ; + "http://skos.um.es/unesco6/11" + ] ; + + "2001-01-26"^^ ; + + ; + + ; + + "mathematics" , "history of science" , "history of mathematics" , "science" ; + + "Testprojektname (test)" ; + + "testpublication" ; + + "0000" ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/6255148/europe.html" + ] + ] ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/3175395/italian-republic.html" + ] + ] ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/3017382/republic-of-france.html" + ] + ] ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/2921044/federal-republic-of-germany.html" + ] + ] ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/6269131/england.html" + ] + ] ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/2658434/switzerland.html" + ] + ] ; + + [ a ; + [ a ; + + [ a ; + + "Geonames" + ] ; + "https://www.geonames.org/2017370/russian-federation.html" + ] + ] ; + + "2000-07-26"^^ ; + + [ a ; + + [ a ; + + "Chronontology Dainst" + ] ; + "http://chronontology.dainst.org/period/Ef9SyESSafJ1" + ] ; + + [ a ; + "https://test.dasch.swiss/" + ] . + + + a ; + + [ a ; + + "Basel" ; + + "4000" ; + + "Teststrasse" + ] ; + + ; + + "Abraham" ; + + "Stewart" ; + + "Dr." ; + + "Editor" ; + + ; + + [ a ; + "https://orcid.org/0000-0002-1825-0097" + ] . + + + a ; + + [ a ; + + "Toronto" ; + + "40000" ; + + "University of Toronto Street" + ] ; + + ; + + "University of Toronto" ; + + [ a ; + "http://www.utoronto.ca/" + ] . + + + a ; + + ; + + "Prof. test test, Prof. test Harbtestrecht" ; + + "0123456789" ; + + [ a ; + "http://p3.snf.ch/testproject" + ] . + + + a ; + + [ a ; + "https://snf.ch" + ] ; + + false . + + + a ; + + [ a ; + + "Basel" ; + + "4000" ; + + "Teststrasse" + ] ; + + ; + + "Coleman" ; + + "James" ; + + "Dr. des." ; + + "Contributor" ; + + . + + + a ; + + [ a ; + + "Basel" ; + + "4000" ; + + "Teststrasse" + ] ; + + ; + + "TEST" ; + + [ a ; + "https://test.swiss" + ] . + + + a ; + + [ a ; + + "Basel" ; + + "4000" ; + + "Teststrasse" + ] ; + + ; + + "Hart" ; + + "Leonhard" ; + + "Prof." ; + + "Editor" ; + + . + + + a ; + + [ a ; + + "Basel" ; + + "4000" ; + + "Teststrasse" + ] ; + + ; + + "Berry" ; + + "Lauren" ; + + "Dr." ; + + "Contributor" ; + + . + + + a ; + + [ a ; + + "Basel" ; + + "4000" ; + + "Teststrasse" + ] ; + + ; + + "Jones" ; + + "Benjamin" ; + + "Dr. des." ; + + "Editor" ; + + . + + + a ; + + "Dies ist ein Testprojekt." ; + + "test" ; + + "Open Access" ; + + "2001-09-26"^^ ; + + "2020-04-26"^^ ; + + "2002-09-24"^^ ; + + [ a ; + "https://test.dasch.swiss" + ] ; + + "Work in progress" ; + + "Testprojekt (test), 2002, https://test.dasch.swiss" ; + + "FR" , "EN" , "DE" ; + + [ a ; + "https://creativecommons.org/licenses/by/3.0" + ] ; + + [ a ; + + "editor" ; + + + ] ; + + [ a ; + + "contributor" ; + + + ] ; + + [ a ; + + "contributor" ; + + + ] ; + + [ a ; + + "editor" ; + + + ] ; + + [ a ; + + "editor" ; + + + ] ; + + "ongoing" ; + + "Testprojekt" ; + + "text" , "image" ; + + ; + + [ a ; + "https://test.dasch.swiss/" + ] . diff --git a/webapi/src/main/scala/org/knora/webapi/OntologySchema.scala b/webapi/src/main/scala/org/knora/webapi/OntologySchema.scala index bf7ea76311..79b5abc5c6 100644 --- a/webapi/src/main/scala/org/knora/webapi/OntologySchema.scala +++ b/webapi/src/main/scala/org/knora/webapi/OntologySchema.scala @@ -72,6 +72,23 @@ case object MarkupAsStandoff extends MarkupRendering */ case object NoMarkup extends MarkupRendering +/** + * A trait representing options that affect the format of JSON-LD responses. + */ +sealed trait JsonLDRendering extends SchemaOption + +/** + * Indicates that flat JSON-LD should be returned, i.e. objects with IRIs should be referenced by IRI + * rather than nested. Blank nodes will still be nested in any case. + */ +case object FlatJsonLD extends JsonLDRendering + +/** + * Indicates that hierarchical JSON-LD should be returned, i.e. objects with IRIs should be nested when + * possible, rather than referenced by IRI. + */ +case object HierarchicalJsonLD extends JsonLDRendering + /** * Utility functions for working with schema options. */ @@ -118,4 +135,15 @@ object SchemaOptions { def renderMarkupAsStandoff(targetSchema: ApiV2Schema, schemaOptions: Set[SchemaOption]): Boolean = { targetSchema == ApiV2Complex && schemaOptions.contains(MarkupAsStandoff) } + + /** + * Determines whether flat JSON-LD should be returned, i.e. objects with IRIs should be referenced by IRI + * rather than nested. + * + * @param schemaOptions the schema options submitted with the request. + * @return `true` if flat JSON-LD should be returned. + */ + def returnFlatJsonLD(schemaOptions: Set[SchemaOption]): Boolean = { + schemaOptions.contains(FlatJsonLD) + } } diff --git a/webapi/src/main/scala/org/knora/webapi/http/handler/KnoraExceptionHandler.scala b/webapi/src/main/scala/org/knora/webapi/http/handler/KnoraExceptionHandler.scala index 74a54a5553..cf68781346 100644 --- a/webapi/src/main/scala/org/knora/webapi/http/handler/KnoraExceptionHandler.scala +++ b/webapi/src/main/scala/org/knora/webapi/http/handler/KnoraExceptionHandler.scala @@ -32,7 +32,7 @@ import spray.json.{JsNumber, JsObject, JsString, JsValue} /** * The Knora exception handler is used by akka-http to convert any exceptions thrown during route processing - * into HttpResponses. It is brought implicitly into scope at the top level [[KnoraLiveService]]. + * into HttpResponses. It is brought implicitly into scope by the application actor. */ object KnoraExceptionHandler extends LazyLogging { @@ -156,7 +156,7 @@ object KnoraExceptionHandler extends LazyLogging { // ... and the HTTP status code. HttpResponse( status = httpStatus, - entity = HttpEntity(ContentType(MediaTypes.`application/json`), jsonLDDocument.toCompactString) + entity = HttpEntity(ContentType(MediaTypes.`application/json`), jsonLDDocument.toCompactString(false)) ) } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/JsonLDUtil.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/JsonLDUtil.scala index aea2091f92..cc11d26dfb 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/JsonLDUtil.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/JsonLDUtil.scala @@ -155,6 +155,64 @@ case class JsonLDObject(value: Map[String, JsonLDValue]) extends JsonLDValue { builder.build } + /** + * Flattens this JSON-LD object by extracting inlined entities with IRIs and replacing them with + * references to their IRI. + * + * @param entitiesToAddToTopLevel inlined entities that have been extracted. + * @param isAtTopLevel `true` if this JSON-LD object is the top level object, or if it is an element + * of a `@graph`. + * @return a flattened copy of this JSON-LD object. + */ + def flattened(entitiesToAddToTopLevel: collection.mutable.Set[JsonLDObject], isAtTopLevel: Boolean): JsonLDObject = { + val thisWithFlattenedContent = JsonLDObject { + // Flatten the object of each predicate. + value.map { + case (pred: String, obj: JsonLDValue) => + // What type of object does this predicate have? + val flatObj: JsonLDValue = obj match { + case jsonLDObject: JsonLDObject => + // A JSON-LD object. Flatten its content. It's not at the top level, so if it has an IRI, + // add it to the top level and refer to it by IRI here. + jsonLDObject.flattened( + entitiesToAddToTopLevel = entitiesToAddToTopLevel, + isAtTopLevel = false + ) + + case jsonLDArray: JsonLDArray => + // An array. Flatten its elements. If the array is the object of @graph, don't + // move its elements to the top level, because they're already at the top level. + jsonLDArray.flattened( + entitiesToAddToTopLevel = entitiesToAddToTopLevel, + isAtTopLevel = pred == JsonLDKeywords.GRAPH + ) + + case _ => + // Something else. Leave it as is. + obj + } + + pred -> flatObj + } + } + + // Is this JSON-LD object already at the top level? + if (isAtTopLevel) { + // Yes. Just return it with flattened content. + thisWithFlattenedContent + } else { + // No. Does it have an IRI? + if (isEntityWithIri) { + // Yes. Add it to the top level, and return a reference to its IRI. + entitiesToAddToTopLevel += thisWithFlattenedContent + JsonLDUtil.iriToJsonLDObject(thisWithFlattenedContent.requireString(JsonLDKeywords.ID)) + } else { + // No, it's a blank node or some other type of data. Just return it with flattened content. + thisWithFlattenedContent + } + } + } + /** * Recursively adds the contents of a JSON-LD entity to an [[RdfModel]]. * @@ -184,10 +242,9 @@ case class JsonLDObject(value: Map[String, JsonLDValue]) extends JsonLDValue { addGraphToModel(model) // Add the IRI predicates and their objects. + val iriPredicates: Set[IRI] = value.keySet -- JsonLDKeywords.allSupported - val predicates = value.keySet -- JsonLDKeywords.allSupported - - for (pred <- predicates) { + for (pred: IRI <- iriPredicates) { val rdfPred: IriNode = nodeFactory.makeIriNode(pred) val obj: JsonLDValue = value(pred) @@ -380,6 +437,13 @@ case class JsonLDObject(value: Map[String, JsonLDValue]) extends JsonLDValue { } } + /** + * Returns `true` if this JSON-LD object represents an RDF entity with an IRI, + * i.e. if it has an `@id` and a `@type`. + */ + def isEntityWithIri: Boolean = { + Set(JsonLDKeywords.ID, JsonLDKeywords.TYPE).subsetOf(value.keySet) + } /** * Returns `true` if this JSON-LD object represents an IRI value. @@ -824,6 +888,38 @@ case class JsonLDArray(value: Seq[JsonLDValue]) extends JsonLDValue { builder.build } + /** + * Flattens this JSON-LD array by extracting inlined entities with IRIs and replacing them with + * references to their IRI. + * + * @param entitiesToAddToTopLevel inlined entities that have been extracted. + * @param isAtTopLevel `true` if this array is the object of `@graph` at the top level of the document. + * @return a flattened copy of this JSON-LD array. + */ + def flattened(entitiesToAddToTopLevel: collection.mutable.Set[JsonLDObject], isAtTopLevel: Boolean = false): JsonLDArray = { + JsonLDArray { + // Flatten the JSON-LD objects that are elements of the array. + value.map { + elem: JsonLDValue => + // What type of element is it? + elem match { + case jsonLDObject: JsonLDObject => + // A JSON-LD object. Flatten its content. If it has an IRI, move it to the top level, + // unless this array is a @graph, meaning that the JSON-LD object is already + // at the top level. + jsonLDObject.flattened( + entitiesToAddToTopLevel = entitiesToAddToTopLevel, + isAtTopLevel = isAtTopLevel + ) + + case _ => + // Something else. Leave it as is. + elem + } + } + } + } + /** * Tries to interpret the elements of this array as JSON-LD objects containing `@language` and `@value`, * and returns the results as a set of [[StringLiteralV2]]. Throws [[BadRequestException]] @@ -855,8 +951,12 @@ case class JsonLDArray(value: Seq[JsonLDValue]) extends JsonLDValue { * * @param body the body of the JSON-LD document. * @param context the context of the JSON-LD document. + * @param isFlat `true` if this JSON-LD document has been constructed as a flat document, i.e. + * without inlining entities that have IRIs. */ -case class JsonLDDocument(body: JsonLDObject, context: JsonLDObject = JsonLDObject(Map.empty[String, JsonLDValue])) { +case class JsonLDDocument(body: JsonLDObject, + context: JsonLDObject = JsonLDObject(Map.empty[String, JsonLDValue]), + isFlat: Boolean = false) { /** * A convenience function that calls `body.requireString`. */ @@ -972,11 +1072,71 @@ case class JsonLDDocument(body: JsonLDObject, context: JsonLDObject = JsonLDObje */ def maybeUUID(key: String): Option[UUID] = body.maybeUUID(key: String) + /** + * Flattens this JSON-LD document by moving inlined entities with IRIs to the top level. + * + * @return a flattened copy of this JSON-LD document. + */ + def flattened: JsonLDDocument = { + // Is this JSON-LD document already flat? + if (isFlat) { + // Yes. Just return it. + this + } else { + // No. Make a mutable Set to collect inlined entities that will be moved to the top level. + val entitiesToAddToTopLevel: collection.mutable.Set[JsonLDObject] = collection.mutable.Set.empty + + // Flatten the content of the document. + val flattenedContent: JsonLDObject = body.flattened( + entitiesToAddToTopLevel = entitiesToAddToTopLevel, + isAtTopLevel = true + ) + + // Are there any entities to add to the top level? + val allContent = if (entitiesToAddToTopLevel.nonEmpty) { + // Yes. Is there a top-level entity, i.e. does the flattened top level have any content + // besides @graph? + + val topLevelWithoutGraph: Map[String, JsonLDValue] = flattenedContent.value - JsonLDKeywords.GRAPH + + val maybeTopLevelObject: Vector[JsonLDObject] = if (topLevelWithoutGraph.nonEmpty) { + // Yes. Make a JsonLDObject for that content. + Vector(JsonLDObject(topLevelWithoutGraph)) + } else { + // No. + Vector.empty + } + + // Make a @graph containing the entities to add to the top level, the elements of the existing @graph + // if there is one, and the existing top-level entity if there is one. + val existingGraphElements: Seq[JsonLDValue] = flattenedContent.maybeArray(JsonLDKeywords.GRAPH).map(_.value).getOrElse(Seq.empty) + JsonLDObject(Map(JsonLDKeywords.GRAPH -> JsonLDArray(maybeTopLevelObject ++ existingGraphElements ++ entitiesToAddToTopLevel))) + } else { + // No. Just keep the existing @graph, if there is one, with the existing top-level entity. + flattenedContent + } + + copy( + body = allContent, + isFlat = true + ) + } + } + /** * Converts this JSON-LD document to its compacted representation. + * + * @param flatten `true` if a flat JSON-LD document should be returned. */ - private def makeCompactedJavaxJsonObject: JsonObject = { - val bodyAsTitaniumJsonDocument: JsonDocument = JsonDocument.of(body.toJavaxJsonValue) + private def makeCompactedJavaxJsonObject(flatten: Boolean): JsonObject = { + // Flatten the document if requested. + val documentFlattenedIfRequested: JsonLDDocument = if (flatten) { + flattened + } else { + this + } + + val bodyAsTitaniumJsonDocument: JsonDocument = JsonDocument.of(documentFlattenedIfRequested.body.toJavaxJsonValue) val contextAsTitaniumJsonDocument: JsonDocument = JsonDocument.of(context.toJavaxJsonValue) JsonLd.compact(bodyAsTitaniumJsonDocument, contextAsTitaniumJsonDocument).get } @@ -985,10 +1145,11 @@ case class JsonLDDocument(body: JsonLDObject, context: JsonLDObject = JsonLDObje * Formats this JSON-LD document as a string, using the specified [[JsonWriterFactory]]. * * @param jsonWriterFactory a [[JsonWriterFactory]] configured with the desired options. + * @param flatten `true` if a flat JSON-LD document should be returned. * @return the formatted document. */ - private def formatWithJsonWriterFactory(jsonWriterFactory: JsonWriterFactory): String = { - val compactedJavaxJsonObject: JsonObject = makeCompactedJavaxJsonObject + private def formatWithJsonWriterFactory(jsonWriterFactory: JsonWriterFactory, flatten: Boolean): String = { + val compactedJavaxJsonObject: JsonObject = makeCompactedJavaxJsonObject(flatten) val stringWriter = new StringWriter() val jsonWriter = jsonWriterFactory.createWriter(stringWriter) jsonWriter.write(compactedJavaxJsonObject) @@ -999,24 +1160,26 @@ case class JsonLDDocument(body: JsonLDObject, context: JsonLDObject = JsonLDObje /** * Converts this JSON-LD document to a pretty-printed JSON-LD string. * + * @param flatten `true` if a flat JSON-LD document should be returned. * @return the formatted document. */ - def toPrettyString: String = { + def toPrettyString(flatten: Boolean = false): String = { val config = new util.HashMap[String, Boolean]() config.put(JsonGenerator.PRETTY_PRINTING, true) val jsonWriterFactory: JsonWriterFactory = Json.createWriterFactory(config) - formatWithJsonWriterFactory(jsonWriterFactory) + formatWithJsonWriterFactory(jsonWriterFactory = jsonWriterFactory, flatten = flatten) } /** * Converts this [[JsonLDDocument]] to a compact JSON-LD string. * + * @param flatten `true` if a flat JSON-LD document should be returned. * @return the formatted document. */ - def toCompactString: String = { + def toCompactString(flatten: Boolean = false): String = { val config = new util.HashMap[String, Boolean]() val jsonWriterFactory: JsonWriterFactory = Json.createWriterFactory(config) - formatWithJsonWriterFactory(jsonWriterFactory) + formatWithJsonWriterFactory(jsonWriterFactory = jsonWriterFactory, flatten = flatten) } /** @@ -1177,9 +1340,10 @@ object JsonLDUtil { * Parses a JSON-LD string as a [[JsonLDDocument]] with an empty context. * * @param jsonLDString the string to be parsed. + * @param flatten `true` if a flat JSON-LD document should be returned. * @return a [[JsonLDDocument]]. */ - def parseJsonLD(jsonLDString: String): JsonLDDocument = { + def parseJsonLD(jsonLDString: String, flatten: Boolean = false): JsonLDDocument = { // Parse the string into a javax.json.JsonStructure. val stringReader = new StringReader(jsonLDString) val jsonReader: JsonReader = Json.createReader(stringReader) @@ -1193,7 +1357,16 @@ object JsonLDUtil { val compactedJsonObject: JsonObject = JsonLd.compact(titaniumDocument, emptyContext).get // Convert the resulting javax.json.JsonObject to a JsonLDDocument. - javaxJsonObjectToJsonLDDocument(compactedJsonObject) + val jsonLDDocument: JsonLDDocument = javaxJsonObjectToJsonLDDocument(compactedJsonObject) + + // Was flat JSON-LD requested? + if (flatten) { + // Yes. Flatten the document. + jsonLDDocument.flattened + } else { + // No. Leave it as is. + jsonLDDocument + } } /** @@ -1252,15 +1425,17 @@ object JsonLDUtil { * * - Inline blank nodes wherever they are used. * - Nest each entity in the first encountered entity that refers to it, and refer to it by IRI elsewhere. - * - Do not nest Knora ontology entities. + * - Don't nest an entity with an IRI inside a blank node. + * - Don't inline Knora ontology entities. * - After nesting, if more than one top-level entity remains, wrap them all in a `@graph`. * * An error is returned if the same blank node is used more than once. * - * @param model the [[RdfModel]] to be read. + * @param model the [[RdfModel]] to be read. + * @param flatJsonLD if `true`, produce a flat JSON-LD document. * @return the corresponding [[JsonLDDocument]]. */ - def fromRdfModel(model: RdfModel): JsonLDDocument = { + def fromRdfModel(model: RdfModel, flatJsonLD: Boolean = false): JsonLDDocument = { if (model.getContexts.nonEmpty) { throw BadRequestException("Named graphs in JSON-LD are not supported") } @@ -1303,7 +1478,8 @@ object JsonLDUtil { statements = statements, model = model, topLevelEntities = topLevelEntities, - processedSubjects = processedSubjects + processedSubjects = processedSubjects, + flatJsonLD = flatJsonLD ) // Add it to the collection of top-level entities. @@ -1317,11 +1493,11 @@ object JsonLDUtil { topLevelEntities.values.head } else { // No. Make a @graph. - JsonLDObject(Map(JsonLDKeywords.GRAPH -> JsonLDArray(topLevelEntities.values.toVector))) + JsonLDObject(Map(JsonLDKeywords.GRAPH -> JsonLDArray(topLevelEntities.values.toSeq))) } } - JsonLDDocument(body = body, context = context) + JsonLDDocument(body = body, context = context, isFlat = flatJsonLD) } /** @@ -1332,13 +1508,15 @@ object JsonLDUtil { * @param model the [[RdfModel]] that is being read. * @param topLevelEntities the top-level entities that have been constructed so far. * @param processedSubjects the subjects that have already been processed. + * @param flatJsonLD if `true`, produce flat JSON-LD. * @return the JSON-LD object that was constructed. */ private def entityToJsonLDObject(subj: RdfResource, statements: Set[Statement], model: RdfModel, topLevelEntities: collection.mutable.Map[RdfResource, JsonLDObject], - processedSubjects: collection.mutable.Set[RdfResource]) + processedSubjects: collection.mutable.Set[RdfResource], + flatJsonLD: Boolean) (implicit stringFormatter: StringFormatter): JsonLDObject = { // Mark the subject as processed. processedSubjects += subj @@ -1384,7 +1562,9 @@ object JsonLDUtil { resource = resource, model = model, topLevelEntities = topLevelEntities, - processedSubjects = processedSubjects + referrerIsBlankNode = idContent.isEmpty, + processedSubjects = processedSubjects, + flatJsonLD = flatJsonLD ) case literal: RdfLiteral => rdfLiteralToJsonLDValue(literal) @@ -1445,62 +1625,82 @@ object JsonLDUtil { * represent the referenced resource. This will be either a complete entity for nesting, or just * the referenced resource's IRI. * - * @param resource the resource to be converted. - * @param model the [[RdfModel]] that is being read. - * @param topLevelEntities the top-level entities that have been constructed so far. - * @param processedSubjects the subjects that have already been processed. + * @param resource the resource to be converted. + * @param model the [[RdfModel]] that is being read. + * @param topLevelEntities the top-level entities that have been constructed so far. + * @param referrerIsBlankNode `true` if the referrer is a blank node. If the referenced resource has an IRI, + * it will not be inlined. + * @param processedSubjects the subjects that have already been processed. + * @param flatJsonLD if `true` and the resource has an IRI, do not inline it, regardless of the referrer. * @return a JSON-LD value representing the resource. */ private def referencedRdfResourceToJsonLDValue(resource: RdfResource, model: RdfModel, topLevelEntities: collection.mutable.Map[RdfResource, JsonLDObject], - processedSubjects: collection.mutable.Set[RdfResource]) + referrerIsBlankNode: Boolean, + processedSubjects: collection.mutable.Set[RdfResource], + flatJsonLD: Boolean) (implicit stringFormatter: StringFormatter): JsonLDValue = { - // How we deal with circular references: the referenced resource is not yet in topLevelEntities, but it - // is already marked as processed. Therefore we will return its IRI rather than inlining it. - - // Is this entity already in topLevelEntities? - topLevelEntities.get(resource) match { - case Some(jsonLDObject) => - // Yes. Remove it from the top level so it can be inlined. - topLevelEntities -= resource - jsonLDObject - - case None => - // No. Is it a Knora ontology entity? - resource match { - case iriNode: IriNode if iriNode.iri.toSmartIri.isKnoraDefinitionIri => - // Yes. Just use its IRI, because we don't inline ontology entities. - iriToJsonLDObject(iriNode.iri) + /** + * Inlines a resource if possible, otherwise calls the specified function. + * + * @param nonInliningFunction a function to be called if the resource cannot be inlined. + * @return a [[JsonLDValue]] representing the resource. + */ + def inlineResource(nonInliningFunction: => JsonLDValue): JsonLDValue = { + // How we deal with circular references: the referenced resource is not yet in topLevelEntities, but it + // is already marked as processed. Therefore we will return its IRI rather than inlining it. + + // Is this entity already in topLevelEntities? + topLevelEntities.get(resource) match { + case Some(jsonLDObject) => + // Yes. Remove it from the top level so it can be inlined. + topLevelEntities -= resource + jsonLDObject + + case None => + // No. See if it's in the model. + val resourceStatements: Set[Statement] = model.find(Some(resource), None, None) + + // Is it in the model and not yet marked as processed? + if (resourceStatements.nonEmpty && !processedSubjects.contains(resource)) { + // Yes. Recurse to get it so it can be inlined. + entityToJsonLDObject( + subj = resource, + statements = resourceStatements, + model = model, + topLevelEntities = topLevelEntities, + processedSubjects = processedSubjects, + flatJsonLD = flatJsonLD + ) + } else { + // No. Do something else with it. + nonInliningFunction + } + } + } - case _ => - // It's not a Knora ontology entity. See if it's in the model. - val resourceStatements: Set[Statement] = model.find(Some(resource), None, None) - - // Is it in the model and not yet marked as processed? - if (resourceStatements.nonEmpty && !processedSubjects.contains(resource)) { - // Yes. Recurse to get it so it can be inlined. - entityToJsonLDObject( - subj = resource, - statements = resourceStatements, - model = model, - topLevelEntities = topLevelEntities, - processedSubjects = processedSubjects - ) - } else { - // No. Maybe it was already inlined, or maybe it wasn't provided - // in the model. Does it have an IRI? - resource match { - case iriNode: IriNode => - // Yes. Just use that. - iriToJsonLDObject(iriNode.iri) - - case blankNode: BlankNode => - // No, it's a blank node. This shouldn't happen. - throw InvalidRdfException(s"Blank node ${blankNode.id} was not found or is referenced in more than one place") - } - } + // Is this resource an IRI? + resource match { + case iriNode: IriNode => + // Yes. Are any of the following true? + // - We were asked for flat JSON-LD. + // - The resource IRI is a Knora definition IRI. + // - The referrer is a blank node. + if (flatJsonLD || + iriNode.iri.toSmartIri.isKnoraDefinitionIri || + referrerIsBlankNode) { + // Yes. Don't try to inline the resource, just return its IRI. + iriToJsonLDObject(iriNode.iri) + } else { + // No. Try to inline it. + inlineResource(iriToJsonLDObject(iriNode.iri)) } + + case blankNode: BlankNode => + // It's a blank node. It should be possible to inline it. If not, the input model is invalid; + // return an error. + inlineResource(throw InvalidRdfException(s"Blank node ${blankNode.id} was not found or is referenced in more than one place")) } } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala index eec405341f..157b4a5643 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala @@ -20,7 +20,7 @@ package org.knora.webapi.messages.util.rdf import akka.http.scaladsl.model.MediaType -import org.knora.webapi.RdfMediaTypes +import org.knora.webapi.{RdfMediaTypes, SchemaOption, SchemaOptions} import org.knora.webapi.exceptions.{BadRequestException, InvalidRdfException} /** @@ -138,41 +138,53 @@ trait RdfFormatUtil { /** * Parses an RDF string to a [[JsonLDDocument]]. * - * @param rdfStr the RDF string to be parsed. - * @param rdfFormat the format of the string. + * @param rdfStr the RDF string to be parsed. + * @param rdfFormat the format of the string. + * @param flatJsonLD if `true`, return flat JSON-LD. * @return the corresponding [[JsonLDDocument]]. */ - def parseToJsonLDDocument(rdfStr: String, rdfFormat: RdfFormat): JsonLDDocument = { + def parseToJsonLDDocument(rdfStr: String, rdfFormat: RdfFormat, flatJsonLD: Boolean = false): JsonLDDocument = { rdfFormat match { case JsonLD => // Use JsonLDUtil to parse JSON-LD. - JsonLDUtil.parseJsonLD(rdfStr) + JsonLDUtil.parseJsonLD(jsonLDString = rdfStr, flatten = flatJsonLD) case nonJsonLD: NonJsonLD => // Use an implementation-specific function to parse other formats to an RdfModel. // Use JsonLDUtil to convert the resulting model to a JsonLDDocument. - JsonLDUtil.fromRdfModel(parseNonJsonLDToRdfModel(rdfStr = rdfStr, rdfFormat = nonJsonLD)) + JsonLDUtil.fromRdfModel( + model = parseNonJsonLDToRdfModel(rdfStr = rdfStr, rdfFormat = nonJsonLD), + flatJsonLD = flatJsonLD + ) } } /** * Converts an [[RdfModel]] to a string. * - * @param rdfModel the model to be formatted. - * @param rdfFormat the format to be used. - * @param prettyPrint if `true`, the output should be pretty-printed. + * @param rdfModel the model to be formatted. + * @param rdfFormat the format to be used. + * @param schemaOptions the schema options that were submitted with the request. + * @param prettyPrint if `true`, the output should be pretty-printed. * @return a string representation of the RDF model. */ - def format(rdfModel: RdfModel, rdfFormat: RdfFormat, prettyPrint: Boolean = true): String = { + def format(rdfModel: RdfModel, + rdfFormat: RdfFormat, + schemaOptions: Set[SchemaOption] = Set.empty, + prettyPrint: Boolean = true): String = { rdfFormat match { case JsonLD => // Use JsonLDUtil to convert to JSON-LD. - val jsonLDDocument: JsonLDDocument = JsonLDUtil.fromRdfModel(rdfModel) + val jsonLDDocument: JsonLDDocument = JsonLDUtil.fromRdfModel( + model = rdfModel, + flatJsonLD = SchemaOptions.returnFlatJsonLD(schemaOptions) + ) + // Format the document as a string. if (prettyPrint) { - jsonLDDocument.toPrettyString + jsonLDDocument.toPrettyString() } else { - jsonLDDocument.toCompactString + jsonLDDocument.toCompactString() } case nonJsonLD: NonJsonLD => diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraRequestV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraRequestV2.scala index fd1aa38519..12fecaae32 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraRequestV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraRequestV2.scala @@ -49,7 +49,11 @@ trait KnoraRdfModelRequestV2 { * Returns a Turtle representation of the graph. */ def toTurtle(featureFactoryConfig: FeatureFactoryConfig): String = { - RdfFeatureFactory.getRdfFormatUtil(featureFactoryConfig).format(rdfModel, Turtle) + RdfFeatureFactory.getRdfFormatUtil(featureFactoryConfig).format( + rdfModel = rdfModel, + rdfFormat = Turtle, + prettyPrint = false + ) } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraResponseV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraResponseV2.scala index add2c7a960..411f74cc77 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraResponseV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/KnoraResponseV2.scala @@ -64,7 +64,7 @@ trait KnoraJsonLDResponseV2 extends KnoraResponseV2 { case InternalSchema => throw AssertionException(s"Response cannot be returned in the internal schema") } - // Convert this response message to a JSON-LD document. + // Convert this response message to a JsonLDDocument. val jsonLDDocument: JsonLDDocument = toJsonLDDocument( targetSchema = targetApiV2Schema, settings = settings, @@ -74,8 +74,8 @@ trait KnoraJsonLDResponseV2 extends KnoraResponseV2 { // Which response format was requested? rdfFormat match { case JsonLD => - // JSON-LD. Use the JsonLDDocument to generate the formatted text. - jsonLDDocument.toPrettyString + // JSON-LD. Have the JsonLDDocument format itself. + jsonLDDocument.toPrettyString(SchemaOptions.returnFlatJsonLD(schemaOptions)) case nonJsonLD: NonJsonLD => // Some other format. Convert the JSON-LD document to an RDF model. @@ -85,7 +85,8 @@ trait KnoraJsonLDResponseV2 extends KnoraResponseV2 { // Convert the model to the requested format. rdfFormatUtil.format( rdfModel = rdfModel, - rdfFormat = nonJsonLD + rdfFormat = nonJsonLD, + schemaOptions = schemaOptions ) } } @@ -132,7 +133,8 @@ trait KnoraTurtleResponseV2 extends KnoraResponseV2 { // Return the model in the requested format. rdfFormatUtil.format( rdfModel = rdfModel, - rdfFormat = rdfFormat + rdfFormat = rdfFormat, + schemaOptions = schemaOptions ) } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala index c6ffd72242..6f4634ffcf 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala @@ -1313,7 +1313,7 @@ case class ReadOntologyV2(ontologyMetadata: OntologyMetadataV2, ontologyMetadata.toJsonLD(targetSchema) + (JsonLDKeywords.GRAPH -> JsonLDArray(allEntitiesSorted)) ) - JsonLDDocument(body = body, context = context) + JsonLDDocument(body = body, context = context, isFlat = true) } } @@ -1542,7 +1542,7 @@ case class ReadOntologyMetadataV2(ontologies: Set[OntologyMetadataV2]) extends K JsonLDKeywords.GRAPH -> JsonLDArray(ontologiesJson) )) - JsonLDDocument(body = body, context = context) + JsonLDDocument(body = body, context = context, isFlat = true) } def toJsonLDDocument(targetSchema: ApiV2Schema, settings: KnoraSettingsImpl, schemaOptions: Set[SchemaOption]): JsonLDDocument = { diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala index 46ed6e8543..5fb04e604e 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala @@ -222,7 +222,7 @@ case class TEIHeader(headerInfo: ReadResourceV2, headerXSLT: Option[String], set // here instead of RDF4J. val rdfParser: RDFParser = Rio.createParser(RDFFormat.JSONLD) - val stringReader = new StringReader(headerJSONLD.toCompactString) + val stringReader = new StringReader(headerJSONLD.toCompactString()) val stringWriter = new StringWriter() val rdfWriter: RDFWriter = new RDFXMLPrettyWriter(stringWriter) diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala index 4a4847ad70..4fbca5a426 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala @@ -90,6 +90,23 @@ object RouteUtilV2 { */ val MARKUP_STANDOFF: String = "standoff" + /** + * The name of the HTTP header that can be used to request hierarchical or flat JSON-LD. + */ + val JSON_LD_RENDERING_HEADER: String = "x-knora-json-ld-rendering" + + /** + * Indicates that flat JSON-LD should be returned, i.e. objects with IRIs should be referenced by IRI + * rather than nested. Blank nodes will still be nested in any case. + */ + val JSON_LD_RENDERING_FLAT: String = "flat" + + /** + * Indicates that hierarchical JSON-LD should be returned, i.e. objects with IRIs should be nested when + * possible, rather than referenced by IRI. + */ + val JSON_LD_RENDERING_HIERARCHICAL: String = "hierarchical" + /** * Gets the ontology schema that is specified in an HTTP request. The schema can be specified * either in the HTTP header [[SCHEMA_HEADER]] or in the URL parameter [[SCHEMA_PARAM]]. @@ -151,6 +168,20 @@ object RouteUtilV2 { } } + private def getJsonLDRendering(requestContext: RequestContext): Option[JsonLDRendering] = { + def nameToJsonLDRendering(jsonLDRenderingName: String): JsonLDRendering = { + jsonLDRenderingName match { + case JSON_LD_RENDERING_FLAT => FlatJsonLD + case JSON_LD_RENDERING_HIERARCHICAL => HierarchicalJsonLD + case _ => throw BadRequestException(s"Unrecognised JSON-LD rendering: $jsonLDRenderingName") + } + } + + requestContext.request.headers.find(_.lowercaseName == JSON_LD_RENDERING_HEADER).map { + header => nameToJsonLDRendering(header.value) + } + } + /** * Gets the schema options submitted in the request. * @@ -158,7 +189,10 @@ object RouteUtilV2 { * @return the set of schema options submitted in the request, including default options. */ def getSchemaOptions(requestContext: RequestContext): Set[SchemaOption] = { - getStandoffRendering(requestContext).toSet + Set( + getStandoffRendering(requestContext), + getJsonLDRendering(requestContext) + ).flatten } /** diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/MetadataRouteV2E2ESpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v2/MetadataRouteV2E2ESpec.scala index bdb8c9e30d..d0eea83cee 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/MetadataRouteV2E2ESpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/MetadataRouteV2E2ESpec.scala @@ -1,5 +1,6 @@ package org.knora.webapi.e2e.v2 +import java.io.File import java.net.URLEncoder import akka.http.scaladsl.model.headers.{BasicHttpCredentials, RawHeader} @@ -7,109 +8,29 @@ import akka.http.scaladsl.model.{HttpEntity, HttpResponse} import org.knora.webapi.messages.util.rdf._ import org.knora.webapi.sharedtestdata.SharedTestDataADM import org.knora.webapi._ +import org.knora.webapi.routing.RouteUtilV2 +import org.knora.webapi.util.FileUtil class MetadataRouteV2E2ESpec extends E2ESpec { + private val rdfModelFactory: RdfModelFactory = RdfFeatureFactory.getRdfModelFactory(defaultFeatureFactoryConfig) private val rdfFormatUtil: RdfFormatUtil = RdfFeatureFactory.getRdfFormatUtil(defaultFeatureFactoryConfig) private val beolUserEmail = SharedTestDataADM.beolUser.email private val beolProjectIRI: IRI = SharedTestDataADM.BEOL_PROJECT_IRI private val password = SharedTestDataADM.testPass - private val metadataContent: String = - s""" - |@prefix dsp-repo: . - |@prefix knora-base: . - |@prefix knora-admin: . - |@prefix owl: . - |@prefix rdf: . - |@prefix rdfs: . - |@prefix xml: . - |@prefix xsd: . - |@prefix foaf: . - |@prefix vowl: . - |@prefix prov: . - |@prefix dc: . - |@prefix dct: . - |@prefix locn: . - |@prefix vcard: . - |@prefix schema: . - |@prefix skos: . - |@prefix unesco6: . - |@base . - | - | rdf:type dsp-repo:Project . - | dsp-repo:hasName "Bernoulli-Euler Online (BEOL)" . - | dsp-repo:hasDescription "The project Bernoulli-Euler Online (BEOL) integrates the two edition projects Basler Edition der Bernoulli-Briefwechsel (BEBB) and Leonhardi Euleri Opera Omnia (LEOO) into one digital platform available on the web. In addition, Jacob Bernoulli's scientific notebook Meditationes - a document of outstanding significance for the history of mathematics at its turning point around 1700 - is published for the first time in its entirety on the BEOL platform as a region-based multilayer interactive digital edition providing access to facsimiles, transcriptions, translations, indices, and commentaries. Besides being an edition platform, BEOL is a virtual research environment for the study of early modern mathematics and science as a data graph using sophisticated analysis tools. Currently BEOL is connected to two third-party repositories: The Newton Project and the Briefportal Leibniz, initiating the formation of a network of digital editions of the early modern scientific correspondence data.The goal of BEOL is thus twofold: it focuses on the mathematics influenced by the Bernoulli dynasty and Leonhard Euler and undertakes a methodological effort to present these materials to the public and researchers in a highly functional way." . - | dsp-repo:hasKeywords "mathematics" . - | dsp-repo:hasKeywords "science" . - | dsp-repo:hasKeywords "history of science" . - | dsp-repo:hasKeywords "history of mathematics" . - | dsp-repo:hasKeywords "Bernoulli" . - | dsp-repo:hasKeywords "Euler" . - | dsp-repo:hasKeywords "Newton" . - | dsp-repo:hasKeywords "Leibniz" . - | dsp-repo:hasCategories "mathematics" . - | dsp-repo:hasStartDate "2016.07" . - | dsp-repo:hasEndDate "2020.01" . - | dsp-repo:hasTemporalCoverage "17th century and 18th century CE" . - | dsp-repo:hasSpatialCoverage "Europe" . - | dsp-repo:hasSpatialCoverage "Russia" . - | dsp-repo:hasSpatialCoverage "France" . - | dsp-repo:hasSpatialCoverage "Switzerland" . - | dsp-repo:hasSpatialCoverage "Germany" . - | dsp-repo:hasSpatialCoverage "Italy" . - | dsp-repo:hasSpatialCoverage "England" . - | dsp-repo:hasFunder "Schweizerischer Nationalfonds (SNSF)" . - | dsp-repo:hasURL "https://beol.dasch.swiss/" . - | dsp-repo:hasDateCreated "09.2017" . - | dsp-repo:hasDateModified "04.2020" . - | dsp-repo:hasShortcode "0801" . - | dsp-repo:hasAlternateName "beol" . - |""".stripMargin - - private val metadataAsJsonLD: String = - """ - |{ - | "http://ns.dasch.swiss/repository#hasDateModified": "04.2020", - | "http://ns.dasch.swiss/repository#hasShortcode": "0801", - | "http://ns.dasch.swiss/repository#hasName": "Bernoulli-Euler Online (BEOL)", - | "http://ns.dasch.swiss/repository#hasSpatialCoverage": [ - | "Italy", - | "Switzerland", - | "England", - | "France", - | "Russia", - | "Germany", - | "Europe" - | ], - | "http://ns.dasch.swiss/repository#hasFunder": "Schweizerischer Nationalfonds (SNSF)", - | "http://ns.dasch.swiss/repository#hasKeywords": [ - | "Leibniz", - | "science", - | "Euler", - | "Bernoulli", - | "mathematics", - | "history of science", - | "Newton", - | "history of mathematics" - | ], - | "http://ns.dasch.swiss/repository#hasEndDate": "2020.01", - | "http://ns.dasch.swiss/repository#hasTemporalCoverage": "17th century and 18th century CE", - | "http://ns.dasch.swiss/repository#hasCategories": "mathematics", - | "http://ns.dasch.swiss/repository#hasDescription": "The project Bernoulli-Euler Online (BEOL) integrates the two edition projects Basler Edition der Bernoulli-Briefwechsel (BEBB) and Leonhardi Euleri Opera Omnia (LEOO) into one digital platform available on the web. In addition, Jacob Bernoulli's scientific notebook Meditationes - a document of outstanding significance for the history of mathematics at its turning point around 1700 - is published for the first time in its entirety on the BEOL platform as a region-based multilayer interactive digital edition providing access to facsimiles, transcriptions, translations, indices, and commentaries. Besides being an edition platform, BEOL is a virtual research environment for the study of early modern mathematics and science as a data graph using sophisticated analysis tools. Currently BEOL is connected to two third-party repositories: The Newton Project and the Briefportal Leibniz, initiating the formation of a network of digital editions of the early modern scientific correspondence data.The goal of BEOL is thus twofold: it focuses on the mathematics influenced by the Bernoulli dynasty and Leonhard Euler and undertakes a methodological effort to present these materials to the public and researchers in a highly functional way.", - | "@type": "http://ns.dasch.swiss/repository#Project", - | "http://ns.dasch.swiss/repository#hasAlternateName": "beol", - | "http://ns.dasch.swiss/repository#hasStartDate": "2016.07", - | "http://ns.dasch.swiss/repository#hasURL": "https://beol.dasch.swiss/", - | "http://ns.dasch.swiss/repository#hasDateCreated": "09.2017", - | "@id": "http://ns.dasch.swiss/beol" - |} - |""".stripMargin + private val metadataAsTurtle: String = FileUtil.readTextFile(new File("test_data/metadataE2EV2/metadata.ttl")) + private val metadataAsFlatJsonLD: String = FileUtil.readTextFile(new File("test_data/metadataE2EV2/metadata-flat.jsonld")) + + private val expectedRdfModel: RdfModel = rdfFormatUtil.parseToRdfModel( + rdfStr = metadataAsTurtle, + rdfFormat = Turtle + ) "The metadata v2 endpoint" should { "perform a put request for the metadata of beol project given as Turtle" in { val request = Put(s"$baseApiUrl/v2/metadata/${URLEncoder.encode(beolProjectIRI, "UTF-8")}", - HttpEntity(RdfMediaTypes.`text/turtle`, metadataContent)) ~> + HttpEntity(RdfMediaTypes.`text/turtle`, metadataAsTurtle)) ~> addCredentials(BasicHttpCredentials(beolUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) @@ -120,7 +41,7 @@ class MetadataRouteV2E2ESpec extends E2ESpec { "perform a put request for the metadata of beol project given as JSON-LD" in { val request = Put(s"$baseApiUrl/v2/metadata/${URLEncoder.encode(beolProjectIRI, "UTF-8")}", - HttpEntity(RdfMediaTypes.`application/json`, metadataAsJsonLD)) ~> + HttpEntity(RdfMediaTypes.`application/json`, metadataAsFlatJsonLD)) ~> addCredentials(BasicHttpCredentials(beolUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) @@ -132,15 +53,22 @@ class MetadataRouteV2E2ESpec extends E2ESpec { "get the created metadata graph as JSON-LD" in { val request = Get(s"$baseApiUrl/v2/metadata/${URLEncoder.encode(beolProjectIRI, "UTF-8")}") val response: HttpResponse = singleAwaitingRequest(request) - val responseJSONLD = responseToJsonLDDocument(response) assert(response.status.isSuccess()) + val responseJSONLD: JsonLDDocument = responseToJsonLDDocument(response) + val responseModel: RdfModel = responseJSONLD.toRdfModel(rdfModelFactory) + assert(responseModel == expectedRdfModel) + } + + "get the created metadata graph as flat JSON-LD" in { + val expectedFlatJsonLDDocument: JsonLDDocument = JsonLDUtil.parseJsonLD(metadataAsFlatJsonLD) - val expectedGraphJSONLD: JsonLDDocument = rdfFormatUtil.parseToJsonLDDocument( - rdfStr = metadataContent, - rdfFormat = Turtle - ) + val request = Get(s"$baseApiUrl/v2/metadata/${URLEncoder.encode(beolProjectIRI, "UTF-8")}"). + addHeader(RawHeader(RouteUtilV2.JSON_LD_RENDERING_HEADER, RouteUtilV2.JSON_LD_RENDERING_FLAT)) - assert(expectedGraphJSONLD.body == responseJSONLD.body) + val response: HttpResponse = singleAwaitingRequest(request) + val responseJSONLD: JsonLDDocument = responseToJsonLDDocument(response) + assert(response.status.isSuccess()) + assert(responseJSONLD == expectedFlatJsonLDDocument) } "get the created metadata graph as Turtle" in { @@ -150,6 +78,9 @@ class MetadataRouteV2E2ESpec extends E2ESpec { val response: HttpResponse = singleAwaitingRequest(request) assert(response.status.isSuccess()) response.entity.contentType.mediaType.value should be(turtleType) + val responseStr = responseToString(response) + val responseModel: RdfModel = parseTurtle(responseStr) + assert(responseModel == expectedRdfModel) } "not return metadata for an invalid project IRI" in { @@ -160,7 +91,7 @@ class MetadataRouteV2E2ESpec extends E2ESpec { "not create metadata for an invalid project IRI" in { val request = Put(s"$baseApiUrl/v2/metadata/invalid-projectIRI", - HttpEntity(RdfMediaTypes.`text/turtle`, metadataContent)) ~> + HttpEntity(RdfMediaTypes.`text/turtle`, metadataAsTurtle)) ~> addCredentials(BasicHttpCredentials(beolUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala index 775a43759d..789a369ddd 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala @@ -1636,7 +1636,7 @@ class ResourcesRouteV2E2ESpec extends E2ESpec(ResourcesRouteV2E2ESpec.config) { // Check the standoff tags to make sure they match the ontology. for (jsonLDObject <- standoffBuffer) { - val docForValidation = JsonLDDocument(body = jsonLDObject).toCompactString + val docForValidation = JsonLDDocument(body = jsonLDObject).toCompactString() instanceChecker.check( instanceResponse = docForValidation, diff --git a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/JsonLDUtilSpec.scala b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/JsonLDUtilSpec.scala index d6dd59fbc0..c5e4417ff3 100644 --- a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/JsonLDUtilSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/JsonLDUtilSpec.scala @@ -121,7 +121,7 @@ abstract class JsonLDUtilSpec(featureToggle: FeatureToggle) extends CoreSpec { """.stripMargin val compactedJsonLDDoc: JsonLDDocument = JsonLDUtil.parseJsonLD(ontologyJsonLDInputStr) - val formattedCompactedDoc = compactedJsonLDDoc.toPrettyString + val formattedCompactedDoc = compactedJsonLDDoc.toPrettyString() val receivedOutputAsJsValue: JsValue = JsonParser(formattedCompactedDoc) val expectedOutputAsJsValue: JsValue = JsonParser(ontologyCompactedJsonLDOutputStr) receivedOutputAsJsValue should ===(expectedOutputAsJsValue) @@ -281,5 +281,86 @@ abstract class JsonLDUtilSpec(featureToggle: FeatureToggle) extends CoreSpec { outputJsonLD.body == expectedWithFoo2AtTopLevel ) } + + "convert an RDF model to flat or hierarchical JSON-LD" in { + // A simple Turtle document. + val turtle = + """@prefix foo: . + |@prefix rdfs: . + |@prefix xsd: . + | + | a foo:Foo; + | rdfs:label "foo 1"; + | foo:hasBar [ + | a foo:Bar; + | rdfs:label "bar 1" + | ]; + | foo:hasOtherFoo . + | + | a foo:Foo; + | rdfs:label "foo 2"; + | foo:hasIndex "3"^^xsd:integer; + | foo:hasBar [ + | a foo:Bar; + | rdfs:label "bar 2" + | ]. + |""".stripMargin + + // Parse it to an RDF4J Model. + val inputModel: RdfModel = rdfFormatUtil.parseToRdfModel(rdfStr = turtle, rdfFormat = Turtle) + + // Convert the model to a hierarchical JsonLDDocument. + val hierarchicalJsonLD: JsonLDDocument = JsonLDUtil.fromRdfModel(model = inputModel, flatJsonLD = false) + + val expectedHierarchicalJsonLD = JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo1"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "@type" -> JsonLDString(value = "http://example.org/foo#Bar"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 1") + )), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 1"), + "http://example.org/foo#hasOtherFoo" -> JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo2"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 2"), + "http://example.org/foo#hasIndex" -> JsonLDInt(value = 3), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "@type" -> JsonLDString(value = "http://example.org/foo#Bar"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 2") + )) + )) + )) + + assert(hierarchicalJsonLD.body == expectedHierarchicalJsonLD) + + // Convert the model to a flat JsonLDDocument. + val flatJsonLD: JsonLDDocument = JsonLDUtil.fromRdfModel(model = inputModel, flatJsonLD = true) + + val expectedFlatJsonLD = JsonLDObject(value = Map("@graph" -> JsonLDArray(value = Vector( + JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo1"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 1"), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "@type" -> JsonLDString(value = "http://example.org/foo#Bar"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 1") + )), + "http://example.org/foo#hasOtherFoo" -> JsonLDObject(value = Map("@id" -> JsonLDString(value = "http://rdfh.ch/foo2"))) + )), + JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo2"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 2"), + "http://example.org/foo#hasIndex" -> JsonLDInt(value = 3), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 2"), + "@type" -> JsonLDString(value = "http://example.org/foo#Bar") + )) + )) + )))) + + assert(flatJsonLD.body == expectedFlatJsonLD) + } } } diff --git a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/KnoraResponseV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/KnoraResponseV2Spec.scala index 9b95b016b5..23400741bd 100644 --- a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/KnoraResponseV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/KnoraResponseV2Spec.scala @@ -39,20 +39,81 @@ abstract class KnoraResponseV2Spec(featureToggle: FeatureToggle) extends CoreSpe private val rdfFormatUtil: RdfFormatUtil = RdfFeatureFactory.getRdfFormatUtil(featureFactoryConfig) + private val turtle = + """ a ; + | [ a ; + | "bar 1" + | ]; + | ; + | "foo 1" . + | + | a ; + | [ a ; + | "bar 2" + | ]; + | 3; + | "foo 2" .""".stripMargin + + private val hierarchicalJsonLD = JsonLDDocument( + JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo1"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "@type" -> JsonLDString(value = "http://example.org/foo#Bar"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 1") + )), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 1"), + "http://example.org/foo#hasOtherFoo" -> JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo2"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 2"), + "http://example.org/foo#hasIndex" -> JsonLDInt(value = 3), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "@type" -> JsonLDString(value = "http://example.org/foo#Bar"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 2") + )) + )) + )) + ) + + private val flatJsonLD = JsonLDDocument( + JsonLDObject(value = Map("@graph" -> JsonLDArray(value = Vector( + JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo1"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 1"), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "@type" -> JsonLDString(value = "http://example.org/foo#Bar"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 1") + )), + "http://example.org/foo#hasOtherFoo" -> JsonLDObject(value = Map("@id" -> JsonLDString(value = "http://rdfh.ch/foo2"))) + )), + JsonLDObject(value = Map( + "@id" -> JsonLDString(value = "http://rdfh.ch/foo2"), + "@type" -> JsonLDString(value = "http://example.org/foo#Foo"), + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "foo 2"), + "http://example.org/foo#hasIndex" -> JsonLDInt(value = 3), + "http://example.org/foo#hasBar" -> JsonLDObject(value = Map( + "http://www.w3.org/2000/01/rdf-schema#label" -> JsonLDString(value = "bar 2"), + "@type" -> JsonLDString(value = "http://example.org/foo#Bar") + )) + )) + )))), + isFlat = true + ) + /** * A test implementation of [[KnoraTurtleResponseV2]]. */ - case class TurtleTestMessage(turtle: String) extends KnoraTurtleResponseV2 + case class TurtleTestResponse(turtle: String) extends KnoraTurtleResponseV2 /** * A test implementation of [[KnoraJsonLDResponseV2]]. */ - case class JsonLDTestMessage(jsonLD: String) extends KnoraJsonLDResponseV2 { + case class JsonLDTestResponse(jsonLDDocument: JsonLDDocument) extends KnoraJsonLDResponseV2 { override protected def toJsonLDDocument(targetSchema: ApiV2Schema, settings: KnoraSettingsImpl, - schemaOptions: Set[SchemaOption]): JsonLDDocument = { - JsonLDUtil.parseJsonLD(jsonLD) - } + schemaOptions: Set[SchemaOption]): JsonLDDocument = jsonLDDocument } "KnoraResponseV2" should { @@ -61,10 +122,10 @@ abstract class KnoraResponseV2Spec(featureToggle: FeatureToggle) extends CoreSpe val turtle: String = FileUtil.readTextFile(new File("test_data/resourcesR2RV2/BookReiseInsHeiligeLand.ttl")) // Wrap it in a KnoraTurtleResponseV2. - val turtleTestMessage = TurtleTestMessage(turtle) + val turtleTestResponse = TurtleTestResponse(turtle) // Ask the KnoraTurtleResponseV2 to convert the content to JSON-LD. - val jsonLD: String = turtleTestMessage.format( + val jsonLD: String = turtleTestResponse.format( rdfFormat = JsonLD, targetSchema = InternalSchema, schemaOptions = Set.empty, @@ -88,10 +149,10 @@ abstract class KnoraResponseV2Spec(featureToggle: FeatureToggle) extends CoreSpe val jsonLD: String = FileUtil.readTextFile(new File("test_data/resourcesR2RV2/BookReiseInsHeiligeLand.jsonld")) // Wrap it in a KnoraJsonLDResponseV2. - val jsonLDTestMessage = JsonLDTestMessage(jsonLD) + val jsonLDTestResponse = JsonLDTestResponse(JsonLDUtil.parseJsonLD(jsonLD)) // Ask the KnoraJsonLDResponseV2 to convert the content to Turtle. - val turtle: String = jsonLDTestMessage.format( + val turtle: String = jsonLDTestResponse.format( rdfFormat = Turtle, targetSchema = ApiV2Complex, schemaOptions = Set.empty, @@ -109,5 +170,50 @@ abstract class KnoraResponseV2Spec(featureToggle: FeatureToggle) extends CoreSpe // Compare the two models. parsedTurtle should ===(parsedExpectedTurtle) } + + "convert a hierarchical JsonLDDocument to a flat one" in { + val jsonLDTestResponse = JsonLDTestResponse(hierarchicalJsonLD) + + val jsonLDResponseStr: String = jsonLDTestResponse.format( + rdfFormat = JsonLD, + targetSchema = ApiV2Complex, + schemaOptions = Set(FlatJsonLD), + featureFactoryConfig = featureFactoryConfig, + settings = settings + ) + + val jsonLDResponseDoc: JsonLDDocument = JsonLDUtil.parseJsonLD(jsonLDResponseStr) + assert(jsonLDResponseDoc.body == flatJsonLD.body) + } + + "convert Turtle to a hierarchical JSON-LD document" in { + val turtleTestResponse = TurtleTestResponse(turtle) + + val jsonLDResponseStr: String = turtleTestResponse.format( + rdfFormat = JsonLD, + targetSchema = InternalSchema, + schemaOptions = Set(HierarchicalJsonLD), + featureFactoryConfig = featureFactoryConfig, + settings = settings + ) + + val jsonLDResponseDoc: JsonLDDocument = JsonLDUtil.parseJsonLD(jsonLDResponseStr) + assert(jsonLDResponseDoc.body == hierarchicalJsonLD.body) + } + + "convert Turtle to a flat JSON-LD document" in { + val turtleTestResponse = TurtleTestResponse(turtle) + + val jsonLDResponseStr: String = turtleTestResponse.format( + rdfFormat = JsonLD, + targetSchema = InternalSchema, + schemaOptions = Set(FlatJsonLD), + featureFactoryConfig = featureFactoryConfig, + settings = settings + ) + + val jsonLDResponseDoc: JsonLDDocument = JsonLDUtil.parseJsonLD(jsonLDResponseStr) + assert(jsonLDResponseDoc.body == flatJsonLD.body) + } } } diff --git a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtilSpec.scala b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtilSpec.scala index 60bd345544..c881a0a2fe 100644 --- a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtilSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtilSpec.scala @@ -117,7 +117,7 @@ abstract class RdfFormatUtilSpec(featureToggle: FeatureToggle) extends CoreSpec val inputJsonLDDocument: JsonLDDocument = rdfFormatUtil.parseToJsonLDDocument(rdfStr = inputTurtle, rdfFormat = JsonLD) checkJsonLDDocumentForRdfTypeBook(inputJsonLDDocument) - val outputJsonLD: String = inputJsonLDDocument.toPrettyString + val outputJsonLD: String = inputJsonLDDocument.toPrettyString() val outputJsonLDDocument: JsonLDDocument = rdfFormatUtil.parseToJsonLDDocument(rdfStr = outputJsonLD, rdfFormat = JsonLD) checkJsonLDDocumentForRdfTypeBook(outputJsonLDDocument) assert(inputJsonLDDocument == outputJsonLDDocument) diff --git a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/jenaimpl/BUILD.bazel b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/jenaimpl/BUILD.bazel index 7234ce79f2..bd21aa0a5f 100644 --- a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/jenaimpl/BUILD.bazel +++ b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/jenaimpl/BUILD.bazel @@ -39,7 +39,7 @@ scala_test( "//webapi:main_library", "//webapi:test_library", "@maven//:org_apache_jena_apache_jena_libs" - ] + BASE_TEST_DEPENDENCIES_WITH_JSON, + ] + BASE_TEST_DEPENDENCIES_WITH_JSON_LD, ) scala_test( @@ -79,6 +79,6 @@ scala_test( "//webapi:main_library", "//webapi:test_library", "@maven//:org_apache_jena_apache_jena_libs" - ] + BASE_TEST_DEPENDENCIES_WITH_JSON, + ] + BASE_TEST_DEPENDENCIES_WITH_JSON_LD, ) diff --git a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/rdf4jimpl/BUILD.bazel b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/rdf4jimpl/BUILD.bazel index 0a65d555e3..e275660d28 100644 --- a/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/rdf4jimpl/BUILD.bazel +++ b/webapi/src/test/scala/org/knora/webapi/messages/util/rdf/rdf4jimpl/BUILD.bazel @@ -39,7 +39,7 @@ scala_test( "//webapi:main_library", "//webapi:test_library", "@maven//:org_apache_jena_apache_jena_libs" - ] + BASE_TEST_DEPENDENCIES_WITH_JSON, + ] + BASE_TEST_DEPENDENCIES_WITH_JSON_LD, ) scala_test( @@ -79,5 +79,5 @@ scala_test( "//webapi:main_library", "//webapi:test_library", "@maven//:org_apache_jena_apache_jena_libs" - ] + BASE_TEST_DEPENDENCIES_WITH_JSON, + ] + BASE_TEST_DEPENDENCIES_WITH_JSON_LD, )