diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index 8345ae99c..401137798 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 6216c31b2..241c4792e 100644 --- a/docs/rdf/rdf-export.md +++ b/docs/rdf/rdf-export.md @@ -64,26 +64,51 @@ 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 the name of a Mapping page — see -[Ontology Mapping](ontology-mapping.md). An unknown projection 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 the name of a +Mapping page — see [Ontology Mapping](ontology-mapping.md); an unknown projection 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 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' +``` + +### 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 1ed63587b..f4d844570 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" ], @@ -586,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 4edfb6f3a..99e036004 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 8dcbe646f..2feccb177 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 d45665606..dbdbb8839 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 000000000..73f8b15e4 --- /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 000000000..1e5c5c6b1 --- /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/Application/Rdf/OntologyMappingProjector.php b/src/Application/Rdf/OntologyMappingProjector.php index a98f2605a..38eaf1e5c 100644 --- a/src/Application/Rdf/OntologyMappingProjector.php +++ b/src/Application/Rdf/OntologyMappingProjector.php @@ -19,6 +19,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; @@ -60,17 +61,41 @@ public function projectPage( Page $page ): QuadList { $schemaMapping = $this->mapping->forSchema( $subject->getSchemaName() ); if ( $schemaMapping !== null ) { - $quads = array_merge( $quads, $this->projectSubject( $subject, $schemaMapping, $graph ) ); + $quads = array_merge( $quads, $this->subjectQuads( $subject, $schemaMapping, $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 + * entry in this Mapping, yields an empty list. + */ + public function projectSubject( Page $page, SubjectId $subjectId ): QuadList { + $subject = $page->getSubjects()->getAllSubjects()->getSubject( $subjectId ); + + if ( $subject === null ) { + return new QuadList(); + } + + $schemaMapping = $this->mapping->forSchema( $subject->getSchemaName() ); + + if ( $schemaMapping === null ) { + return new QuadList(); + } + + return QuadList::fromArray( + $this->subjectQuads( $subject, $schemaMapping, $this->namespaces->graph( $this->target, $page->getId() ) ) + ); + } + /** * @return Quad[] */ - private function projectSubject( Subject $subject, SchemaMapping $schemaMapping, Iri $graph ): array { + private function subjectQuads( Subject $subject, SchemaMapping $schemaMapping, Iri $graph ): array { $subjectIri = $this->namespaces->subject( $subject->id ); $quads = [ diff --git a/src/Application/Rdf/PageProjector.php b/src/Application/Rdf/PageProjector.php index d0e2ed11c..1b5405ffc 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 45a19530a..c295ec8b2 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 000000000..85184d1df --- /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/Actions/SubjectsAction.php b/src/EntryPoints/Actions/SubjectsAction.php index 0e1cf03d8..b23277619 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 36a6b7037..9d8f19e7c 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/src/EntryPoints/REST/ExportPageRdfApi.php b/src/EntryPoints/REST/ExportPageRdfApi.php index d85009509..5cab2996e 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(); @@ -76,42 +70,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 000000000..5a09e5a9c --- /dev/null +++ b/src/EntryPoints/REST/ExportSubjectRdfApi.php @@ -0,0 +1,105 @@ +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 ) { + // Read-filter the known-projection list with the caller's own authority, so the 400 never + // leaks the titles of Mapping pages they may not read (#1046). + $knownProjections = $extension->filterReadableProjectionNames( + $resolution->knownProjectionNames, + $this->getAuthority() + ); + + return $this->getResponseFactory()->createHttpError( 400, [ + 'message' => 'Unknown RDF projection: "' . $projectionName . '". Known projections: ' + . implode( ', ', $knownProjections ) . '.', + ] ); + } + + $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 000000000..cf43a468f --- /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 e035f60ea..0e37104d2 100644 --- a/src/NeoWikiExtension.php +++ b/src/NeoWikiExtension.php @@ -68,6 +68,7 @@ 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\Mapping\Mapping; use ProfessionalWiki\NeoWiki\Domain\Mapping\MappingName; @@ -76,6 +77,7 @@ 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; @@ -330,6 +332,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 @@ -480,6 +492,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 a66ae4436..2721c5421 100644 --- a/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php +++ b/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php @@ -42,6 +42,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/'; @@ -115,7 +116,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' ), @@ -127,8 +132,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' ), @@ -136,8 +143,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 an entry of its own. + */ + public function testProjectSubjectEmitsOnlyTheTargetSubjectsMappedBlockInTheTargetGraph(): void { + $quads = $this->newProjector( [ 'Person' => $this->personMapping(), 'City' => $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 testProjectSubjectForASubjectWhoseSchemaHasNoEntryReturnsNothing(): void { + // Only Person has an entry; the requested Subject uses the unmapped Ghost Schema. + $quads = $this->newProjector( [ 'Person' => $this->personMapping() ] ) + ->projectSubject( $this->pagePersonBetweenSiblings(), new SubjectId( self::GHOST_ID ) ); + + $this->assertTrue( $quads->isEmpty() ); + $this->logger->assertNoLoggingCallsWhereMade(); + } + + public function testProjectSubjectForASubjectNotOnThePageReturnsNothing(): void { + $quads = $this->newProjector( [ 'Person' => $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(): SchemaMapping { diff --git a/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php b/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php index 88c9c29cf..c8221eac6 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 000000000..633110c35 --- /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/Actions/SubjectsActionTest.php b/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php index 4347d4621..b58a846ce 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/REST/ExportSubjectRdfApiTest.php b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php new file mode 100644 index 000000000..f6014fa20 --- /dev/null +++ b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php @@ -0,0 +1,310 @@ +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 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' ] ); + + $this->assertSame( 200, $explicit->getStatusCode() ); + $this->assertSame( + ParsedRdf::canonicalQuads( $default->getBody()->getContents() ), + ParsedRdf::canonicalQuads( $explicit->getBody()->getContents() ) + ); + } + + public function testValidTargetProjectsTheMappedVocabularyWithNativeSubjectIris(): void { + $this->createEdmMapping(); + + $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 testReadableSubjectWithoutAMappingForTheTargetReturnsAnEmptyGraph(): void { + // "EDM" is a known projection because Berlin's Schema has an entry on the Mapping page, but this + // Subject's Schema does 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->createEdmMapping(); + + $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(); + + $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 createEdmMapping(): void { + $this->createMapping( 'EDM', <<schemaName()}": { + "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/EntryPoints/RdfAutodiscoveryLinksTest.php b/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php new file mode 100644 index 000000000..4b03ec5eb --- /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; + } + +} diff --git a/tests/phpunit/TestDoubles/FixedPageProjector.php b/tests/phpunit/TestDoubles/FixedPageProjector.php index 090e30dc0..245d24da0 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; + } + }