Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ Every endpoint is also published as a complete [OpenAPI 3.0 description](#full-s

## Permissions

The Subject, page-subjects, subject-labels, Schema, Layout, RDF export, and entity-dereference read endpoints enforce
the caller's per-page `read` permission; page protection and `$wgNamespaceProtection` do not restrict them, because
MediaWiki's `read` action ignores both. When you may not read a page they respond as if the data were absent — a `null`
value, an empty list, or a `404` — never a `403`. `GET /subject-labels` omits the labels of Subjects whose page you
cannot read; because that filter runs per result, it caps `limit` at 50.
The Subject, page-subjects, subject-labels, Schema, Layout, Mapping, RDF export, and entity-dereference read endpoints
enforce the caller's per-page `read` permission; page protection and `$wgNamespaceProtection` do not restrict them,
because MediaWiki's `read` action ignores both. When you may not read a page they respond as if the data were absent — a
`null` value, an empty list, or a `404` — never a `403`. `GET /subject-labels` omits the labels of Subjects whose page
you cannot read; because that filter runs per result, it caps `limit` at 50.

The `GET /schemas`, `GET /layouts`, and `GET /mappings` list endpoints paginate with an opaque cursor over the rows you
may read (see [Cursor pagination](#cursor-pagination)): a restricted Schema, Layout, or Mapping is skipped exactly like
one that does not exist, and no total count is reported, so nothing about restricted rows can be inferred from the
pagination.

Subject write endpoints require per-page `edit` permission and answer `403` on denial.

Expand Down Expand Up @@ -70,7 +75,7 @@ A Schema defines a Subject type and its properties. For the body shape, see

| Endpoint | Description |
|---|---|
| `GET /neowiki/v0/schemas` | List Schemas. Paginated with `limit` and `offset`. |
| `GET /neowiki/v0/schemas` | List Schemas. [Cursor-paginated](#cursor-pagination) with `limit` and `cursor`. |
| `GET /neowiki/v0/schema/{schemaName}` | Fetch a Schema by name. |
| `GET /neowiki/v0/schema-names/{search}` | Find Schema names by prefix. |

Expand All @@ -80,7 +85,7 @@ A Layout defines how a Subject is displayed.

| Endpoint | Description |
|---|---|
| `GET /neowiki/v0/layouts` | List Layouts. Paginated with `limit` and `offset`. |
| `GET /neowiki/v0/layouts` | List Layouts. [Cursor-paginated](#cursor-pagination) with `limit` and `cursor`. |
| `GET /neowiki/v0/layout/{layoutName}` | Fetch a Layout by name. |

### Mappings
Expand All @@ -90,7 +95,7 @@ An ontology Mapping projects native Schemas to a target ontology. For the format

| Endpoint | Description |
|---|---|
| `GET /neowiki/v0/mappings` | List ontology Mappings, each with the names of its mapped Schemas. Paginated with `limit` and `offset`. |
| `GET /neowiki/v0/mappings` | List ontology Mappings, each with the names of its mapped Schemas. [Cursor-paginated](#cursor-pagination) with `limit` and `cursor`. |

### Query

Expand All @@ -100,6 +105,18 @@ An ontology Mapping projects native Schemas to a target ontology. For the format

<!-- REST-ENDPOINTS:END -->

## Cursor pagination

The Schema, Layout, and Mapping list endpoints paginate with an opaque cursor. Request up to `limit` items (1–50,
default 10); the response carries the items and a `nextCursor`:

```json
{ "schemas": [ ... ], "nextCursor": "1462" }
```

Pass that value back as `cursor` to fetch the next page; `null` marks the last page. Do not construct a cursor
yourself — a malformed one is rejected with a `400`. Cursors stay valid while items are created and deleted.

## The `expand` parameter

The Subject read and page-subjects read endpoints take an optional multi-valued `expand` query parameter (pipe-separated,
Expand Down
105 changes: 45 additions & 60 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,6 @@ parameters:
count: 1
path: src/NeoWikiExtension.php

-
message: "#^Cannot access property \\$page_title on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Instantiated class TitleValue not found\\.$#"
count: 2
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\MediaWiki\\\\DatabaseSchemaNameLookup\\:\\:dbResultToTitleValueArray\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\MediaWiki\\\\DatabaseSchemaNameLookup\\:\\:getFirstSchemaNames\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\MediaWiki\\\\DatabaseSchemaNameLookup\\:\\:getSchemaNamesMatching\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\MediaWiki\\\\DatabaseSchemaNameLookup\\:\\:searchSuggestionsToTitleArray\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Call to method getPrefixedText\\(\\) on an unknown class Title\\.$#"
count: 1
Expand Down Expand Up @@ -195,36 +165,6 @@ parameters:
count: 1
path: src/GraphDatabasePlugins/Neo4j/Persistence/Neo4jQueryStore.php

-
message: "#^Cannot access property \\$page_title on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Instantiated class TitleValue not found\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\MediaWiki\\\\DatabaseLayoutNameLookup\\:\\:dbResultToTitleValueArray\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\MediaWiki\\\\DatabaseLayoutNameLookup\\:\\:getLayoutNames\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\LayoutNameLookup\\:\\:getLayoutNames\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/LayoutNameLookup.php

-
message: "#^Method ProfessionalWiki\\\\NeoWiki\\\\Persistence\\\\SchemaNameLookup\\:\\:getSchemaNamesMatching\\(\\) has invalid return type TitleValue\\.$#"
count: 1
path: src/Persistence/SchemaNameLookup.php

-
message: "#^Call to method getHeader\\(\\) on an unknown class WebRequest\\.$#"
count: 1
Expand Down Expand Up @@ -299,3 +239,48 @@ parameters:
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/GraphDatabasePlugins/Neo4j/Application/KeywordCypherQueryValidator.php

-
message: "#^Cannot access property \\$page_id on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Cannot access property \\$page_title on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Parameter \\#2 \\$op of method Wikimedia\\\\Rdbms\\\\IReadableDatabase\\:\\:expr\\(\\) expects '\\!\\='\\|'\\='\\|'\\\\\\\\x3C'\\|'\\\\\\\\x3C\\='\\|'\\\\\\\\x3E'\\|'\\\\\\\\x3E\\='\\|'LIKE'\\|'NOT LIKE', '\\>' given\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseLayoutNameLookup.php

-
message: "#^Cannot access property \\$page_id on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseMappingNameLookup.php

-
message: "#^Cannot access property \\$page_title on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseMappingNameLookup.php

-
message: "#^Parameter \\#2 \\$op of method Wikimedia\\\\Rdbms\\\\IReadableDatabase\\:\\:expr\\(\\) expects '\\!\\='\\|'\\='\\|'\\\\\\\\x3C'\\|'\\\\\\\\x3C\\='\\|'\\\\\\\\x3E'\\|'\\\\\\\\x3E\\='\\|'LIKE'\\|'NOT LIKE', '\\>' given\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseMappingNameLookup.php

-
message: "#^Cannot access property \\$page_id on array\\|stdClass\\|false\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Cannot access property \\$page_title on array\\|stdClass\\|false\\.$#"
count: 2
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php

-
message: "#^Parameter \\#2 \\$op of method Wikimedia\\\\Rdbms\\\\IReadableDatabase\\:\\:expr\\(\\) expects '\\!\\='\\|'\\='\\|'\\\\\\\\x3C'\\|'\\\\\\\\x3C\\='\\|'\\\\\\\\x3E'\\|'\\\\\\\\x3E\\='\\|'LIKE'\\|'NOT LIKE', '\\>' given\\.$#"
count: 1
path: src/Persistence/MediaWiki/DatabaseSchemaNameLookup.php
14 changes: 9 additions & 5 deletions resources/ext.neowiki/src/application/SchemaLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ export interface SchemaSummary {

export interface SchemaSummaryPage {
schemas: SchemaSummary[];
totalRows: number;
nextCursor: string | null;
}

export interface SchemaLookup {

getSchema( schemaName: SchemaName ): Promise<Schema>;
getSchemaNames( search: string ): Promise<string[]>;
getSchemaSummaries( offset: number, limit: number ): Promise<SchemaSummaryPage>;
getSchemaSummaries( cursor: string | null, limit: number ): Promise<SchemaSummaryPage>;

}

Expand Down Expand Up @@ -43,16 +43,20 @@ export class InMemorySchemaLookup implements SchemaLookup {
return [ ...this.schemaNames ];
}

public async getSchemaSummaries( offset: number, limit: number ): Promise<SchemaSummaryPage> {
public async getSchemaSummaries( cursor: string | null, limit: number ): Promise<SchemaSummaryPage> {
const summaries = [ ...this.schemas.values() ].map( ( schema ) => ( {
name: schema.getName(),
description: schema.getDescription(),
propertyCount: [ ...schema.getPropertyDefinitions() ].length,
} ) );

// The cursor is opaque to callers; this fake encodes the next start index in it.
const start = cursor === null ? 0 : parseInt( cursor, 10 );
const end = start + limit;

return {
schemas: summaries.slice( offset, offset + limit ),
totalRows: summaries.length,
schemas: summaries.slice( start, end ),
nextCursor: end < summaries.length ? String( end ) : null,
};
}

Expand Down
18 changes: 14 additions & 4 deletions resources/ext.neowiki/src/components/LayoutsPage/LayoutsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import { CdxButton, CdxIcon, CdxTable } from '@wikimedia/codex';
import type { TableColumn } from '@wikimedia/codex';
import { cdxIconAdd, cdxIconEdit, cdxIconTrash } from '@wikimedia/codex-icons';
import { NeoWikiExtension } from '@/NeoWikiExtension.ts';
import { useCursorPagination } from '@/composables/useCursorPagination.ts';
import { useLayoutPermissions } from '@/composables/useLayoutPermissions.ts';
import { useLayoutStore } from '@/stores/LayoutStore.ts';
import { Layout } from '@/domain/Layout.ts';
Expand All @@ -116,10 +117,16 @@ const paginationSizeOptions: { value: number }[] = [
];

const loading = ref( true );
const totalRows = ref( 0 );
const isCreatorOpen = ref( false );
const pageSize = ref( paginationSizeOptions[ 0 ].value );
const lastOffset = ref( 0 );
// Undefined while the end of the listing is unknown, which keeps CdxTable in its indeterminate
// pagination. Once a response carries a null cursor the exact count is known, and the table needs
// it: its indeterminate next-button heuristic (a short page) misses a listing that ends exactly on
// a page boundary. The count covers only rows this client has itself paged through, so it reveals
// nothing the row listing did not already.
const totalRows = ref<number | undefined>( undefined );
const { cursorFor, recordNextCursor } = useCursorPagination();
const { canCreateLayouts, canEditLayout: canEditLayouts, checkCreatePermission, checkEditPermission } = useLayoutPermissions();
const layoutStore = useLayoutStore();

Expand Down Expand Up @@ -182,19 +189,21 @@ async function fetchLayouts( offset: number, limit: number ): Promise<void> {
pageSize.value = limit;
lastOffset.value = offset;

const cursor = cursorFor( offset );
const cursorParam = cursor === null ? '' : `&cursor=${ encodeURIComponent( cursor ) }`;
const restApiUrl = NeoWikiExtension.getInstance().getMediaWiki().util.wikiScript( 'rest' );
const httpClient = NeoWikiExtension.getInstance().newHttpClient();

const response = await httpClient.get(
`${ restApiUrl }/neowiki/v0/layouts?limit=${ limit }&offset=${ offset }`
`${ restApiUrl }/neowiki/v0/layouts?limit=${ limit }${ cursorParam }`
);

if ( !response.ok ) {
loading.value = false;
return;
}

const result: { layouts: LayoutSummary[]; totalRows: number } = await response.json();
const result: { layouts: LayoutSummary[]; nextCursor: string | null } = await response.json();

rows.value = result.layouts.map( ( summary ) => ( {
name: summary.name,
Expand All @@ -203,7 +212,8 @@ async function fetchLayouts( offset: number, limit: number ): Promise<void> {
description: summary.description
} ) );

totalRows.value = result.totalRows;
recordNextCursor( offset, limit, result.nextCursor );
totalRows.value = result.nextCursor === null ? offset + result.layouts.length : undefined;
loading.value = false;
}

Expand Down
18 changes: 14 additions & 4 deletions resources/ext.neowiki/src/components/MappingsPage/MappingsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import { CdxButton, CdxIcon, CdxTable } from '@wikimedia/codex';
import type { TableColumn } from '@wikimedia/codex';
import { cdxIconAdd, cdxIconEdit, cdxIconTrash } from '@wikimedia/codex-icons';
import { NeoWikiExtension } from '@/NeoWikiExtension.ts';
import { useCursorPagination } from '@/composables/useCursorPagination.ts';
import { useMappingPermissions } from '@/composables/useMappingPermissions.ts';
import MappingCreatorDialog from './MappingCreatorDialog.vue';
import DeletePageDialog from '@/components/common/DeletePageDialog.vue';
Expand All @@ -106,10 +107,16 @@ const paginationSizeOptions: { value: number }[] = [
];

const loading = ref( true );
const totalRows = ref( 0 );
const pageSize = ref( paginationSizeOptions[ 0 ].value );
const lastOffset = ref( 0 );
const isCreatorOpen = ref( false );
// Undefined while the end of the listing is unknown, which keeps CdxTable in its indeterminate
// pagination. Once a response carries a null cursor the exact count is known, and the table needs
// it: its indeterminate next-button heuristic (a short page) misses a listing that ends exactly on
// a page boundary. The count covers only rows this client has itself paged through, so it reveals
// nothing the row listing did not already.
const totalRows = ref<number | undefined>( undefined );
const { cursorFor, recordNextCursor } = useCursorPagination();

const isDeleteConfirmOpen = ref( false );
const deletingMappingName = ref( '' );
Expand Down Expand Up @@ -161,26 +168,29 @@ async function fetchMappings( offset: number, limit: number ): Promise<void> {
pageSize.value = limit;
lastOffset.value = offset;

const cursor = cursorFor( offset );
const cursorParam = cursor === null ? '' : `&cursor=${ encodeURIComponent( cursor ) }`;
const restApiUrl = NeoWikiExtension.getInstance().getMediaWiki().util.wikiScript( 'rest' );
const httpClient = NeoWikiExtension.getInstance().newHttpClient();

const response = await httpClient.get(
`${ restApiUrl }/neowiki/v0/mappings?limit=${ limit }&offset=${ offset }`
`${ restApiUrl }/neowiki/v0/mappings?limit=${ limit }${ cursorParam }`
);

if ( !response.ok ) {
loading.value = false;
return;
}

const result: { mappings: MappingSummary[]; totalRows: number } = await response.json();
const result: { mappings: MappingSummary[]; nextCursor: string | null } = await response.json();

rows.value = result.mappings.map( ( summary ) => ( {
name: summary.name,
schemas: summary.schemas
} ) );

totalRows.value = result.totalRows;
recordNextCursor( offset, limit, result.nextCursor );
totalRows.value = result.nextCursor === null ? offset + result.mappings.length : undefined;
loading.value = false;
}

Expand Down
Loading
Loading