diff --git a/.scripts/travis_setup.sh b/.scripts/travis_setup.sh index a6f17a7a..c45aa993 100755 --- a/.scripts/travis_setup.sh +++ b/.scripts/travis_setup.sh @@ -1,7 +1,7 @@ #!/bin/bash echo "Setup database for Drupal" mysql -u root -e 'create database drupal;' -mysql -u root -e "GRANT ALL PRIVILEGES ON drupal.* To 'drupal'@'localhost' IDENTIFIED BY 'drupal';" +mysql -u root -e "GRANT ALL PRIVILEGES ON drupal.* To 'drupal'@'127.0.0.1' IDENTIFIED BY 'drupal';" if [ $TRAVIS_PHP_VERSION = "5.6" ]; then phpenv config-add $SCRIPT_DIR/php56.ini @@ -30,11 +30,11 @@ phpenv rehash echo "Drush setup drupal site" cd web -drush si --db-url=mysql://drupal:drupal@localhost/drupal --yes -drush runserver --php-cgi=$HOME/.phpenv/shims/php-cgi localhost:8081 &>/tmp/drush_webserver.log & - +drush si --db-url=mysql://drupal:drupal@127.0.0.1/drupal --yes +drush runserver 127.0.0.1:8282 & +until curl -s 127.0.0.1:8282; do true; done > /dev/null echo "Enable simpletest module" -drush en -y simpletest +drush --uri=127.0.0.1:8282 en -y simpletest echo "Setup ActiveMQ" cd /opt diff --git a/.travis.yml b/.travis.yml index e4225973..3449988f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,13 +21,13 @@ install: - git -C "$TRAVIS_BUILD_DIR" checkout -b travis-testing - cd $DRUPAL_DIR; composer config repositories.local path "$TRAVIS_BUILD_DIR" - composer require "islandora/islandora:dev-travis-testing as dev-8.x-1.x" --prefer-source - - cd web; drush en -y islandora + - cd web; drush --uri=127.0.0.1:8282 en -y islandora script: - $SCRIPT_DIR/line_endings.sh $TRAVIS_BUILD_DIR - phpcs --standard=Drupal --ignore=*.md --extensions=php,module,inc,install,test,profile,theme,css,info $TRAVIS_BUILD_DIR - phpcpd --names *.module,*.inc,*.test,*.php $TRAVIS_BUILD_DIR - - php core/scripts/run-tests.sh --verbose --php `which php` islandora + - php core/scripts/run-tests.sh --url http://127.0.0.1:8282 --verbose --php `which php` --module "islandora" notifications: irc: diff --git a/islandora.routing.yml b/islandora.routing.yml index a5e27da7..e264e339 100644 --- a/islandora.routing.yml +++ b/islandora.routing.yml @@ -1,17 +1,4 @@ -# Islandora Routing definition -entity.fedora_resource_type.rdftest: - path: '/fedora_resource/{fedora_resource}/rdf' - defaults: - _controller: '\Drupal\node\Controller\NodePreviewController::view' - _title_callback: '\Drupal\node\Controller\NodePreviewController::title' - requirements: - _node_preview_access: '{node_preview}' - options: - parameters: - node_preview: - type: 'node_preview' - -# Menu list of Islandora configuration forms +# Menu list of Islandora configuration forms system.admin_config_islandora: path: '/admin/config/islandora' defaults: @@ -28,3 +15,15 @@ system.islandora_settings: _title: 'Islandora Settings' requirements: _permission: 'administer site configuration' + +# Islandora JSON-LD Routing definition +entity.fedora_resource_type.jsonldcontext: + path: '/fedora_resource_context/{bundle}' + defaults: + _controller: '\Drupal\islandora\Controller\FedoraResourceJsonLdContextController::content' + requirements: + _permission: 'access content' + options: + parameters: + bundle: + type: entity:{fedora_resource}:{fedora_resource_type} diff --git a/islandora.services.yml b/islandora.services.yml index 44eead48..64e8753d 100644 --- a/islandora.services.yml +++ b/islandora.services.yml @@ -21,3 +21,9 @@ services: islandora.versioncounter: class: Drupal\islandora\VersionCounter\VersionCounter arguments: ['@database'] + logger.channel.islandora: + parent: logger.channel_base + arguments: ['islandora'] + islandora.jsonldcontextgenerator: + class: Drupal\islandora\JsonldContextGenerator\JsonldContextGenerator + arguments: ['@entity_field.manager','@entity_type.bundle.info','@entity_type.manager', '@cache.default', '@logger.channel.islandora'] diff --git a/src/Controller/FedoraResourceJsonLdContextController.php b/src/Controller/FedoraResourceJsonLdContextController.php new file mode 100644 index 00000000..3a4b5474 --- /dev/null +++ b/src/Controller/FedoraResourceJsonLdContextController.php @@ -0,0 +1,88 @@ +jsonldContextGenerator = $jsonld_context_generator; + } + + /** + * Controller's create method for dependecy injection. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * The App Container. + * + * @return static + * An instance of our islandora.jsonldcontextgenerator service. + */ + public static function create(ContainerInterface $container) { + return new static($container->get('islandora.jsonldcontextgenerator')); + } + + /** + * Returns an JSON-LD Context for a fedora_resource bundle. + * + * @param string $bundle + * Route argument, a bundle. + * @param \Symfony\Component\HttpFoundation\Request $request + * The Symfony Http Request. + * + * @return \Symfony\Component\HttpFoundation\Response + * An Http response. + */ + public function content($bundle, Request $request) { + + // TODO: expose cached/not cached through + // more varied HTTP response codes. + try { + $context = $this->jsonldContextGenerator->getContext('fedora_resource.' . $bundle); + $response = new CacheableJsonResponse(json_decode($context), 200); + $response->setEncodingOptions(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK); + $response->headers->set('X-Powered-By', 'Islandora CLAW API'); + $response->headers->set('Content-Type', 'application/ld+json'); + + // For now deal with Cache dependencies manually. + $meta = new CacheableMetadata(); + $meta->setCacheContexts(['user.permissions', 'ip', 'url']); + $meta->setCacheTags(RdfMapping::load('fedora_resource.' . $bundle)->getCacheTags()); + $meta->setCacheMaxAge(Cache::PERMANENT); + $response->addCacheableDependency($meta); + } + catch (\Exception $e) { + $response = new Response($e->getMessage(), 400); + } + + return $response; + } + +} diff --git a/src/JsonldContextGenerator/JsonldContextGenerator.php b/src/JsonldContextGenerator/JsonldContextGenerator.php new file mode 100644 index 00000000..e9161df2 --- /dev/null +++ b/src/JsonldContextGenerator/JsonldContextGenerator.php @@ -0,0 +1,424 @@ +entityFieldManager = $entity_field_manager; + $this->entityTypeManager = $entity_manager; + $this->bundleInfo = $bundle_info; + $this->cache = $cache_backend; + $this->logger = $logger_channel; + } + + /** + * {@inheritdoc} + */ + public function getContext($ids = 'fedora_resource.rdf_source') { + $cid = JsonldContextGenerator::CACHE_BASE_CID . $ids; + $cache = $this->cache->get($cid); + $data = ''; + if (!$cache) { + $rdfMapping = RdfMapping::load($ids); + // Our whole chain of exceptions will never happen + // because RdfMapping:load returns NULL on non existance + // Which forces me to check for it + // and don't even call writeCache on missing + // Solution, throw also one here. + if ($rdfMapping) { + $data = $this->writeCache($rdfMapping, $cid); + } + else { + $msg = t("Can't generate JSON-LD Context for @ids without RDF Mapping present.", + ['@ids' => $ids]); + $this->logger->warning("@msg", + [ + '@msg' => $msg, + ]); + throw new \Exception($msg); + } + } + else { + $data = $cache->data; + } + return $data; + } + + /** + * {@inheritdoc} + */ + public function generateContext(RdfMappingInterface $rdfMapping) { + // TODO: we will need to use \Drupal\Core\Field\FieldDefinitionInterface + // a lot to be able to create/frame/discern drupal bundles based on JSON-LD + // So keep an eye on that definition. + $allRdfNameSpaces = rdf_get_namespaces(); + + // This one will become our return value. + $jsonLdContextArray['@context'] = []; + + // Temporary array to keep track of our used namespaces and props. + $theAccumulator = []; + + $bundle_rdf_mappings = $rdfMapping->getPreparedBundleMapping(); + $drupal_types = $this->entityBundleIdsSplitter($rdfMapping->id()); + $entity_type_id = $drupal_types['entityTypeId']; + $bundle = $drupal_types['bundleId']; + // If we don't have rdf:type(s) for this bundle then it makes little + // sense to continue. + // This only generates an Exception if there is an + // rdfmapping object but has no rdf:type. + if (empty($bundle_rdf_mappings['types'])) { + $msg = t("Can't generate JSON-LD Context without at least one rdf:type for Entity type @entity_type, Bundle @bundle_name combo.", + ['@entity_type' => $entity_type_id, ' @bundle_name' => $bundle]); + $this->logger->warning("@msg", + [ + '@msg' => $msg, + ]); + throw new \Exception($msg); + } + + /* We have a lot of assumptions here (rdf module is strange) + a) xsd and other utility namespaces are in place + b) the user knows what/how rdf mapping works and does it right + c) that if a field's mapping_type is "rel" or "rev" and datatype is + not defined, then '@type' is uncertain. + d) that mapping back and forward is 1 to 1. + Drupal allows multiple fields to be mapped to a same rdf prop + but that does not scale back. If drupal gets an input with a list + of values for a given property, we would never know in which Drupal + fields we should put those values. it's the many to one, + one to many reduction problem made worst by the abstraction of + fields being containers of mappings and not rdf properties. */ + // Only care for those mappings that point to bundled or base fields. + // First our bundled fields. + foreach ($this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle) as $bundleFieldName => $fieldDefinition) { + $field_context = $this->getFieldsRdf($rdfMapping, $bundleFieldName, $fieldDefinition, $allRdfNameSpaces); + $theAccumulator = array_merge($field_context, $theAccumulator); + } + // And then our Base fields. + foreach ($this->entityFieldManager->getBaseFieldDefinitions($entity_type_id) as $baseFieldName => $fieldDefinition) { + $field_context = $this->getFieldsRdf($rdfMapping, $baseFieldName, $fieldDefinition, $allRdfNameSpaces); + $theAccumulator = array_merge($field_context, $theAccumulator); + } + $theAccumulator = array_filter($theAccumulator); + $jsonLdContextArray['@context'] = $theAccumulator; + return json_encode($jsonLdContextArray, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * Gets the correct piece of @context for a given entity field. + * + * @param \Drupal\rdf\RdfMappingInterface $rdfMapping + * Rdf mapping object. + * @param string $field_name + * The name of the field. + * @param \Drupal\Core\Field\FieldDefinitionInterface $fieldDefinition + * The definition of the field. + * @param array $allRdfNameSpaces + * Every RDF prefixed namespace in this Drupal. + * + * @return array + * Piece of JSON-LD context that supports this field + */ + private function getFieldsRdf(RdfMappingInterface $rdfMapping, $field_name, FieldDefinitionInterface $fieldDefinition, array $allRdfNameSpaces) { + $termDefinition = []; + $fieldContextFragment = []; + $fieldRDFMapping = $rdfMapping->getPreparedFieldMapping($field_name); + if (!empty($fieldRDFMapping)) { + // If one ore more properties, all will share same datatype so + // get that before iterating. + // First get our defaults, no-user or config based input. + $default_field_term_mapping = $this->getTermContextFromField($fieldDefinition->getType()); + + // Now we start overriding from config entity defined mappings. + // Assume all non defined mapping types as "property". + $reltype = isset($fieldRDFMapping['mapping_type']) ? $fieldRDFMapping['mapping_type'] : 'property'; + + if (isset($fieldRDFMapping['datatype']) && ($reltype == 'property')) { + $termDefinition = ['@type' => $fieldRDFMapping['datatype']]; + } + if (!isset($fieldRDFMapping['datatype']) && ($reltype != 'property')) { + $termDefinition = ['@type' => '@id']; + } + + // This should respect user provided mapping and fill rest with defaults. + $termDefinition = $termDefinition + $default_field_term_mapping; + + // Now iterate over all properties for this field + // trying to parse them as compact IRI. + foreach ($fieldRDFMapping['properties'] as $property) { + $compactedDefinition = $this->parseCompactedIri($property); + if ($compactedDefinition['prefix'] != NULL) { + // Check if the namespace prefix exists. + if (array_key_exists($compactedDefinition['prefix'], $allRdfNameSpaces)) { + // Just overwrite as many times as needed, + // still faster than checking if + // it's there in the first place. + $fieldContextFragment[$compactedDefinition['prefix']] = $allRdfNameSpaces[$compactedDefinition['prefix']]; + $fieldContextFragment[$property] = $termDefinition; + } + } + } + } + + return $fieldContextFragment; + } + + /** + * Writes JSON-LD @context cache per Entity_type bundle combo. + * + * @param \Drupal\rdf\RdfMappingInterface $rdfMapping + * Rdf mapping object. + * @param string $cid + * Name of the cache bin to use. + * + * @return string + * A json encoded string for the processed JSON-LD @context + */ + protected function writeCache(RdfMappingInterface $rdfMapping, $cid) { + + // This is how an empty json encoded @context looks like. + $data = json_encode(['@context' => ''], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + try { + $data = $this->generateContext($rdfMapping); + $this->cache->set($cid, $data, Cache::PERMANENT, $rdfMapping->getCacheTagsToInvalidate()); + } + catch (\Exception $e) { + $this->logger->warning("@msg", + [ + '@msg' => $e->getMessage(), + ]); + } + + return $data; + } + + /** + * Absurdly simple exploder for a joint entityType and Bundle ids string. + * + * @param string $ids + * A string with containing entity id and bundle joined by a dot. + * + * @return array + * And array with the entity type and the bundle id + */ + protected function entityBundleIdsSplitter($ids) { + list($entity_type_id, $bundle_id) = explode(".", $ids, 2); + return ['entityTypeId' => $entity_type_id, 'bundleId' => $bundle_id]; + } + + /** + * Parses and IRI, checks if it is complaint with compacted IRI definition. + * + * Assumes this notion of compact IRI/similar to CURIE + * http://json-ld.org/spec/ED/json-ld-syntax/20120522/#dfn-prefix. + * + * @param string $iri + * IRIs are strings. + * + * @return array + * If $iri is a compacted iri, prefix and term as separate + * array members, if not, unmodified $iri in term position + * and null prefix. + */ + protected function parseCompactedIri($iri) { + // As naive as it gets. + list($prefix, $rest) = array_pad(explode(":", $iri, 2), 2, ''); + if ((substr($rest, 0, 2) == "//") || ($prefix == $iri)) { + // Means this was never a compacted IRI. + return ['prefix' => NULL, 'term' => $iri]; + } + return ['prefix' => $prefix, 'term' => $rest]; + } + + /** + * Naive approach on Drupal field to JSON-LD type mapping. + * + * TODO: Would be fine to have this definitions in an + * configEntity way in the future. + * + * @param string $field_type + * As provided by \Drupal\Core\Field\FieldDefinitionInterface::getType(). + * + * @return array + * A json-ld term definition if there is a match + * or array("@type" => "xsd:string") in case of no match. + */ + protected function getTermContextFromField($field_type) { + // Be aware that drupal field definitions can be complex. + // e.g text_with_summary has a text, a summary, a number of lines, etc + // we are only dealing with the resulting ->value() of all this separate + // pieces and mapping only that as a whole. + // Default mapping to return in case no $field_type matches + // field_mappings array keys. + $default_mapping = [ + "@type" => "xsd:string", + ]; + + $field_mappings = [ + "comment" => [ + "@type" => "xsd:string", + ], + "datetime" => [ + "@type" => "xsd:dateTime", + ], + "file" => [ + "@type" => "@id", + ], + "image" => [ + "@type" => "@id", + ], + "link" => [ + "@type" => "xsd:anyURI", + ], + "list_float" => [ + "@type" => "xsd:float", + "@container" => "@list", + ], + "list_integer" => [ + "@type" => "xsd:int", + "@container" => "@list", + ], + "list_string" => [ + "@type" => "xsd:string", + "@container" => "@list", + ], + "path" => [ + "@type" => "xsd:anyURI", + ], + "text" => [ + "@type" => "xsd:string", + ], + "text_with_summary" => [ + "@type" => "xsd:string", + ], + "text_long" => [ + "@type" => "xsd:string", + ], + "uuid" => [ + "@type" => "xsd:string", + ], + "uri" => [ + "@type" => "xsd:anyURI", + ], + "language" => [ + "@type" => "xsd:language", + ], + "string_long" => [ + "@type" => "xsd:string", + ], + "changed" => [ + "@type" => "xsd:dateTime", + ], + "map" => "xsd:", + "boolean" => [ + "@type" => "xsd:boolean", + ], + "email" => [ + "@type" => "xsd:string", + ], + "integer" => [ + "@type" => "xsd:int", + ], + "decimal" => [ + "@type" => "xsd:decimal", + ], + "created" => [ + "@type" => "xsd:dateTime", + ], + "float" => [ + "@type" => "xsd:float", + ], + "entity_reference" => [ + "@type" => "@id", + ], + "timestamp" => [ + "@type" => "xsd:dateTime", + ], + "string" => [ + "@type" => "xsd:string", + ], + "password" => [ + "@type" => "xsd:string", + ], + ]; + + return array_key_exists($field_type, $field_mappings) ? $field_mappings[$field_type] : $default_mapping; + + } + +} diff --git a/src/JsonldContextGenerator/JsonldContextGeneratorInterface.php b/src/JsonldContextGenerator/JsonldContextGeneratorInterface.php new file mode 100644 index 00000000..90e22768 --- /dev/null +++ b/src/JsonldContextGenerator/JsonldContextGeneratorInterface.php @@ -0,0 +1,44 @@ +user = $this->drupalCreateUser([ + 'administer site configuration', + 'view published fedora resource entities', + 'access content', + ] + ); + // Login. + $this->drupalLogin($this->user); + } + + /** + * Tests that the Context Response Page can be reached. + */ + public function testJsonldcontextPageExists() { + $url = Url::fromRoute('entity.fedora_resource_type.jsonldcontext', ['bundle' => 'rdf_source']); + $this->drupalGet($url); + $this->assertResponse(200); + } + + /** + * Tests that the response is in fact application/ld+json. + */ + public function testJsonldcontextContentypeheaderResponseIsValid() { + $url = Url::fromRoute('entity.fedora_resource_type.jsonldcontext', ['bundle' => 'rdf_source']); + $this->drupalGet($url); + $this->assertEqual($this->drupalGetHeader('Content-Type'), 'application/ld+json', 'Correct JSON-LD mime type was returned'); + } + + /** + * Tests that the Context received has the basic structural needs. + */ + public function testJsonldcontextResponseIsValid() { + $url = Url::fromRoute('entity.fedora_resource_type.jsonldcontext', ['bundle' => 'rdf_source']); + $this->drupalGet($url); + $jsonldarray = json_decode($this->getRawContent(), TRUE); + // Check if the only key is "@context". + $this->assertTrue(count(array_keys($jsonldarray)) == 1 && (key($jsonldarray) == '@context'), "JSON-LD to array encoded response has just one key and that key is @context"); + } + +} diff --git a/tests/src/Kernel/FedoraContentTypeCreationTrait.php b/tests/src/Kernel/FedoraContentTypeCreationTrait.php new file mode 100644 index 00000000..dc549808 --- /dev/null +++ b/tests/src/Kernel/FedoraContentTypeCreationTrait.php @@ -0,0 +1,43 @@ + 'some_bundle'. + * + * @return \Drupal\islandora\Entity\FedoraResourceType + * Created content type. + */ + protected function createFedoraResourceContentType(array $values = []) { + // Find a non-existent random type name. + $random = new Random(); + if (!isset($values['type'])) { + do { + $id = strtolower($random->string(8)); + } while (FedoraResourceType::load($id)); + } + else { + $id = $values['type']; + } + $values += [ + 'id' => $id, + 'label' => $id, + ]; + $type = FedoraResourceType::create($values); + $type->save(); + return $type; + } + +} diff --git a/tests/src/Kernel/JsonldContextGeneratorTest.php b/tests/src/Kernel/JsonldContextGeneratorTest.php new file mode 100644 index 00000000..911bef2a --- /dev/null +++ b/tests/src/Kernel/JsonldContextGeneratorTest.php @@ -0,0 +1,132 @@ + ['schema:dateCreated'], + 'datatype' => 'xsd:dateTime', + 'datatype_callback' => ['callable' => 'Drupal\rdf\CommonDataConverter::dateIso8601Value'], + ]; + + // Save bundle mapping config. + $rdfMapping = rdf_get_mapping('entity_test', 'rdf_source') + ->setBundleMapping(['types' => $types]) + ->setFieldMapping('created', $mapping) + ->save(); + // Initialize our generator. + $this->theJsonldContextGenerator = new JsonldContextGenerator( + $this->container->get('entity_field.manager'), + $this->container->get('entity_type.bundle.info'), + $this->container->get('entity_type.manager'), + $this->container->get('cache.default'), + $this->container->get('logger.channel.islandora') + ); + + } + + /** + * @covers \Drupal\islandora\JsonldContextGenerator\JsonldContextGenerator::getContext + */ + public function testGetContext() { + // Test with known asserts. + $context = $this->theJsonldContextGenerator->getContext('entity_test.rdf_source'); + $context_as_array = json_decode($context, TRUE); + $this->assertTrue(is_array($context_as_array), 'JSON-LD Context generated has correct structure for known Bundle'); + + $this->assertTrue(strpos($context, '"schema": "http://schema.org/"') !== FALSE, "JSON-LD Context generated contains the expected values for known Bundle"); + + } + + /** + * Tests Exception in case of no rdf type. + * + * @expectedException \Exception + * @covers \Drupal\islandora\JsonldContextGenerator\JsonldContextGenerator::getContext + */ + public function testGetContextException() { + // This should throw the expected Exception. + $newFedoraEntity = $this->drupalCreateFedoraContentType(); + $this->theJsonldContextGenerator->getContext('fedora_resource.' . $newFedoraEntity->id()); + + } + + /** + * @covers \Drupal\islandora\JsonldContextGenerator\JsonldContextGenerator::generateContext + */ + public function testGenerateContext() { + // Test with known asserts. + $rdfMapping = rdf_get_mapping('entity_test', 'rdf_source'); + $context = $this->theJsonldContextGenerator->generateContext($rdfMapping); + $context_as_array = json_decode($context, TRUE); + $this->assertTrue(is_array($context_as_array), 'JSON-LD Context generated has correct structure for known Bundle'); + + $this->assertTrue(strpos($context, '"schema": "http://schema.org/"') !== FALSE, "JSON-LD Context generated contains the expected values for known Bundle"); + + } + + /** + * Tests Exception in case of no rdf type. + * + * @expectedException \Exception + * @covers \Drupal\islandora\JsonldContextGenerator\JsonldContextGenerator::generateContext + */ + public function testGenerateContextException() { + // This should throw the expected Exception. + $newFedoraEntity = $this->drupalCreateFedoraContentType(); + $rdfMapping = rdf_get_mapping('fedora_resource', $newFedoraEntity->id()); + $this->theJsonldContextGenerator->getContext('fedora_resource.' . $newFedoraEntity->id()); + + } + +}