From aea7ca9127e544526050de487b2a476e40126f31 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sun, 19 Jul 2026 15:31:41 +0200 Subject: [PATCH 1/5] Add per-subject RDF export endpoint Fixes https://github.com/ProfessionalWiki/NeoWiki/issues/1087 Adds GET /neowiki/v0/subject/{subjectId}/rdf, serving one Subject's outbound bounded description as RDF: exactly the triples the page export emits for that Subject, including the native relation reification, with none of the page-metadata triples, in the hosting page's per-projection named graph. It mirrors the page endpoint's `format` (trig/turtle) and `projection` (native/edm/...) parameters and their negotiation. Mechanism: - PageProjector gains projectSubject( Page, SubjectId ): QuadList, implemented by both the native and ontology-mapping projectors by reusing their existing per-Subject internals (the former private projectSubject helpers, renamed to subjectQuads). An absent Subject, or one whose projection yields nothing (native: Schema unavailable; ontology: no Mapping for the target), yields an empty list. - RdfSubjectExporter resolves the Subject to its hosting page via the Neo4j PageIdentifiersLookup (the same dependency the subject JSON endpoint already carries), authorizes the page, loads it, and confirms the Subject is still in the current revision before projecting. A Subject that is unknown, has no readable or loadable hosting page, or is gone from the slot all return one byte-identical 404 (anti-oracle parity, cf. #1046). - ExportSubjectRdfApi validates the Subject ID shape first (400 on malformed, never a 500) and rejects an unknown projection (400) before resolving the Subject, so neither can be used to probe for restricted Subjects. - The format negotiation shared with the page endpoint moves into a new RdfFormatNegotiation trait, keeping the two endpoints byte-for-byte consistent. Docs: the rest-api.md endpoint table and the rdf-export.md endpoint section. Co-Authored-By: Claude Opus 4.8 --- docs/api/rest-api.md | 1 + docs/rdf/rdf-export.md | 33 ++- extension.json | 5 + .../Rdf/OntologyMappingProjector.php | 29 +- src/Application/Rdf/PageProjector.php | 11 + src/Application/Rdf/RdfPageProjector.php | 30 +- src/Application/Rdf/RdfSubjectExporter.php | 57 ++++ src/EntryPoints/REST/ExportPageRdfApi.php | 44 +-- src/EntryPoints/REST/ExportSubjectRdfApi.php | 98 +++++++ src/EntryPoints/REST/RdfFormatNegotiation.php | 64 +++++ src/NeoWikiExtension.php | 16 ++ .../Rdf/OntologyMappingProjectorTest.php | 86 +++++- .../Application/Rdf/RdfPageProjectorTest.php | 143 ++++++++++ .../Rdf/RdfSubjectExporterTest.php | 138 +++++++++ .../REST/ExportSubjectRdfApiTest.php | 262 ++++++++++++++++++ .../TestDoubles/FixedPageProjector.php | 5 + 16 files changed, 964 insertions(+), 58 deletions(-) create mode 100644 src/Application/Rdf/RdfSubjectExporter.php create mode 100644 src/EntryPoints/REST/ExportSubjectRdfApi.php create mode 100644 src/EntryPoints/REST/RdfFormatNegotiation.php create mode 100644 tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php create mode 100644 tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index 2645b346..c3c19971 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -48,6 +48,7 @@ Read, change, and validate Subjects. New Subjects are created on a page — see | Endpoint | Description | |---|---| | `GET /neowiki/v0/subject/{subjectId}` | Fetch a Subject. Optional `revisionId`; `expand` with `page` or `relations`. | +| `GET /neowiki/v0/subject/{subjectId}/rdf` | Export one Subject as RDF. `format` is `trig` (default) or `turtle`; `projection` is `native` (default) or an ontology target. See [RDF export](../rdf/rdf-export.md). | | `PUT /neowiki/v0/subject/{subjectId}` | Replace a Subject's label and statements. | | `DELETE /neowiki/v0/subject/{subjectId}` | Delete a Subject. | | `POST /neowiki/v0/subject/validate` | Check whether a new Subject is valid, without saving it. | diff --git a/docs/rdf/rdf-export.md b/docs/rdf/rdf-export.md index 8ca1ff0a..0fe44792 100644 --- a/docs/rdf/rdf-export.md +++ b/docs/rdf/rdf-export.md @@ -64,26 +64,43 @@ describe the same set of entities. A warning is logged for each omitted Subject. ## Endpoint -`GET /rest.php/neowiki/v0/page/{pageId}/rdf` - -Returns the page's projection. The `projection` query parameter selects the vocabulary: `native` (the -default, described here) or an ontology target declared by a Mapping page — see -[Ontology Mapping](ontology-mapping.md). An unknown target returns `400`. - -The format is chosen by the `format` query parameter, falling back to the `Accept` header, then to -TriG: +RDF is served per page or per Subject. Both take the same `projection` and `format` query parameters. +The `projection` selects the vocabulary: `native` (the default, described here) or an ontology target +declared by a Mapping page — see [Ontology Mapping](ontology-mapping.md); an unknown target returns +`400`. The `format` picks the serialization, falling back to the `Accept` header, then to TriG: | `format` | `Accept` | Content-Type | Named graph | |---|---|---|---| | `trig` (default) | `application/trig` | `application/trig; charset=utf-8` | yes | | `turtle` | `text/turtle` | `text/turtle; charset=utf-8` | no (same triples, no graph wrapper) | +### Page + +`GET /rest.php/neowiki/v0/page/{pageId}/rdf` + +Returns the page's projection: its page metadata and every Subject on it, in the page's named graph. Returns `404` when the page does not exist or has no NeoWiki Subject data. ```sh curl 'https://wiki.example/rest.php/neowiki/v0/page/42/rdf?format=turtle' ``` +### Subject + +`GET /rest.php/neowiki/v0/subject/{subjectId}/rdf` + +Returns one Subject's projection: exactly the triples the page export emits for that Subject — its +outbound description, including the native relation reification — with none of the page-metadata +triples, in the hosting page's named graph. Inbound relations pointing at the Subject from elsewhere +are not included. + +Returns `404` when the Subject does not exist, is no longer on its hosting page, or is on a page the +caller may not read — the three are indistinguishable. A malformed Subject ID returns `400`. + +```sh +curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=edm' +``` + ## Bulk dump `maintenance/DumpRdf.php` streams the projection of **every** subject page to stdout as TriG, one named diff --git a/extension.json b/extension.json index 1ed63587..ecccf4cc 100644 --- a/extension.json +++ b/extension.json @@ -254,6 +254,11 @@ "method": [ "DELETE" ], "factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newDeleteSubjectApi" }, + { + "path": "/neowiki/v0/subject/{subjectId}/rdf", + "method": [ "GET" ], + "factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newExportSubjectRdfApi" + }, { "path": "/neowiki/v0/schema/{schemaName}", "method": [ "GET" ], diff --git a/src/Application/Rdf/OntologyMappingProjector.php b/src/Application/Rdf/OntologyMappingProjector.php index df56ea4b..67f970ef 100644 --- a/src/Application/Rdf/OntologyMappingProjector.php +++ b/src/Application/Rdf/OntologyMappingProjector.php @@ -18,6 +18,7 @@ use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfValueMapperRegistry; use ProfessionalWiki\NeoWiki\Domain\Statement; use ProfessionalWiki\NeoWiki\Domain\Subject\Subject; +use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue; use Psr\Log\LoggerInterface; @@ -109,17 +110,41 @@ public function projectPage( Page $page ): QuadList { $mapping = $this->mappingsBySchema[$subject->getSchemaName()->getText()] ?? null; if ( $mapping !== null ) { - $quads = array_merge( $quads, $this->projectSubject( $subject, $mapping, $graph ) ); + $quads = array_merge( $quads, $this->subjectQuads( $subject, $mapping, $graph ) ); } } return QuadList::fromArray( $quads ); } + /** + * Projects a single Subject on the page into the target ontology — its per-Subject block from + * {@see projectPage()} (mapped type, label, mapped property values, relations as direct triples) + * placed in the target's named graph. A Subject that is not on the page, or whose Schema has no + * Mapping for this target, yields an empty list. + */ + public function projectSubject( Page $page, SubjectId $subjectId ): QuadList { + $subject = $page->getSubjects()->getAllSubjects()->getSubject( $subjectId ); + + if ( $subject === null ) { + return new QuadList(); + } + + $mapping = $this->mappingsBySchema[$subject->getSchemaName()->getText()] ?? null; + + if ( $mapping === null ) { + return new QuadList(); + } + + return QuadList::fromArray( + $this->subjectQuads( $subject, $mapping, $this->namespaces->graph( $this->target, $page->getId() ) ) + ); + } + /** * @return Quad[] */ - private function projectSubject( Subject $subject, Mapping $mapping, Iri $graph ): array { + private function subjectQuads( Subject $subject, Mapping $mapping, Iri $graph ): array { $expander = new CurieExpander( $mapping->prefixes ); $subjectIri = $this->namespaces->subject( $subject->id ); diff --git a/src/Application/Rdf/PageProjector.php b/src/Application/Rdf/PageProjector.php index d0e2ed11..1b5405ff 100644 --- a/src/Application/Rdf/PageProjector.php +++ b/src/Application/Rdf/PageProjector.php @@ -6,6 +6,7 @@ use ProfessionalWiki\NeoWiki\Domain\Page\Page; use ProfessionalWiki\NeoWiki\Domain\Rdf\QuadList; +use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; /** * Projects a {@see Page} to RDF quads in some target vocabulary. The native projection @@ -17,4 +18,14 @@ interface PageProjector { public function projectPage( Page $page ): QuadList; + /** + * Projects the single Subject $subjectId hosted on $page: exactly the quads {@see projectPage()} + * emits for that Subject (its outbound bounded description, including native relation reification), + * with none of the page-metadata quads. The page is passed for context — the graph IRI derives from + * its id and the Schema/Mapping is resolved as in a full-page projection. Returns an empty list when + * the Subject is absent from the page or its projection yields no quads (native: the Schema is + * unavailable; ontology: no Mapping targets it). + */ + public function projectSubject( Page $page, SubjectId $subjectId ): QuadList; + } diff --git a/src/Application/Rdf/RdfPageProjector.php b/src/Application/Rdf/RdfPageProjector.php index 45a19530..c295ec8b 100644 --- a/src/Application/Rdf/RdfPageProjector.php +++ b/src/Application/Rdf/RdfPageProjector.php @@ -21,6 +21,7 @@ use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; use ProfessionalWiki\NeoWiki\Domain\Statement; use ProfessionalWiki\NeoWiki\Domain\Subject\Subject; +use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; use Psr\Log\LoggerInterface; /** @@ -68,12 +69,37 @@ public function projectPage( Page $page ): QuadList { $quads = $this->projectPageMetadata( $page, $subjects, $graph ); foreach ( $subjects as [ $subject, $schema ] ) { - $quads = array_merge( $quads, $this->projectSubject( $subject, $schema, $graph, $page->getId() ) ); + $quads = array_merge( $quads, $this->subjectQuads( $subject, $schema, $graph, $page->getId() ) ); } return QuadList::fromArray( $quads ); } + /** + * Projects a single Subject on the page — its per-Subject block from {@see projectPage()} (type, + * label, Statements, reified Relations) placed in the page's native named graph, without any + * page-metadata quads. A Subject that is not on the page, or whose Schema is unavailable (logged, + * as in the full-page projection), yields an empty list. + */ + public function projectSubject( Page $page, SubjectId $subjectId ): QuadList { + $subject = $page->getSubjects()->getAllSubjects()->getSubject( $subjectId ); + + if ( $subject === null ) { + return new QuadList(); + } + + $schema = $this->schemaLookup->getSchema( $subject->getSchemaName() ); + + if ( $schema === null ) { + $this->logger->warning( 'Schema not found: ' . $subject->getSchemaName()->getText() ); + return new QuadList(); + } + + return QuadList::fromArray( + $this->subjectQuads( $subject, $schema, $this->namespaces->graph( self::PROJECTION, $page->getId() ), $page->getId() ) + ); + } + /** * Resolves each Subject's Schema up front. A Subject whose Schema is unavailable is dropped from * the projection entirely — matching Neo4jSubjectUpdater, which skips the whole Subject — so page @@ -168,7 +194,7 @@ private function isProjected( Subject $mainSubject, array $subjects ): bool { /** * @return Quad[] */ - private function projectSubject( Subject $subject, Schema $schema, Iri $graph, PageId $pageId ): array { + private function subjectQuads( Subject $subject, Schema $schema, Iri $graph, PageId $pageId ): array { $subjectIri = $this->namespaces->subject( $subject->id ); $quads = [ diff --git a/src/Application/Rdf/RdfSubjectExporter.php b/src/Application/Rdf/RdfSubjectExporter.php new file mode 100644 index 00000000..8a9624c8 --- /dev/null +++ b/src/Application/Rdf/RdfSubjectExporter.php @@ -0,0 +1,57 @@ +pageIdentifiersLookup->getPageIdOfSubject( $subjectId ); + + if ( $pageIdentifiers === null ) { + return null; + } + + if ( !$this->readAuthorizer->authorizeReadByPageId( $pageIdentifiers->getId() ) ) { + return null; + } + + $page = $this->loader->loadByPageId( $pageIdentifiers->getId() ); + + if ( $page === null || $page->getSubjects()->getAllSubjects()->getSubject( $subjectId ) === null ) { + return null; + } + + return $this->serializer->serialize( $this->projector->projectSubject( $page, $subjectId ), $format ); + } + +} diff --git a/src/EntryPoints/REST/ExportPageRdfApi.php b/src/EntryPoints/REST/ExportPageRdfApi.php index dc510ad8..5132b2af 100644 --- a/src/EntryPoints/REST/ExportPageRdfApi.php +++ b/src/EntryPoints/REST/ExportPageRdfApi.php @@ -6,10 +6,8 @@ use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; -use MediaWiki\Rest\StringStream; use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector; use ProfessionalWiki\NeoWiki\Domain\Page\PageId; -use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfFormat; use ProfessionalWiki\NeoWiki\NeoWikiExtension; use Wikimedia\ParamValidator\ParamValidator; @@ -22,11 +20,7 @@ */ class ExportPageRdfApi extends SimpleHandler { - private const string FORMAT_TRIG = 'trig'; - private const string FORMAT_TURTLE = 'turtle'; - - private const string CONTENT_TYPE_TRIG = 'application/trig; charset=utf-8'; - private const string CONTENT_TYPE_TURTLE = 'text/turtle; charset=utf-8'; + use RdfFormatNegotiation; public function run( int $pageId ): Response { $extension = NeoWikiExtension::getInstance(); @@ -68,42 +62,6 @@ private function noDataResponse( int $pageId ): Response { ] ); } - private function resolveFormat(): RdfFormat { - $requested = $this->getValidatedParams()['format'] ?? null; - - if ( $requested === self::FORMAT_TURTLE ) { - return RdfFormat::Turtle; - } - - if ( $requested === self::FORMAT_TRIG ) { - return RdfFormat::TriG; - } - - return $this->formatFromAcceptHeader(); - } - - private function formatFromAcceptHeader(): RdfFormat { - $accept = $this->getRequest()->getHeaderLine( 'Accept' ); - - // TriG is a superset of Turtle, so only pick Turtle when the client asks for it specifically. - if ( str_contains( $accept, 'text/turtle' ) && !str_contains( $accept, 'application/trig' ) ) { - return RdfFormat::Turtle; - } - - return RdfFormat::TriG; - } - - private function rdfResponse( string $document, RdfFormat $format ): Response { - $response = $this->getResponseFactory()->create(); - $response->setHeader( - 'Content-Type', - $format === RdfFormat::Turtle ? self::CONTENT_TYPE_TURTLE : self::CONTENT_TYPE_TRIG - ); - $response->setBody( new StringStream( $document ) ); - - return $response; - } - public function getParamSettings(): array { return [ 'pageId' => [ diff --git a/src/EntryPoints/REST/ExportSubjectRdfApi.php b/src/EntryPoints/REST/ExportSubjectRdfApi.php new file mode 100644 index 00000000..f9cf1fff --- /dev/null +++ b/src/EntryPoints/REST/ExportSubjectRdfApi.php @@ -0,0 +1,98 @@ +getResponseFactory()->createHttpError( 400, [ + 'message' => 'Invalid Subject ID: ' . $subjectId, + ] ); + } + + $extension = NeoWikiExtension::getInstance(); + $projectionName = $this->getValidatedParams()['projection'] ?? RdfPageProjector::PROJECTION; + $resolution = $extension->resolveRdfProjection( $projectionName ); + + // The projection check runs before the Subject is resolved and before the read gate, so a + // caller who cannot read the Subject's page sees the same 400 as anyone else and cannot use it + // to probe existence or readability. + if ( $resolution->projection === null ) { + return $this->getResponseFactory()->createHttpError( 400, [ + 'message' => 'Unknown RDF projection: "' . $projectionName . '". Known projections: ' + . implode( ', ', $resolution->knownProjectionNames ) . '.', + ] ); + } + + $format = $this->resolveFormat(); + + $document = $extension + ->newRdfSubjectExporterForProjection( $resolution->projection, $this->getAuthority() ) + ->exportBySubjectId( new SubjectId( $subjectId ), $format ); + + if ( $document === null ) { + return $this->noDataResponse( $subjectId ); + } + + return $this->rdfResponse( $document, $format ); + } + + private function noDataResponse( string $subjectId ): Response { + return $this->getResponseFactory()->createHttpError( 404, [ + 'message' => 'No NeoWiki data found for subject: ' . $subjectId, + ] ); + } + + public function getParamSettings(): array { + return [ + 'subjectId' => [ + self::PARAM_SOURCE => 'path', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => true, + self::PARAM_DESCRIPTION => 'Persistent identifier of the Subject. 15 characters, starting with "s".', + ], + 'format' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => [ + self::FORMAT_TRIG, + self::FORMAT_TURTLE, + ], + ParamValidator::PARAM_REQUIRED => false, + self::PARAM_DESCRIPTION => 'RDF serialization to return: "trig" (default, includes the hosting page\'s named graph) or "turtle". Overrides the Accept header.', + ], + 'projection' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + self::PARAM_DESCRIPTION => 'RDF projection to produce: "native" (default) for NeoWiki-native vocabulary, or an ontology target declared by a Mapping page (e.g. "edm"). An unknown target returns 400.', + ], + ]; + } + +} diff --git a/src/EntryPoints/REST/RdfFormatNegotiation.php b/src/EntryPoints/REST/RdfFormatNegotiation.php new file mode 100644 index 00000000..cf43a468 --- /dev/null +++ b/src/EntryPoints/REST/RdfFormatNegotiation.php @@ -0,0 +1,64 @@ +getValidatedParams()['format'] ?? null; + + if ( $requested === self::FORMAT_TURTLE ) { + return RdfFormat::Turtle; + } + + if ( $requested === self::FORMAT_TRIG ) { + return RdfFormat::TriG; + } + + return $this->formatFromAcceptHeader(); + } + + private function formatFromAcceptHeader(): RdfFormat { + $accept = $this->getRequest()->getHeaderLine( 'Accept' ); + + // TriG is a superset of Turtle, so only pick Turtle when the client asks for it specifically. + if ( str_contains( $accept, 'text/turtle' ) && !str_contains( $accept, 'application/trig' ) ) { + return RdfFormat::Turtle; + } + + return RdfFormat::TriG; + } + + private function rdfResponse( string $document, RdfFormat $format ): Response { + $response = $this->getResponseFactory()->create(); + $response->setHeader( + 'Content-Type', + $format === RdfFormat::Turtle ? self::CONTENT_TYPE_TURTLE : self::CONTENT_TYPE_TRIG + ); + $response->setBody( new StringStream( $document ) ); + + return $response; + } + +} diff --git a/src/NeoWikiExtension.php b/src/NeoWikiExtension.php index 0273895b..3786c3d7 100644 --- a/src/NeoWikiExtension.php +++ b/src/NeoWikiExtension.php @@ -68,12 +68,14 @@ use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector; use ProfessionalWiki\NeoWiki\Application\Rdf\RdfProjection; use ProfessionalWiki\NeoWiki\Application\Rdf\RdfProjectionResolution; +use ProfessionalWiki\NeoWiki\Application\Rdf\RdfSubjectExporter; use ProfessionalWiki\NeoWiki\Domain\Mapping\CurieExpander; use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfNamespaces; use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfSerializer; use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfValueMapperRegistry; use ProfessionalWiki\NeoWiki\Infrastructure\Rdf\HardfRdfSerializer; use ProfessionalWiki\NeoWiki\EntryPoints\REST\ExportPageRdfApi; +use ProfessionalWiki\NeoWiki\EntryPoints\REST\ExportSubjectRdfApi; use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jWriteQueryEngine; use ProfessionalWiki\NeoWiki\Domain\PropertyType\PropertyTypeLookup; use ProfessionalWiki\NeoWiki\Domain\PropertyType\PropertyTypeRegistry; @@ -328,6 +330,16 @@ public function newRdfPageExporterForProjection( RdfProjection $projection ): Rd ); } + public function newRdfSubjectExporterForProjection( RdfProjection $projection, Authority $authority ): RdfSubjectExporter { + return new RdfSubjectExporter( + $this->getPageIdentifiersLookup(), + $this->newRdfPageLoader(), + $projection->projector, + $projection->serializer, + $this->newPageReadAuthorizer( $authority ), + ); + } + /** * The projection for a name, or null when the name is neither "native" nor a target any Mapping page * declares. The seam the SPARQL store plugin (#586) consumes for its own store (it needs only @@ -438,6 +450,10 @@ public static function newExportPageRdfApi(): ExportPageRdfApi { return new ExportPageRdfApi(); } + public static function newExportSubjectRdfApi(): ExportSubjectRdfApi { + return new ExportSubjectRdfApi(); + } + /** * Propagating fan-out over every backend, used by the maintenance rebuild path so failures surface. */ diff --git a/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php b/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php index 6faeedba..4997670b 100644 --- a/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php +++ b/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php @@ -41,6 +41,7 @@ class OntologyMappingProjectorTest extends TestCase { private const string PERSON_ID = 's1janeaaaaaaaa2'; private const string CITY_ID = 's1cityaaaaaaaa3'; + private const string GHOST_ID = 's1ghostaaaaaaa9'; private const string EDM = 'http://www.europeana.eu/schemas/edm/'; private const string DC = 'http://purl.org/dc/elements/1.1/'; @@ -111,7 +112,11 @@ private function expectedTriG(): string { } private function examplePage(): Page { - $person = TestSubject::build( + return TestPage::build( id: 42, mainSubject: $this->examplePerson(), childSubjects: new SubjectMap( $this->exampleCity() ) ); + } + + private function examplePerson(): Subject { + return TestSubject::build( id: self::PERSON_ID, label: 'Jane', schemaName: new SchemaName( 'Person' ), @@ -123,8 +128,10 @@ private function examplePage(): Page { TestStatement::buildRelation( 'BornIn', [ TestRelation::build( targetId: self::CITY_ID ) ] ), ] ) ); + } - $city = TestSubject::build( + private function exampleCity(): Subject { + return TestSubject::build( id: self::CITY_ID, label: 'Berlin', schemaName: new SchemaName( 'City' ), @@ -132,8 +139,81 @@ private function examplePage(): Page { TestStatement::build( 'Name', new StringValue( 'Berlin' ), 'text' ), ] ) ); + } + + /** + * The per-Subject export projects exactly the requested Subject's mapped block from a full-page + * projection, in the target's named graph, and nothing else. The target Person sits between two + * siblings; its BornIn relation points at the City sibling, whose IRI appears as the dc:spatial + * object but whose own mapped block (edm:Place, …) must not — so a "project every Subject" + * regression fails even though the City has a Mapping of its own. + */ + public function testProjectSubjectEmitsOnlyTheTargetSubjectsMappedBlockInTheTargetGraph(): void { + $quads = $this->newProjector( [ $this->personMapping(), $this->cityMapping() ] ) + ->projectSubject( $this->pagePersonBetweenSiblings(), new SubjectId( self::PERSON_ID ) ); + + $output = ( new HardfRdfSerializer( $this->serializerPrefixes() ) )->serialize( $quads, RdfFormat::TriG ); + + $this->assertSame( + ParsedRdf::canonicalQuads( $this->personOnlyTriG() ), + ParsedRdf::canonicalQuads( $output ) + ); + $this->logger->assertNoLoggingCallsWhereMade(); + } + + public function testProjectSubjectForASubjectWhoseSchemaHasNoMappingForTheTargetReturnsNothing(): void { + // Only Person is mapped; the requested Subject uses the unmapped Ghost Schema. + $quads = $this->newProjector( [ $this->personMapping() ] ) + ->projectSubject( $this->pagePersonBetweenSiblings(), new SubjectId( self::GHOST_ID ) ); + + $this->assertTrue( $quads->isEmpty() ); + $this->logger->assertNoLoggingCallsWhereMade(); + } + + public function testProjectSubjectForASubjectNotOnThePageReturnsNothing(): void { + $quads = $this->newProjector( [ $this->personMapping() ] ) + ->projectSubject( TestPage::build( id: 42, mainSubject: $this->examplePerson() ), new SubjectId( self::CITY_ID ) ); - return TestPage::build( id: 42, mainSubject: $person, childSubjects: new SubjectMap( $city ) ); + $this->assertTrue( $quads->isEmpty() ); + $this->logger->assertNoLoggingCallsWhereMade(); + } + + private function pagePersonBetweenSiblings(): Page { + return TestPage::build( + id: 42, + mainSubject: $this->exampleCity(), + childSubjects: new SubjectMap( $this->examplePerson(), $this->ghostSubject() ) + ); + } + + private function ghostSubject(): Subject { + return TestSubject::build( + id: self::GHOST_ID, + label: 'Unmapped', + schemaName: new SchemaName( 'Ghost' ), + statements: new StatementList( [ TestStatement::build( 'Name', new StringValue( 'Unmapped' ), 'text' ) ] ) + ); + } + + private function personOnlyTriG(): string { + return << . + @prefix neo-graph: . + @prefix rdf: . + @prefix rdfs: . + @prefix xsd: . + @prefix edm: . + @prefix dc: . + + neo-graph:42 { + neo-subj:s1janeaaaaaaaa2 a edm:ProvidedCHO ; + rdfs:label "Jane" ; + dc:title "Jane"@en ; + edm:isShownAt "https://jane.example"^^xsd:anyURI ; + dc:date "1990"^^edm:year ; + dc:spatial neo-subj:s1cityaaaaaaaa3 . + } + TRIG; } private function personMapping(): Mapping { diff --git a/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php b/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php index 88c9c29c..c8221eac 100644 --- a/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php +++ b/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php @@ -20,6 +20,7 @@ use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; +use ProfessionalWiki\NeoWiki\Domain\Subject\Subject; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectMap; use ProfessionalWiki\NeoWiki\Domain\Value\NumberValue; @@ -44,6 +45,7 @@ class RdfPageProjectorTest extends TestCase { private const string ACME_ID = 's1acmeaaaaaaaa1'; private const string JANE_ID = 's1janeaaaaaaaa2'; private const string CEO_RELATION_ID = 'r1ceoaaaaaaaaa3'; + private const string AFTER_ID = 's1afteraaaaaaa9'; private RdfNamespaces $ns; private LegacyLoggerSpy $logger; @@ -288,6 +290,147 @@ public function testDroppingAnInvalidDatePartLogsAWarning(): void { ); } + /** + * The per-Subject export projects exactly the requested Subject's block from a full-page projection — + * its type, label, Statements and reified Relation — and nothing else: no page-metadata quads and + * none of the sibling Subjects' quads, even though a sibling (Jane) is the Relation target whose IRI + * does appear. The target sits between two siblings so a "project every Subject" regression fails. + */ + public function testProjectSubjectEmitsOnlyTheTargetSubjectsBlockIncludingRelationReification(): void { + $schemaLookup = new InMemorySchemaLookup( + $this->companySchema(), + TestSchema::build( name: 'Person', properties: new PropertyDefinitions( [] ) ), + ); + + $quads = $this->newProjector( $schemaLookup ) + ->projectSubject( $this->pageWithAcmeBetweenSiblings(), new SubjectId( self::ACME_ID ) ); + + $this->assertSame( + ParsedRdf::canonicalQuads( $this->acmeSubjectTriG() ), + ParsedRdf::canonicalQuads( $this->serialize( $quads ) ) + ); + $this->logger->assertNoLoggingCallsWhereMade(); + } + + public function testProjectSubjectDerivesTheNamedGraphFromTheHostingPageId(): void { + $quads = $this->newProjector( new InMemorySchemaLookup( $this->companySchema() ) ) + ->projectSubject( TestPage::build( id: 77, mainSubject: $this->acmeWithCeo() ), new SubjectId( self::ACME_ID ) ); + + $this->assertSame( + [ 'https://wiki.example/graph/native/page/77' ], + $this->distinctGraphs( $quads ), + 'Every quad belongs to the hosting page\'s native named graph, derived from its id.' + ); + } + + public function testProjectSubjectForASubjectNotOnThePageReturnsNothing(): void { + $quads = $this->newProjector( new InMemorySchemaLookup( $this->companySchema() ) ) + ->projectSubject( TestPage::build( id: 42, mainSubject: $this->acmeWithCeo() ), new SubjectId( self::AFTER_ID ) ); + + $this->assertTrue( $quads->isEmpty() ); + $this->logger->assertNoLoggingCallsWhereMade(); + } + + public function testProjectSubjectForASubjectWithAnUnavailableSchemaReturnsNothingAndLogs(): void { + $page = TestPage::build( id: 42, mainSubject: $this->acmeWithCeo() ); + + // Only Person is registered, so the requested Company Subject's Schema is unavailable and it is + // skipped — the same graceful degradation as the full-page projection. + $quads = $this->newProjector( new InMemorySchemaLookup( + TestSchema::build( name: 'Person', properties: new PropertyDefinitions( [] ) ) + ) )->projectSubject( $page, new SubjectId( self::ACME_ID ) ); + + $this->assertTrue( $quads->isEmpty() ); + $this->assertSame( + [ 'Schema not found: Company' ], + $this->logger->getLogCalls()->getMessages() + ); + } + + private function acmeWithCeo(): Subject { + return TestSubject::build( + id: self::ACME_ID, + label: 'ACME Corp', + schemaName: new SchemaName( 'Company' ), + statements: new StatementList( [ + TestStatement::build( 'Founded', new NumberValue( 2019 ), 'number' ), + TestStatement::buildRelation( 'CEO', [ + TestRelation::build( + id: self::CEO_RELATION_ID, + targetId: self::JANE_ID, + properties: [ 'Since' => 2022 ], + ), + ] ), + ] ) + ); + } + + private function pageWithAcmeBetweenSiblings(): Page { + $jane = TestSubject::build( + id: self::JANE_ID, + label: 'Jane Smith', + schemaName: new SchemaName( 'Person' ), + statements: new StatementList( [ + TestStatement::build( 'Age', new NumberValue( 45 ), 'number' ), + ] ) + ); + $after = TestSubject::build( + id: self::AFTER_ID, + label: 'Later Co', + schemaName: new SchemaName( 'Company' ), + statements: new StatementList( [ + TestStatement::build( 'Founded', new NumberValue( 2000 ), 'number' ), + ] ) + ); + + return TestPage::build( + id: 42, + properties: TestPageProperties::build( + title: 'ACME Corp', + creationTime: '20240301090000', + modificationTime: '20251115164500', + lastEditor: 'Admin', + ), + mainSubject: $jane, + childSubjects: new SubjectMap( $this->acmeWithCeo(), $after ), + ); + } + + private function acmeSubjectTriG(): string { + return << . + @prefix neo-subj: . + @prefix neo-prop: . + @prefix neo-schema: . + @prefix neo-rel: . + @prefix neo-graph: . + @prefix rdfs: . + + neo-graph:42 { + neo-subj:s1acmeaaaaaaaa1 a neo-schema:Company ; + rdfs:label "ACME Corp" ; + neo-prop:Founded 2019 ; + neo-prop:CEO neo-subj:s1janeaaaaaaaa2 . + + neo-rel:r1ceoaaaaaaaaa3 a neo:Relation ; + neo:source neo-subj:s1acmeaaaaaaaa1 ; + neo:target neo-subj:s1janeaaaaaaaa2 ; + neo:relationType neo-prop:CEO ; + neo-prop:Since 2022 . + } + TRIG; + } + + /** + * @return list + */ + private function distinctGraphs( QuadList $quads ): array { + return array_values( array_unique( array_map( + static fn ( Quad $quad ): string => $quad->graph->value, + $quads->asArray() + ) ) ); + } + /** * A user-authored Property or Schema name must never break out of its IRI: illegal characters * are escaped so the document still parses to exactly the intended quads. The confirmed repro diff --git a/tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php b/tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php new file mode 100644 index 00000000..633110c3 --- /dev/null +++ b/tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php @@ -0,0 +1,138 @@ +exporter( + lookup: new InMemoryPageIdentifiersLookup(), + loadedPage: $this->pageWithTheSubject(), + authorized: true, + ); + + $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) ); + } + + public function testReturnsNullWhenTheHostingPageIsNotReadable(): void { + $exporter = $this->exporter( + lookup: $this->lookupResolvingTheSubject(), + loadedPage: $this->pageWithTheSubject(), + authorized: false, + ); + + $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) ); + } + + public function testReturnsNullWhenTheHostingPageCannotBeLoaded(): void { + $exporter = $this->exporter( + lookup: $this->lookupResolvingTheSubject(), + loadedPage: null, + authorized: true, + ); + + $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) ); + } + + public function testReturnsNullWhenTheGraphPointsToAPageThatNoLongerHasTheSubject(): void { + // Stale graph: it resolves the Subject to a page whose current revision no longer carries it. + $exporter = $this->exporter( + lookup: $this->lookupResolvingTheSubject(), + loadedPage: TestPage::build( id: 42, mainSubject: TestSubject::build( id: self::OTHER_ID ) ), + authorized: true, + ); + + $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) ); + } + + public function testSerializesTheProjectedSubjectWhenItIsPresentAndReadable(): void { + $quads = new QuadList( + new Quad( + new Iri( 'https://wiki.example/entity/' . self::SUBJECT_ID ), + new Iri( 'http://www.w3.org/2000/01/rdf-schema#label' ), + RdfLiteralFactory::typed( 'ACME Corp', 'string' ), + new Iri( 'https://wiki.example/graph/native/page/42' ), + ) + ); + + $document = $this->exporter( + lookup: $this->lookupResolvingTheSubject(), + loadedPage: $this->pageWithTheSubject(), + authorized: true, + projector: new FixedPageProjector( $quads ), + )->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ); + + $this->assertSame( + ( new HardfRdfSerializer( [] ) )->serialize( $quads, RdfFormat::TriG ), + $document, + 'The document is the projected Subject serialized in the requested format.' + ); + } + + private function exporter( + InMemoryPageIdentifiersLookup $lookup, + ?Page $loadedPage, + bool $authorized, + ?PageProjector $projector = null + ): RdfSubjectExporter { + return new RdfSubjectExporter( + $lookup, + $this->fixedLoader( $loadedPage ), + $projector ?? new FixedPageProjector( new QuadList() ), + new HardfRdfSerializer( [] ), + new StubPageReadAuthorizer( $authorized ), + ); + } + + private function lookupResolvingTheSubject(): InMemoryPageIdentifiersLookup { + return new InMemoryPageIdentifiersLookup( [ + [ new SubjectId( self::SUBJECT_ID ), new PageIdentifiers( new PageId( 42 ), 'ACME Corp', 0 ) ], + ] ); + } + + private function pageWithTheSubject(): Page { + return TestPage::build( id: 42, mainSubject: TestSubject::build( id: self::SUBJECT_ID ) ); + } + + private function fixedLoader( ?Page $page ): RdfPageLoader { + return new class( $page ) extends RdfPageLoader { + + public function __construct( private readonly ?Page $page ) { + } + + public function loadByPageId( PageId $pageId ): ?Page { + return $this->page; + } + + }; + } + +} diff --git a/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php new file mode 100644 index 00000000..bf8ef495 --- /dev/null +++ b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php @@ -0,0 +1,262 @@ +setUpNeo4j(); + + $this->createSchema( + self::SCHEMA, + <<pageId = $this->createPageWithSubjects( + 'ExportSubjectRdfApiTest_Cities', + mainSubject: TestSubject::build( + id: self::BERLIN_ID, + label: new SubjectLabel( 'Berlin' ), + schemaName: new SchemaName( self::SCHEMA ), + statements: new StatementList( [ + TestStatement::build( 'population', '3700000' ), + ] ) + ), + childSubjects: new SubjectMap( + TestSubject::build( + id: self::PARIS_ID, + label: new SubjectLabel( 'Paris' ), + schemaName: new SchemaName( self::SCHEMA ), + statements: new StatementList( [ + TestStatement::build( 'population', '2100000' ), + ] ) + ) + ), + )->getPage()->getId(); + } + + /** + * @param array $query + * @param array $headers + */ + private function export( array $query = [], array $headers = [], ?string $subjectId = null, ?Authority $authority = null ): Response { + return $this->executeHandler( + new ExportSubjectRdfApi(), + new RequestData( [ + 'method' => 'GET', + 'pathParams' => [ 'subjectId' => $subjectId ?? self::BERLIN_ID ], + 'queryParams' => $query, + 'headers' => $headers, + ] ), + authority: $authority + ); + } + + public function testDefaultsToTriGWithTheHostingPagesNamedGraph(): void { + $response = $this->export(); + $body = $response->getBody()->getContents(); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertSame( 'application/trig; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) ); + $this->assertStringContainsString( 'Berlin', $body ); + $this->assertStringEndsWith( + '/graph/native/page/' . $this->pageId, + $this->soleGraphIn( $body ), + 'Every TriG quad belongs to the hosting page\'s named graph.' + ); + } + + public function testFormatParameterSelectsTurtleWithoutANamedGraph(): void { + $response = $this->export( query: [ 'format' => 'turtle' ] ); + $body = $response->getBody()->getContents(); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertSame( 'text/turtle; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) ); + $this->assertStringContainsString( 'Berlin', $body ); + $this->assertSame( [ '' ], $this->graphsIn( $body ), 'Turtle drops the named graph.' ); + } + + public function testFormatParameterOverridesTheAcceptHeader(): void { + $response = $this->export( query: [ 'format' => 'trig' ], headers: [ 'Accept' => 'text/turtle' ] ); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertSame( 'application/trig; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) ); + } + + public function testAcceptHeaderSelectsTurtle(): void { + $response = $this->export( headers: [ 'Accept' => 'text/turtle' ] ); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertSame( 'text/turtle; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) ); + $this->assertSame( [ '' ], $this->graphsIn( $response->getBody()->getContents() ) ); + } + + public function testAcceptHeaderSelectsTriG(): void { + $response = $this->export( headers: [ 'Accept' => 'application/trig' ] ); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertSame( 'application/trig; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) ); + } + + public function testReturns404ForAnUnknownSubject(): void { + $response = $this->export( subjectId: self::ABSENT_ID ); + + $this->assertSame( 404, $response->getStatusCode() ); + $this->assertStringContainsString( + 'No NeoWiki data found for subject: ' . self::ABSENT_ID, + $response->getBody()->getContents() + ); + } + + public function testSubjectOnAnUnreadablePageIsByteIdenticalToAnAbsentSubject(): void { + // An existing Subject whose hosting page the caller cannot read must answer exactly like an + // absent Subject, so the endpoint cannot confirm a harvested Subject id exists (#1046). Only the + // Subject id echoed in the message differs between the two. + $denied = $this->export( authority: $this->authorityWithGlobalReadButNoPageRead() ); + $absent = $this->export( subjectId: self::ABSENT_ID ); + + $this->assertSame( 404, $denied->getStatusCode() ); + $this->assertSame( $absent->getStatusCode(), $denied->getStatusCode() ); + $this->assertSame( + $absent->getHeaderLine( 'Content-Type' ), + $denied->getHeaderLine( 'Content-Type' ) + ); + $this->assertSame( + str_replace( self::ABSENT_ID, self::BERLIN_ID, $absent->getBody()->getContents() ), + $denied->getBody()->getContents() + ); + } + + public function testUnknownProjectionReturns400EvenForAnAbsentSubject(): void { + // The projection check runs before the Subject is resolved, so a caller cannot use a valid vs + // invalid projection to probe whether a Subject exists. + $response = $this->export( + query: [ 'projection' => 'no-such-projection' ], + subjectId: self::ABSENT_ID + ); + + $this->assertSame( 400, $response->getStatusCode() ); + } + + public function testReturns400ForAMalformedSubjectId(): void { + $response = $this->export( subjectId: 'not-a-valid-id' ); + + $this->assertSame( 400, $response->getStatusCode() ); + } + + public function testOmittedProjectionEqualsExplicitNative(): void { + $default = $this->export(); + $explicit = $this->export( query: [ 'projection' => 'native' ] ); + + $this->assertSame( 200, $explicit->getStatusCode() ); + $this->assertSame( + ParsedRdf::canonicalQuads( $default->getBody()->getContents() ), + ParsedRdf::canonicalQuads( $explicit->getBody()->getContents() ) + ); + } + + public function testValidTargetProjectsTheMappedVocabularyWithNativeSubjectIris(): void { + $this->createBerlinToEdmMapping(); + + $response = $this->export( query: [ 'projection' => 'edm', 'format' => 'turtle' ] ); + $body = $response->getBody()->getContents(); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertStringContainsString( 'europeana.eu/schemas/edm', $body, 'The mapped ontology vocabulary is used.' ); + $this->assertStringContainsString( self::BERLIN_ID, $body, 'The Subject IRI stays native.' ); + $this->assertStringNotContainsString( self::SCHEMA, $body, 'No native schema class is emitted.' ); + } + + public function testReturnsOnlyTheRequestedSubjectsTriples(): void { + $body = $this->export()->getBody()->getContents(); + + $this->assertStringContainsString( 'Berlin', $body, 'The requested Subject is present.' ); + $this->assertStringNotContainsString( 'Paris', $body, 'A sibling Subject on the same page is not included.' ); + $this->assertStringNotContainsString( self::PARIS_ID, $body, 'A sibling Subject IRI is not included.' ); + } + + private function createBerlinToEdmMapping(): void { + $this->createMapping( 'ExportSubjectRdfApiTestBerlinToEdm', <<schemaName()}", + "target": "edm", + "prefixes": { + "edm": "http://www.europeana.eu/schemas/edm/", + "dc": "http://purl.org/dc/elements/1.1/" + }, + "subject": { "class": "edm:Place" }, + "properties": { + "population": { "predicate": "dc:description" } + } + } + JSON ); + } + + private function schemaName(): string { + return self::SCHEMA; + } + + /** + * The single named graph every quad in the document belongs to. + */ + private function soleGraphIn( string $rdf ): string { + $graphs = $this->graphsIn( $rdf ); + + $this->assertCount( 1, $graphs, 'A per-Subject projection places every quad in exactly one named graph.' ); + + return $graphs[0]; + } + + /** + * The graph value (the fourth field of each canonical quad) of every quad in the parsed document. + * Distinguishes TriG (a non-empty page graph on every quad) from Turtle (the empty default graph). + * + * @return list + */ + private function graphsIn( string $rdf ): array { + return array_values( array_unique( array_map( + static fn ( string $line ): string => explode( "\t", $line )[3], + ParsedRdf::canonicalQuads( $rdf ) + ) ) ); + } + +} diff --git a/tests/phpunit/TestDoubles/FixedPageProjector.php b/tests/phpunit/TestDoubles/FixedPageProjector.php index 090e30dc..245d24da 100644 --- a/tests/phpunit/TestDoubles/FixedPageProjector.php +++ b/tests/phpunit/TestDoubles/FixedPageProjector.php @@ -7,6 +7,7 @@ use ProfessionalWiki\NeoWiki\Application\Rdf\PageProjector; use ProfessionalWiki\NeoWiki\Domain\Page\Page; use ProfessionalWiki\NeoWiki\Domain\Rdf\QuadList; +use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; /** * A {@see PageProjector} that returns a preset {@see QuadList} regardless of the page, so tests can @@ -23,4 +24,8 @@ public function projectPage( Page $page ): QuadList { return $this->quads; } + public function projectSubject( Page $page, SubjectId $subjectId ): QuadList { + return $this->quads; + } + } From 585444d4d6647b73e4b4fb81048d4f0e0de3150d Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sun, 19 Jul 2026 15:48:03 +0200 Subject: [PATCH 2/5] Cover the empty-projection case and tighten the exporter docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refinements from the PR review of the per-subject RDF export endpoint: - Add an integration test proving a readable Subject whose Schema has no mapping for the requested ontology target returns a 200 empty graph, not a 404 — a readable Subject is never hidden. Documents that contract in rdf-export.md. - Reword the RdfSubjectExporter docstring: the page endpoint keeps its read gate in the REST handler, not in RdfPageExporter. Co-Authored-By: Claude Opus 4.8 --- docs/rdf/rdf-export.md | 4 ++- src/Application/Rdf/RdfSubjectExporter.php | 8 +++--- .../REST/ExportSubjectRdfApiTest.php | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/rdf/rdf-export.md b/docs/rdf/rdf-export.md index 0fe44792..6c7bc856 100644 --- a/docs/rdf/rdf-export.md +++ b/docs/rdf/rdf-export.md @@ -95,7 +95,9 @@ triples, in the hosting page's named graph. Inbound relations pointing at the Su are not included. Returns `404` when the Subject does not exist, is no longer on its hosting page, or is on a page the -caller may not read — the three are indistinguishable. A malformed Subject ID returns `400`. +caller may not read — the three are indistinguishable. A malformed Subject ID returns `400`. A +readable Subject whose Schema has no mapping for the requested ontology target projects to an empty +graph — a `200`, not a `404`. ```sh curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=edm' diff --git a/src/Application/Rdf/RdfSubjectExporter.php b/src/Application/Rdf/RdfSubjectExporter.php index 8a9624c8..85184d1d 100644 --- a/src/Application/Rdf/RdfSubjectExporter.php +++ b/src/Application/Rdf/RdfSubjectExporter.php @@ -18,10 +18,10 @@ * gone or carries no Subject slot, the current revision no longer holds it (a graph lagging the slot), * or the hosting page is not readable. * - * The read gate lives here, not in the handler as for {@see RdfPageExporter} (whose page id is a path - * param known up front): a Subject's hosting page is only known after the graph resolves it. Folding - * the gate in keeps every not-found reason byte identical (cf. #1046) and mirrors GetSubjectQuery, - * which authorizes the resolved page the same way. + * The read gate lives here rather than in the REST handler, where the page export keeps it (its page + * id is a path parameter known up front): a Subject's hosting page is only known after the graph + * resolves it. Folding the gate in keeps every not-found reason byte identical (cf. #1046) and mirrors + * GetSubjectQuery, which authorizes the resolved page the same way. */ readonly class RdfSubjectExporter { diff --git a/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php index bf8ef495..f1cc1417 100644 --- a/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php +++ b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php @@ -31,6 +31,7 @@ class ExportSubjectRdfApiTest extends NeoWikiIntegrationTestCase { private const string SCHEMA = 'ExportSubjectRdfApiTestSchema'; private const string BERLIN_ID = 'sTestSRA1111111'; private const string PARIS_ID = 'sTestSRA2222222'; + private const string UNMAPPED_ID = 'sTestSRA3333333'; private const string ABSENT_ID = 'sTestSRA9999999'; private int $pageId; @@ -205,6 +206,32 @@ public function testValidTargetProjectsTheMappedVocabularyWithNativeSubjectIris( $this->assertStringNotContainsString( self::SCHEMA, $body, 'No native schema class is emitted.' ); } + public function testReadableSubjectWithoutAMappingForTheTargetReturnsAnEmptyGraph(): void { + // "edm" is a known projection because Berlin's Schema is mapped to it, but this Subject's Schema + // is not, so its ontology projection is empty. A readable Subject is never hidden behind a 404, + // so the response is a 200 empty graph rather than a not-found. + $this->createBerlinToEdmMapping(); + + $this->createSchema( 'ExportSubjectRdfApiTestUnmappedSchema' ); + $this->createPageWithSubjects( + 'ExportSubjectRdfApiTest_Unmapped', + mainSubject: TestSubject::build( + id: self::UNMAPPED_ID, + label: new SubjectLabel( 'Freetown' ), + schemaName: new SchemaName( 'ExportSubjectRdfApiTestUnmappedSchema' ) + ) + ); + + $response = $this->export( query: [ 'projection' => 'edm', 'format' => 'turtle' ], subjectId: self::UNMAPPED_ID ); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertSame( + [], + ParsedRdf::canonicalQuads( $response->getBody()->getContents() ), + 'A readable Subject with no Mapping for the target projects to an empty graph, not a 404.' + ); + } + public function testReturnsOnlyTheRequestedSubjectsTriples(): void { $body = $this->export()->getBody()->getContents(); From dabb56904bcea6c6d91c0d76a807222c1d849bc9 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sun, 19 Jul 2026 20:35:14 +0200 Subject: [PATCH 3/5] Test the subject endpoint's read-filtered projection list The subject RDF endpoint adopted #1086's filterReadableProjectionNames() for the unknown-projection 400 during the master merge, but no subject-endpoint test covered it. Add one mirroring the page endpoint: a read-restricted Mapping page name must not leak into the 400 list (#1046), while "native" stays listed. Co-Authored-By: Claude Opus 4.8 --- .../REST/ExportSubjectRdfApiTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php index ed082439..f6014fa2 100644 --- a/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php +++ b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php @@ -183,6 +183,25 @@ public function testReturns400ForAMalformedSubjectId(): void { $this->assertSame( 400, $response->getStatusCode() ); } + public function testReadRestrictedMappingNamesAreAbsentFromTheKnownProjectionsList(): void { + // The unknown-projection 400 list must not leak the titles of Mapping pages the caller cannot + // read (#1046). A caller who can read the page sees "EDM"; one who cannot must not — though + // "native" (not a page) stays listed for everyone. + $this->createEdmMapping(); + + $readable = $this->export( query: [ 'projection' => 'bogus' ] ); + $restricted = $this->export( + query: [ 'projection' => 'bogus' ], + authority: $this->authorityWithGlobalReadButNoPageRead() + ); + + $this->assertStringContainsString( 'EDM', $readable->getBody()->getContents() ); + + $restrictedBody = $restricted->getBody()->getContents(); + $this->assertStringNotContainsString( 'EDM', $restrictedBody, 'A read-restricted Mapping page name must not leak into the 400 list.' ); + $this->assertStringContainsString( 'native', $restrictedBody, 'The always-available native projection stays listed.' ); + } + public function testOmittedProjectionEqualsExplicitNative(): void { $default = $this->export(); $explicit = $this->export( query: [ 'projection' => 'native' ] ); From 077ebe376a33120c4fa66db58ca71985479073e1 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sun, 19 Jul 2026 20:39:37 +0200 Subject: [PATCH 4/5] Describe the subject RDF 404 in consumer-observable terms The graph-lagging-the-slot case reads as plain absence from outside; naming it required internal knowledge and diluted the absent-vs-denied contract point. Co-Authored-By: Claude Fable 5 --- docs/rdf/rdf-export.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/rdf/rdf-export.md b/docs/rdf/rdf-export.md index 599bca3e..752f2510 100644 --- a/docs/rdf/rdf-export.md +++ b/docs/rdf/rdf-export.md @@ -94,10 +94,9 @@ outbound description, including the native relation reification — with none of triples, in the hosting page's named graph. Inbound relations pointing at the Subject from elsewhere are not included. -Returns `404` when the Subject does not exist, is no longer on its hosting page, or is on a page the -caller may not read — the three are indistinguishable. A malformed Subject ID returns `400`. A -readable Subject whose Schema has no mapping for the requested ontology target projects to an empty -graph — a `200`, not a `404`. +Returns `404` when the Subject does not exist or is on a page the caller may not read — the two are +indistinguishable. A malformed Subject ID returns `400`. A readable Subject whose Schema has no mapping +for the requested ontology target projects to an empty graph — a `200`, not a `404`. ```sh curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=EDM' From f5c5edba2cbeac9780117fad4904b30c913ec731 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Mon, 20 Jul 2026 00:31:52 +0200 Subject: [PATCH 5/5] Surface data access in the UI: Data tab export links and RDF autodiscovery (#1097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surface data access in the UI: Data tab export links and RDF autodiscovery Fixes https://github.com/ProfessionalWiki/NeoWiki/issues/1095 Stacked on #1090, whose per-subject RDF endpoint these menus link to; based on feature/subject-rdf-export so the diff is only this work. GitHub retargets to master once #1090 merges. The REST API served a page's RDF, its Subjects as JSON, a Subject as JSON, and (via #1090) a Subject's RDF, in native or ontology-mapped projections — none of it reachable from the UI. This adds three things: - A per-subject export menu in each expanded Data tab row, beside the ID: JSON, then Turtle and TriG for each readable projection. - A page-level export menu in the Data tab header: page-subjects JSON, then per-projection Turtle/TriG of the page RDF. The two menus share one labelling scheme so they read as one system. - `` autodiscovery tags (Turtle + TriG, native projection) in the head of pages that carry NeoWiki data. The menus are driven by the projections the viewing user may read. SubjectsAction exposes that list as the `wgNeoWikiRdfProjections` JS config var, filtered server-side by filterReadableProjectionNames() so a restricted Mapping page title never reaches a reader who cannot see it. A later per-wiki choice to hide a projection then becomes a server-side filter with no UI change. Menu item URLs carry explicit `format=` parameters and percent-encode the projection name; each opens in a new tab so the Data tab state is not lost (Codex 1.14 menu-item anchors cannot set a target, so navigation happens on selection). Autodiscovery links are emitted only when the page has Subjects, so the advertised endpoint never 404s. Tests: Vitest covers the URL/label derivation (ordering, native-vs-mapping labels, projection encoding). PHP integration covers the config var and its permission filtering, and the head tags being present on a page with Subjects and absent on one without. Co-Authored-By: Claude Fable 5 * Assert RDF autodiscovery hrefs are absolute The head-tag test matched only the path substring, so a regression dropping the PROTO_CANONICAL expansion to a relative href would have shipped silently. Assert the hrefs are absolute so the canonical-URL behavior is locked. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/rdf/rdf-export.md | 7 ++ extension.json | 7 +- i18n/en.json | 5 ++ i18n/qqq.json | 5 ++ .../SubjectsManager/SubjectsManagerPage.vue | 87 ++++++++++++++++--- .../src/presentation/DataExportMenu.ts | 67 ++++++++++++++ .../tests/presentation/DataExportMenu.spec.ts | 67 ++++++++++++++ src/EntryPoints/Actions/SubjectsAction.php | 9 +- src/EntryPoints/NeoWikiHooks.php | 31 +++++++ .../Actions/SubjectsActionTest.php | 50 ++++++++++- .../EntryPoints/RdfAutodiscoveryLinksTest.php | 83 ++++++++++++++++++ 11 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 resources/ext.neowiki/src/presentation/DataExportMenu.ts create mode 100644 resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts create mode 100644 tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php diff --git a/docs/rdf/rdf-export.md b/docs/rdf/rdf-export.md index 752f2510..241c4792 100644 --- a/docs/rdf/rdf-export.md +++ b/docs/rdf/rdf-export.md @@ -102,6 +102,13 @@ for the requested ontology target projects to an empty graph — a `200`, not a curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=EDM' ``` +### Finding these exports + +These exports are surfaced in the UI: the Data tab (`?action=subjects`) links to each Subject's JSON and +per-projection Turtle/TriG, and to the same for the whole page. Pages that carry NeoWiki data also +advertise the page export through `` autodiscovery tags (Turtle and TriG, native +projection) in the HTML head, so Linked Data tooling can locate the data without reading this reference. + ## Bulk dump `maintenance/DumpRdf.php` streams the projection of **every** subject page to stdout as TriG, one named diff --git a/extension.json b/extension.json index ecccf4cc..f4d84457 100644 --- a/extension.json +++ b/extension.json @@ -591,7 +591,12 @@ "neowiki-managesubjects-id-label", "neowiki-managesubjects-id-copy", "neowiki-managesubjects-id-copied", - "neowiki-managesubjects-id-copy-error" + "neowiki-managesubjects-id-copy-error", + "neowiki-managesubjects-export-button", + "neowiki-managesubjects-export-json", + "neowiki-managesubjects-export-native", + "neowiki-managesubjects-export-turtle", + "neowiki-managesubjects-export-trig" ], "@group:": "TODO: Load code separately while in development. Remove later.", "group": "ext.neowiki" diff --git a/i18n/en.json b/i18n/en.json index 4edfb6f3..99e03600 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -305,5 +305,10 @@ "neowiki-managesubjects-id-copy": "Copy subject ID $1", "neowiki-managesubjects-id-copied": "Copied \"$1\" to clipboard.", "neowiki-managesubjects-id-copy-error": "Couldn't copy the subject ID.", + "neowiki-managesubjects-export-button": "Export", + "neowiki-managesubjects-export-json": "JSON", + "neowiki-managesubjects-export-native": "Native", + "neowiki-managesubjects-export-turtle": "$1 · Turtle", + "neowiki-managesubjects-export-trig": "$1 · TriG", "action-subjects": "manage subjects on this page" } diff --git a/i18n/qqq.json b/i18n/qqq.json index 8dcbe646..2feccb17 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -236,5 +236,10 @@ "neowiki-managesubjects-id-copy": "Tooltip on the subject ID button. $1 is the Subject ID. Click copies the ID to the clipboard.", "neowiki-managesubjects-id-copied": "Notification shown after the subject ID was copied. $1 is the Subject ID.", "neowiki-managesubjects-id-copy-error": "Notification shown when copying the subject ID to the clipboard failed.", + "neowiki-managesubjects-export-button": "Label and accessible name of the button that opens the data export menu on the Data tab (per Subject, and for the whole page in the header).", + "neowiki-managesubjects-export-json": "Export menu item that downloads the raw JSON representation of the Subject or page.", + "neowiki-managesubjects-export-native": "Display name of the native (NeoWiki-vocabulary) RDF projection, used as $1 in {{msg-neowiki|neowiki-managesubjects-export-turtle}} and {{msg-neowiki|neowiki-managesubjects-export-trig}}.", + "neowiki-managesubjects-export-turtle": "Export menu item for the Turtle serialization of an RDF projection. $1 is the projection name (e.g. the message {{msg-neowiki|neowiki-managesubjects-export-native}}, or an ontology name such as \"EDM\"). \"Turtle\" is the name of the RDF serialization format and is not translated.", + "neowiki-managesubjects-export-trig": "Export menu item for the TriG serialization of an RDF projection. $1 is the projection name (e.g. the message {{msg-neowiki|neowiki-managesubjects-export-native}}, or an ontology name such as \"EDM\"). \"TriG\" is the name of the RDF serialization format and is not translated.", "action-subjects": "Description of the Subject management action used by MediaWiki when generating action descriptions." } diff --git a/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue b/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue index d4566560..dbdbb883 100644 --- a/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue +++ b/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue @@ -8,15 +8,28 @@ {{ $i18n( 'neowiki-managesubjects-count', subjects.length ).text() }} - - - {{ $i18n( 'neowiki-managesubjects-add-button' ).text() }} - +
+ + + {{ $i18n( 'neowiki-managesubjects-export-button' ).text() }} + + + + {{ $i18n( 'neowiki-managesubjects-add-button' ).text() }} + +

+ + + {{ $i18n( 'neowiki-managesubjects-export-button' ).text() }} + @@ -320,6 +343,16 @@ + + + {{ $i18n( 'neowiki-managesubjects-export-button' ).text() }} + @@ -386,6 +419,7 @@ import type { MenuButtonItemData } from '@wikimedia/codex'; import { cdxIconAdd, cdxIconCollapse, + cdxIconDownload, cdxIconDraggable, cdxIconEdit, cdxIconEllipsis, @@ -406,9 +440,14 @@ import EditSummary from '@/components/common/EditSummary.vue'; import I18nSlot from '@/components/common/I18nSlot.vue'; import SubjectStatementsView from '@/components/SubjectsManager/SubjectStatementsView.vue'; import { setPendingNotification } from '@/presentation/PendingNotification.ts'; +import { subjectExportMenuItems, pageExportMenuItems } from '@/presentation/DataExportMenu.ts'; const pageId = Number( mw.config.get( 'wgNeoWikiManageSubjectsPageId' ) ); +// RDF projections readable by the viewing user (native + ontology mappings), permission-filtered +// server-side. Drives the export menus; native is always present, so this is never truly empty. +const rdfProjections = ( mw.config.get( 'wgNeoWikiRdfProjections' ) as string[] | null ) ?? []; + const subjectStore = useSubjectStore(); const schemaStore = useSchemaStore(); const { @@ -553,6 +592,26 @@ function dispatchRowAction( value: string | number | null, subject: Subject ): v } } +const exportMenuSelection = ref( null ); + +function subjectExportItems( subject: Subject ): MenuButtonItemData[] { + return subjectExportMenuItems( subject.getId().text, rdfProjections ); +} + +const pageExportItems = computed( + () => pageExportMenuItems( pageId, rdfProjections ) +); + +// The menu item value is the export endpoint URL; open it in a new tab so the Data tab UI state +// (expanded rows, edits in progress) survives. Codex 1.14 menu-item anchors cannot set a target, +// so we navigate on selection rather than rely on the item's `url`. +function openExport( value: string | number | null ): void { + exportMenuSelection.value = null; + if ( typeof value === 'string' && value !== '' ) { + window.open( value, '_blank', 'noopener' ); + } +} + function toggleExpanded( id: string ): void { const next = new Set( expandedIds.value ); if ( next.has( id ) ) { @@ -799,6 +858,12 @@ onUnmounted( () => { margin-bottom: @spacing-150; } + &__controls-actions { + display: flex; + align-items: center; + gap: @spacing-100; + } + &__count { color: @color-subtle; } @@ -1104,7 +1169,9 @@ onUnmounted( () => { &__row-footer { display: flex; - justify-content: flex-start; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; gap: @spacing-100; margin-top: @spacing-100; padding-top: @spacing-75; diff --git a/resources/ext.neowiki/src/presentation/DataExportMenu.ts b/resources/ext.neowiki/src/presentation/DataExportMenu.ts new file mode 100644 index 00000000..73f8b15e --- /dev/null +++ b/resources/ext.neowiki/src/presentation/DataExportMenu.ts @@ -0,0 +1,67 @@ +import type { MenuButtonItemData } from '@wikimedia/codex'; + +/** + * Builds the export menu items for the Data tab: a JSON entry, then Turtle and TriG entries for each + * RDF projection the viewing user may read (native first, then ontology mappings). Item values are the + * full endpoint URLs; the menu navigates to them on selection. Kept as pure functions so the URL and + * label derivation is unit-tested independently of the Vue component. + */ + +interface RdfFormatOption { + format: string; + messageKey: string; +} + +const RDF_FORMATS: readonly RdfFormatOption[] = [ + { format: 'turtle', messageKey: 'neowiki-managesubjects-export-turtle' }, + { format: 'trig', messageKey: 'neowiki-managesubjects-export-trig' }, +]; + +const NATIVE_PROJECTION = 'native'; + +function restApiBase(): string { + return mw.util.wikiScript( 'rest' ); +} + +function projectionLabel( projection: string ): string { + return projection === NATIVE_PROJECTION ? + mw.msg( 'neowiki-managesubjects-export-native' ) : + projection; +} + +function exportMenuItems( + jsonUrl: string, + rdfEndpoint: string, + projections: readonly string[], +): MenuButtonItemData[] { + const items: MenuButtonItemData[] = [ + { value: jsonUrl, label: mw.msg( 'neowiki-managesubjects-export-json' ) }, + ]; + + for ( const projection of projections ) { + for ( const { format, messageKey } of RDF_FORMATS ) { + items.push( { + value: `${ rdfEndpoint }?projection=${ encodeURIComponent( projection ) }&format=${ format }`, + label: mw.msg( messageKey, projectionLabel( projection ) ), + } ); + } + } + + return items; +} + +export function subjectExportMenuItems( + subjectId: string, + projections: readonly string[], +): MenuButtonItemData[] { + const base = `${ restApiBase() }/neowiki/v0/subject/${ encodeURIComponent( subjectId ) }`; + return exportMenuItems( base, `${ base }/rdf`, projections ); +} + +export function pageExportMenuItems( + pageId: number, + projections: readonly string[], +): MenuButtonItemData[] { + const base = `${ restApiBase() }/neowiki/v0/page/${ pageId }`; + return exportMenuItems( `${ base }/subjects`, `${ base }/rdf`, projections ); +} diff --git a/resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts b/resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts new file mode 100644 index 00000000..1e5c5c6b --- /dev/null +++ b/resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { subjectExportMenuItems, pageExportMenuItems } from '@/presentation/DataExportMenu'; + +describe( 'DataExportMenu', () => { + beforeEach( () => { + vi.stubGlobal( 'mw', { + util: { wikiScript: vi.fn( () => '/w/rest.php' ) }, + msg: vi.fn( ( key: string, ...params: string[] ) => + params.length > 0 ? `[${ key }|${ params.join( ',' ) }]` : `[${ key }]` ), + } ); + } ); + + describe( 'subjectExportMenuItems', () => { + it( 'lists JSON first, then Turtle and TriG for each projection in order', () => { + const items = subjectExportMenuItems( 's2picasso2aaaa2', [ 'native', 'EDM' ] ); + + expect( items.map( ( item ) => item.value ) ).toEqual( [ + '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2', + '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=native&format=turtle', + '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=native&format=trig', + '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=EDM&format=turtle', + '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=EDM&format=trig', + ] ); + } ); + + it( 'labels JSON, the native projection, and mapping projections distinctly', () => { + const items = subjectExportMenuItems( 's2picasso2aaaa2', [ 'native', 'EDM' ] ); + + expect( items.map( ( item ) => item.label ) ).toEqual( [ + '[neowiki-managesubjects-export-json]', + '[neowiki-managesubjects-export-turtle|[neowiki-managesubjects-export-native]]', + '[neowiki-managesubjects-export-trig|[neowiki-managesubjects-export-native]]', + '[neowiki-managesubjects-export-turtle|EDM]', + '[neowiki-managesubjects-export-trig|EDM]', + ] ); + } ); + + it( 'percent-encodes the projection name in the RDF URLs', () => { + const items = subjectExportMenuItems( 's1aaaaaaaaaaaa1', [ 'Wikidata items' ] ); + + expect( items[ 1 ].value ).toBe( + '/w/rest.php/neowiki/v0/subject/s1aaaaaaaaaaaa1/rdf?projection=Wikidata%20items&format=turtle', + ); + } ); + + it( 'returns only the JSON entry when there are no readable projections', () => { + const items = subjectExportMenuItems( 's1aaaaaaaaaaaa1', [] ); + + expect( items ).toHaveLength( 1 ); + expect( items[ 0 ].value ).toBe( '/w/rest.php/neowiki/v0/subject/s1aaaaaaaaaaaa1' ); + } ); + } ); + + describe( 'pageExportMenuItems', () => { + it( 'targets the page subjects JSON and the page RDF endpoint', () => { + const items = pageExportMenuItems( 42, [ 'native', 'EDM' ] ); + + expect( items.map( ( item ) => item.value ) ).toEqual( [ + '/w/rest.php/neowiki/v0/page/42/subjects', + '/w/rest.php/neowiki/v0/page/42/rdf?projection=native&format=turtle', + '/w/rest.php/neowiki/v0/page/42/rdf?projection=native&format=trig', + '/w/rest.php/neowiki/v0/page/42/rdf?projection=EDM&format=turtle', + '/w/rest.php/neowiki/v0/page/42/rdf?projection=EDM&format=trig', + ] ); + } ); + } ); +} ); diff --git a/src/EntryPoints/Actions/SubjectsAction.php b/src/EntryPoints/Actions/SubjectsAction.php index 0e1cf03d..b2327761 100644 --- a/src/EntryPoints/Actions/SubjectsAction.php +++ b/src/EntryPoints/Actions/SubjectsAction.php @@ -45,10 +45,17 @@ public function onView(): string { ); } - NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $out, $this->getSkin() ); + $extension = NeoWikiExtension::getInstance(); + $extension->newFrontendModuleLoader()->load( $out, $this->getSkin() ); $out->addJsConfigVars( [ 'wgNeoWikiManageSubjectsPageId' => $title->getArticleID(), + // The export UI (Data tab menus) is driven by this list. Filtered by the viewing user's + // read authority so restricted Mapping page titles never reach a reader who cannot see them. + 'wgNeoWikiRdfProjections' => $extension->filterReadableProjectionNames( + $extension->getRdfProjectionNames(), + $this->getAuthority() + ), ] ); return Html::element( 'div', [ 'id' => 'ext-neowiki-manage-subjects' ] ); diff --git a/src/EntryPoints/NeoWikiHooks.php b/src/EntryPoints/NeoWikiHooks.php index 36a6b703..9d8f19e7 100644 --- a/src/EntryPoints/NeoWikiHooks.php +++ b/src/EntryPoints/NeoWikiHooks.php @@ -6,6 +6,7 @@ use MediaWiki\Html\Html; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MainConfigNames; use MediaWiki\MediaWikiServices; use MediaWiki\Output\OutputPage; use MediaWiki\Page\ProperPageIdentity; @@ -16,6 +17,7 @@ use MediaWiki\Title\ForeignTitle; use MediaWiki\Title\Title; use MediaWiki\User\UserIdentity; +use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector; use ProfessionalWiki\NeoWiki\Domain\Page\PageId; use ProfessionalWiki\NeoWiki\EntryPoints\Content\SchemaContent; use ProfessionalWiki\NeoWiki\EntryPoints\Content\SubjectContent; @@ -61,6 +63,7 @@ private static function handleContentPage( OutputPage $out, Skin $skin ): void { NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $out, $skin ); $out->addHtml( self::getNeoWikiAppHtml( $out ) ); + self::addRdfAutodiscoveryLinks( $out ); if ( !NeoWikiExtension::getInstance()->shouldAutoRenderMainSubject() ) { return; @@ -113,6 +116,34 @@ private static function pageIsLatestRevision( OutputPage $out ): bool { return $out->getRevisionId() === $out->getTitle()->getLatestRevID(); } + /** + * Advertises the page's RDF export (native projection) via `` autodiscovery + * tags — one Turtle, one TriG — so Linked Data tooling finds the data without reading the API docs. + * Emitted only when the page has Subjects, so the advertised endpoint never 404s. Native only; the + * per-projection exports are reachable from the Data tab UI. + */ + private static function addRdfAutodiscoveryLinks( OutputPage $out ): void { + $pageId = $out->getTitle()->getArticleID(); + + if ( !NeoWikiExtension::getInstance()->newPageSubjectsLookup()->pageHasSubjects( new PageId( $pageId ) ) ) { + return; + } + + $services = MediaWikiServices::getInstance(); + $endpoint = $services->getMainConfig()->get( MainConfigNames::RestPath ) + . '/neowiki/v0/page/' . $pageId . '/rdf?projection=' . RdfPageProjector::PROJECTION; + $urlUtils = $services->getUrlUtils(); + + foreach ( [ 'turtle' => 'text/turtle', 'trig' => 'application/trig' ] as $format => $type ) { + $href = $endpoint . '&format=' . $format; + $out->addLink( [ + 'rel' => 'alternate', + 'type' => $type, + 'href' => $urlUtils->expand( $href, PROTO_CANONICAL ) ?? $href, + ] ); + } + } + private static function handleSchemaPage( OutputPage $out, Skin $skin ): void { NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $out, $skin ); diff --git a/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php b/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php index 4347d462..b58a846c 100644 --- a/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php +++ b/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php @@ -4,14 +4,22 @@ namespace ProfessionalWiki\NeoWiki\Tests\EntryPoints\Actions; +use Article; +use MediaWiki\Context\RequestContext; +use MediaWiki\Output\OutputPage; +use MediaWiki\Permissions\Authority; use MediaWiki\Title\Title; -use MediaWikiIntegrationTestCase; use ProfessionalWiki\NeoWiki\EntryPoints\Actions\SubjectsAction; +use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase; +use ProfessionalWiki\NeoWiki\Tests\NeoWikiMockAuthorityTrait; /** * @covers \ProfessionalWiki\NeoWiki\EntryPoints\Actions\SubjectsAction + * @group Database */ -class SubjectsActionTest extends MediaWikiIntegrationTestCase { +class SubjectsActionTest extends NeoWikiIntegrationTestCase { + + use NeoWikiMockAuthorityTrait; public function testNullTitleIsNotEligible(): void { $this->assertFalse( SubjectsAction::isEligibleTitle( null ) ); @@ -43,4 +51,42 @@ public function testTitleInContentNamespaceIsEligible(): void { $this->assertTrue( SubjectsAction::isEligibleTitle( $title ) ); } + public function testExposesReadableRdfProjectionsAsConfigVar(): void { + $this->createMapping( 'EDM', '{ "version": 1, "schemas": {} }' ); + + $out = $this->runOnView( 'SubjectsActionTest projections', $this->getTestSysop()->getAuthority() ); + + $this->assertSame( + [ 'native', 'EDM' ], + $out->getJsConfigVars()['wgNeoWikiRdfProjections'] + ); + } + + public function testOmitsRdfProjectionsTheViewingUserCannotRead(): void { + $this->createMapping( 'EDM', '{ "version": 1, "schemas": {} }' ); + + $out = $this->runOnView( + 'SubjectsActionTest restricted', + $this->authorityWithGlobalReadButNoPageRead() + ); + + $this->assertSame( + [ 'native' ], + $out->getJsConfigVars()['wgNeoWikiRdfProjections'], + 'A read-restricted Mapping page name must not reach a reader who cannot see it.' + ); + } + + private function runOnView( string $pageName, Authority $authority ): OutputPage { + $title = $this->getExistingTestPage( $pageName )->getTitle(); + + $context = new RequestContext(); + $context->setTitle( $title ); + $context->setAuthority( $authority ); + + ( new SubjectsAction( Article::newFromTitle( $title, $context ), $context ) )->onView(); + + return $context->getOutput(); + } + } diff --git a/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php b/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php new file mode 100644 index 00000000..4b03ec5e --- /dev/null +++ b/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php @@ -0,0 +1,83 @@ +setUpNeo4j(); + $this->createSchema( TestSubject::DEFAULT_SCHEMA_ID ); + $this->markPageTableAsUsed(); + } + + public function testAdvertisesTurtleAndTrigExportsForPageWithSubjects(): void { + $revision = $this->createPageWithSubjects( + 'Autodiscovery with subjects', + TestSubject::build( id: self::SUBJECT_ID ) + ); + + $out = $this->renderContentPage( 'Autodiscovery with subjects', $revision->getId() ); + $pageId = $revision->getPageId(); + + $turtle = $this->alternateLinkHref( $out, 'text/turtle' ); + $trig = $this->alternateLinkHref( $out, 'application/trig' ); + + $this->assertNotNull( $turtle, 'A text/turtle alternate link is expected on a page with Subjects.' ); + $this->assertNotNull( $trig, 'An application/trig alternate link is expected on a page with Subjects.' ); + $this->assertStringContainsString( "/neowiki/v0/page/{$pageId}/rdf?projection=native&format=turtle", $turtle ); + $this->assertStringContainsString( "/neowiki/v0/page/{$pageId}/rdf?projection=native&format=trig", $trig ); + // Absolute (canonical) URLs, not relative paths, so Linked Data tooling can dereference them. + $this->assertStringStartsWith( 'http', $turtle ); + $this->assertStringStartsWith( 'http', $trig ); + } + + public function testDoesNotAdvertiseExportsForPageWithoutSubjects(): void { + $this->editPage( 'Autodiscovery without subjects', 'Plain page, no NeoWiki data.' ); + $revisionId = Title::newFromText( 'Autodiscovery without subjects' )->getLatestRevID(); + + $out = $this->renderContentPage( 'Autodiscovery without subjects', $revisionId ); + + $this->assertNull( $this->alternateLinkHref( $out, 'text/turtle' ) ); + $this->assertNull( $this->alternateLinkHref( $out, 'application/trig' ) ); + } + + private function renderContentPage( string $pageName, int $revisionId ): OutputPage { + $context = new RequestContext(); + $context->setTitle( Title::newFromText( $pageName ) ); + + $out = $context->getOutput(); + $out->setArticleFlag( true ); + $out->setRevisionId( $revisionId ); + + NeoWikiHooks::onBeforePageDisplay( $out, $context->getSkin() ); + + return $out; + } + + private function alternateLinkHref( OutputPage $out, string $type ): ?string { + foreach ( $out->getLinkTags() as $tag ) { + if ( ( $tag['rel'] ?? null ) === 'alternate' && ( $tag['type'] ?? null ) === $type ) { + return $tag['href'] ?? null; + } + } + + return null; + } + +}