Add public Cypher query REST API + close #549#817
Open
alistair3149 wants to merge 27 commits into
Open
Conversation
alistair3149
commented
May 12, 2026
|
|
||
| "GroupPermissions": { | ||
| "*": { | ||
| "neowiki-query": true |
Member
Author
There was a problem hiding this comment.
I picked a separate user rights for easier spam protections for wiki admins
alistair3149
commented
May 12, 2026
| "description": "Whether development UIs should be enabled", | ||
| "value": false | ||
| }, | ||
| "NeoWikiQueryLimits": { |
Member
Author
There was a problem hiding this comment.
Make the query limit more granular with timeout and max rows.
13 tasks
Member
|
Has some organization/architecture issues. I'm investigating |
c44764b to
382c35a
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ultNormalizer Move the converter up to the Application layer so all query surfaces (Lua, parser function, future REST handler) can share it. Behavior is preserved; only namespace, class name, and file location change. Add a refining @param annotation to convertAssociative() to satisfy PHPStan at the new path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Match the file's existing import style for Laudis types instead of an inline FQN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…arkup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove now-unused RuntimeException import from CypherRawParserFunction. - Restructure QueryService validator handling: catch any Throwable from the validator call, then check the bool result outside the try. Avoids the rethrow-in-catch pattern. - Add testValidatorExceptionBecomesBackendUnavailable in QueryServiceTest so the validator-throws contract has a focused unit test (was only exercised via CypherRawParserFunctionTest::testValidatorExceptionReturnsError). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… description Strip alignment padding from the new RateLimits and NeoWikiQueryLimits config blocks to match the rest of the file's single-space convention. Reword the NeoWikiQueryLimits description so the per-tier trigger condition is stated correctly (default = users WITHOUT apihighlimits). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Core MediaWiki grants the 'bot' group the noratelimit right by default, so a per-bot RateLimits entry is unnecessary. The [0, 0] value also triggers WRStatsError in the rate-limiter implementation, which had required a setUp() override in QueryCypherApiTest. Dropping the entry fixes both: bots remain unlimited via noratelimit, and the test setup no longer needs the workaround. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Switch from RequestContext::getMain()->getUser() to $this->getAuthority() for the permission check, matching the pattern of other REST handlers in this extension. Resolve a User from the authority via UserFactory for pingLimiter and QueryLimits::forUser, which both require a full User. - Drop the redundant `?? []` for $body['parameters'] now that PARAM_DEFAULT on the body field is honored by getValidatedBody(). - Add a comment on the match default arm to make its defensive intent explicit. - Add testParameterMissingExceptionMapsTo400ParameterMissing for the one error mapping that lacked coverage. - Update testReturns403WhenUserLacksNeowikiQueryRight to pass an explicit mockAnonAuthorityWithPermissions([]) via the new authority parameter on executeRequest, since getAuthority() now drives the gate. - Re-sort the QueryCypherApi import in NeoWikiExtension.php into alphabetical order with its REST siblings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task 9 review caught two gaps in the new Query API doc: - The cell normalization table omitted UnboundRelationship, which the normalizer handles for undirected pattern matches. - The "Successful response" example showed Alice/Ben year values that contradicted the matching rows in the Step 4 walkthrough response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cases hit the full HTTP path through QueryCypherApi against the test container's Neo4j instance: a happy-path count query (asserting the envelope shape and durationMs presence) and a write-query rejection (asserting the 422 / writeQueryRejected error contract). Skips gracefully via NeoWikiIntegrationTestCase::setUpNeo4j() if the backend is unreachable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final pre-merge review caught three real findings: - The earlier removal of the bot RateLimits entry was based on a wrong premise. Core MediaWiki grants noratelimit to sysop and bureaucrat, NOT to bot. The bot group only has apihighlimits. Without an explicit RateLimits entry, bots fall through to the user bucket (60/min), which defeats the purpose of having a bot account. Restore the entry with a generous-but-bounded value (1000/min) that doesn't trigger WRStats the way [0,0] did. Update the docs to reflect actual behavior. - CypherRawParserFunction was using the wfMessage() global. The Parser context is in scope, so use $parser->msg() per the project preference. - QueryCypherApi::run() called QueryLimits::forUser() inside the QueryException catch block, but forUser() can throw a ConfigException (non-QueryException) if NeoWikiQueryLimits is missing. Wrap that call separately and map to a 500 internalError so the public error contract is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- QueryCypherApi::mapException() now uses $e->errorType() directly for the error-type string, leaving the match to map exception class to HTTP status only. Eliminates the two-sources-of-truth maintenance trap noted in the final review without leaking HTTP semantics into the domain-layer exception hierarchy. - Remove the five i18n keys that became dead code when CypherQueryRunner and CypherRawParserFunction stopped formatting their own error messages and started surfacing QueryService exception messages directly. The neowiki-cypher-raw-error-json-encode key remains since the parser function still uses it for the JSON-encode failure path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ption Smoke test against a live wiki revealed that a Cypher syntax error returned 503 backendUnavailable instead of 400 cypherSyntaxError. The ExplainCypherQueryValidator runs an EXPLAIN against Neo4j to detect non-keyword write operations; when the user submits invalid Cypher, EXPLAIN itself throws a Neo4jException with the SyntaxError code. The previous validator try/catch swept all Neo4jExceptions into BackendUnavailableException, which both (a) hid the real failure mode from clients and (b) returned 503 for what should be a 400 client error. Catch Neo4jException from the validator separately and route it through the same translateNeo4jException() the engine path uses, so syntax, parameter-missing, and timeout errors surfaced by the validator translate to the same typed exceptions and HTTP statuses they would on the engine path. Non-Neo4j Throwables from the validator (real connection failures) still become BackendUnavailableException. Add a unit test that exercises this routing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Laudis's Neo4jException constructor wraps the actual server message in
a verbose template ('Neo4j errors detected. First one with code "..."
and message "..."'). Switching to getNeo4jMessage() strips that wrapper
and surfaces just the Neo4j-supplied text the user actually wants to
see — e.g. "Invalid input 'RETURN': expected a graph pattern ..." or
"Expected parameter(s): missing". Falls back to the original
getMessage() when getNeo4jMessage() is null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ApiTest Match the rest of the file's import style; the inline FQNs were a holdover from the spec template that listed them as "either is fine". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline fully-qualified names are not acceptable in this project. Add a use Throwable import at the top of the file and drop the leading backslash on the catch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry\Cypher Move QueryService, QueryRequest, QueryResult, and QueryResultNormalizer into a new Application\Query\Cypher namespace and drop their language prefix from the class names. Rename the factory accordingly: NeoWikiExtension::newQueryService() becomes newCypherQueryService(). Per ADR 19 each backend is its own plugin with its own native query language. Putting Cypher-specific types in their own subnamespace makes that grouping explicit and gives a clean parallel slot for the future Application\Query\Sparql sibling. Things that stay generic (no namespace move): - QueryLimits — timeout and row cap apply to any backend. - The QueryException hierarchy — most subclasses (EmptyQueryException, WriteQueryRejectedException, ParameterMissingException, QueryTimeoutException, BackendUnavailableException, InternalQueryException) are language-agnostic. CypherSyntaxException stays in the shared namespace because it carries its language in the class name. git mv preserves history on the six relocated files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit moved QueryRequest into Application\Query\Cypher but left the unqualified QueryLimits type hint in the constructor, causing PHP to resolve it as Cypher\QueryLimits — which doesn't exist (QueryLimits stayed at the parent namespace as a shared value object). CI caught this as a TypeError on every test that constructed a QueryRequest, plus PHPStan errors on QueryService accessing $request->limits->timeoutSeconds and ->maxRows. Add the missing use import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final-pre-merge review caught a real bug: deriving columns via array_keys($rowsList[0]) means an empty result reports an empty columns array, even when the query has named RETURN aliases. The spec contract promises "RETURN aliases in declaration order", which should still be true for zero-row results. SummarizedResult exposes keys() returning the protocol-level alias list — present even when there are zero rows. Use that. Update the test stub helper to accept an explicit keys list (defaults to deriving from the first row, preserving existing test cases) and add a focused test that asserts columns survive an empty result. Rename testReturnsRowsAsListWithColumnsFromFirstRow to testReturnsRowsAsListWithColumnsFromProtocolKeys to match the actual behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
68f5b63 to
666d34f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #549
Summary
POST /neowiki/v0/query/cypher— public read-only Cypher endpoint with stable JSON envelope (columns,rows,truncated,resultCount,durationMs) and stableerrorTypestrings keyed to documented HTTP statuses.nw.queryLua,{{#cypher_raw}}parser fn) through a sharedQueryServiceso validation, transaction timeout, result-row cap, and exception translation live in one place.QueryServiceand rate-limiting at the REST surface only (parser/Lua skippingLimiterbecause their output is parser-cached).Per-surface user controls:
neowiki-queryuser right (default*) gates whether anyone can call the endpoint at all. Sysadmin lockdown:$wgGroupPermissions['*']['neowiki-query'] = false;apihighlimits(defaultbot/sysop) lifts limits to theexpensivetier (300s / 50,000 rows vs default 30s / 5,000 rows).$wgRateLimits['neowiki-query']ships defaults: anon 10/min, user 60/min, bot 1000/min. Sysop/bureaucrat exempt via corenoratelimit.$wgNeoWikiQueryLimitsoverrides per-query timeout and row cap per tier.Architecture follows ADR 19 (no NeoWiki-specific query abstraction language; per-backend honesty). The future SPARQL endpoint will sit alongside as
/neowiki/v0/query/sparqlwith its own service.Files
src/Application/Query/{QueryService,QueryRequest,QueryResult,QueryLimits,QueryResultNormalizer}.php+ 7 typed exception classessrc/EntryPoints/REST/QueryCypherApi.phpdocs/QueryApi.md(discovery flow, contract, Neo4j-side deployment recommendations)CypherQueryRunner(Lua) andCypherRawParserFunction(parser fn) into thin adapters overQueryServiceextension.json(right, RateLimits, config, route),Neo4jQueryStore::runReadQuery()(timeout)Markup change
{{#cypher_raw}}output wrapping is now<div class="mw-neowiki-cypher-result"><pre>...</pre></div>(was<pre><code class="json">...</code></pre>). Anyone who styled the old markup will need to update selectors.Test plan
make phpunitfiltered by each new test class (QueryServiceTest,QueryCypherApiTest,QueryLimitsTest,QueryResultNormalizerTest,QueryExceptionTest,Neo4jQueryStoreTimeoutTest,QueryCypherEndToEndTest) and the refactored ones (CypherQueryRunnerTest,CypherRawParserFunctionTest) — all greenmake cscleanModuleSpecHandlerNeoWikiTest) still green; new route appears at/rest.php/specs/v0/module/-Manual Browser Check
The parser-function markup change is visible to anyone using
{{#cypher_raw}}on a wiki page.{{#cypher_raw}}(the demo wiki has examples; if unsure, edit a sandbox page and add{{#cypher_raw: MATCH (p:Page) RETURN p.name LIMIT 5}}).<div class="mw-neowiki-cypher-result"><pre>...</pre></div>.<pre>is pretty-printed and HTML-escaped (no<script>execution from data values).Manual API Check
The REST endpoint can be exercised directly via curl. Replace
localhost:8484with your dev wiki URL.Known gaps (deliberate)
QueryCypherApiTest::testRateLimitedReturns429ismarkTestIncomplete—pingLimiteris hard to trip in integration tests without lots of session setup; behavior is exercised by manual testing.CypherListfirst. Bounded bymaxRowsso the worst case is bounded; flagged in code review as a perf optimization to revisit if real workloads show a problem.