Skip to content

Replace PATCH /subject endpoint with PUT#819

Merged
JeroenDeDauw merged 14 commits into
masterfrom
280-put-subject-api
May 13, 2026
Merged

Replace PATCH /subject endpoint with PUT#819
JeroenDeDauw merged 14 commits into
masterfrom
280-put-subject-api

Conversation

@alistair3149
Copy link
Copy Markdown
Member

@alistair3149 alistair3149 commented May 12, 2026

Went though the manual browser check steps and did a few curls to validate.

Fixes #280

Summary

Replaces PATCH /neowiki/v0/subject/{subjectId} with PUT /neowiki/v0/subject/{subjectId} and makes the contract honest. The PATCH endpoint claimed RFC 7396 merge-patch semantics but did not deliver them: statements was required (omission was not "leave unchanged"), and null-deletion of statements was a long-standing TODO. The new PUT endpoint replaces the writable view of a Subject in one shot — omit a statement key to delete it, label is required and non-empty.

Wire-level changes

  • Route: PATCH /neowiki/v0/subject/{subjectId}PUT /neowiki/v0/subject/{subjectId}
  • Request body: same {label, statements, comment} shape, but label is now required (non-empty after trim) and statements keys not in the map are deleted from the Subject. Pass {} to clear all statements. Immutable fields (id, schema, pageId, pageTitle) are not part of the body and are ignored if sent.
  • Response: 200 with {status: \"updated\", subjectId: ...} on success. 400 for missing/empty label or missing statements. 403 for unauthorized. 404 for non-existent Subject.

Pre-1.0 — third-party consumers were warned at docs/RestApi.md that /neowiki/v0/* is not stable. No back-compat shims.

Code structure

The handler/action pair is named after the use case rather than the HTTP method, matching existing Application/Actions/ conventions (CreateSubject, DeleteSubject, SetMainSubject):

  • src/EntryPoints/REST/ReplaceSubjectApi.php — REST adapter. Catches InvalidArgumentException → 400, SubjectNotFoundException → 404, SubjectEditNotAuthorizedException → 403. Anything else propagates as 500.
  • src/Application/Actions/ReplaceSubject/ReplaceSubjectAction.php — application use case: authorize → load (404 if absent) → setLabel → setStatements via StatementListBuilder (with SelectStatementResolver) → save.
  • src/Application/StatementListBuilder.php — deserializes a request statements array into a StatementList. Absorbed the StatementListPatcher (its merge code path was unreachable after this refactor).
  • src/Application/Subject/Exception/SubjectNotFoundException.php, src/Application/Subject/Exception/SubjectEditNotAuthorizedException.php — typed exceptions, mirroring the Application/Query/Exception/ layout from Add public Cypher query REST API + close #549 #817.
  • src/Application/SelectStatementResolver.php — renamed from SelectPatchResolver (the "Patch" name was tied to the old endpoint).
  • src/Domain/Subject/Subject::setStatements() — symmetric counterpart to setLabel(). Subject::patchStatements() removed (dead).
  • src/Domain/Subject/SubjectLabel — constructor now rejects empty/whitespace-only strings.

The SubjectLabel hardening also affects CreateSubjectApi: that handler only caught RuntimeException, so an empty-label POST would have escaped as an unhandled exception. Added an InvalidArgumentException catch returning 400 plus a regression test.

The TS frontend (RestSubjectRepository.updateSubject) now calls httpClient.put. The InMemoryHttpClient test mock routes by URL only; added an explicit spy assertion to verify the method change.

Test plan

  • make phpunit filter=ReplaceSubjectApiTest — 10 tests including 200 happy path, omit-to-delete, empty-map clear-all, missing/empty label → 400, missing statements → 400, non-existent subject → 404, 403 unauthorized
  • make phpunit filter=ReplaceSubjectActionTest — 8 tests covering the action's full matrix (label replace, statements replace, omit deletion, comment forwarding, typed-exception throws, select resolution)
  • make phpunit filter=StatementListBuilderTest — 4 tests
  • make phpunit filter=SelectStatementResolverTest — preserved from rename
  • make phpunit filter=SubjectLabelTest — 4 tests for the new validation
  • make phpunit filter=CreateSubjectApiTest — 5 tests including the new testEmptyLabelReturns400 regression
  • make phpunit filter=ModuleSpecHandlerNeoWikiTest — OpenAPI spec drift, confirms PUT is registered and PATCH is gone
  • make phpunit filter=Application and filter=EntryPoints — no regressions
  • make cs (phpcs + phpstan) clean
  • make ts-test — 782/782 pass; new spy test asserts httpClient.put is called
  • make ts-lint clean
  • Live API smoke — exercised happy/400/403/404/405 paths against the dev wiki via curl
  • Live UI smoke — exercised SubjectsManager edit + omit-to-delete via Chrome devtools, confirmed PUT body structure and end-to-end deletion

Note: full unfiltered make phpunit was not run; it hangs in the local dev env on something pre-existing (a Persistence/Neo4j integration test that doesn't terminate). All filtered scopes touching this change pass.

Manual API Check

The PUT endpoint can be exercised via curl. Replace localhost:8484 with your dev wiki URL.

CJAR=\$(mktemp)
TOKEN=\$(curl -sS -c \"\$CJAR\" \"http://localhost:8484/api.php?action=query&meta=tokens&type=csrf&format=json\" | python3 -c 'import json,sys;print(json.load(sys.stdin)[\"query\"][\"tokens\"][\"csrftoken\"])')

# Happy path — replace and reduce statements
curl -sS -w '\nHTTP %{http_code}\n' -b \"\$CJAR\" -X PUT \"http://localhost:8484/rest.php/neowiki/v0/subject/s1demo7aaaaaaa4\" \\
  -H 'Content-Type: application/json' -H \"X-CSRF-TOKEN: \$TOKEN\" \\
  -d '{\"label\":\"ACME Amsterdam HQ\",\"statements\":{\"Description\":{\"propertyType\":\"text\",\"value\":[\"Smoke\"]}},\"comment\":\"smoke\"}'
# Expect: 200 with {\"status\":\"updated\",\"subjectId\":\"s1demo7aaaaaaa4\"}

# Empty label rejected
curl -sS -w '\nHTTP %{http_code}\n' -b \"\$CJAR\" -X PUT \"http://localhost:8484/rest.php/neowiki/v0/subject/s1demo7aaaaaaa4\" \\
  -H 'Content-Type: application/json' -H \"X-CSRF-TOKEN: \$TOKEN\" \\
  -d '{\"label\":\"   \",\"statements\":{}}'
# Expect: 400 with {\"status\":\"error\",\"message\":\"SubjectLabel cannot be empty\"...}

# Non-existent subject → 404
curl -sS -w '\nHTTP %{http_code}\n' -b \"\$CJAR\" -X PUT \"http://localhost:8484/rest.php/neowiki/v0/subject/sDoesNotExist99\" \\
  -H 'Content-Type: application/json' -H \"X-CSRF-TOKEN: \$TOKEN\" \\
  -d '{\"label\":\"X\",\"statements\":{}}'
# Expect: 404 with {\"status\":\"error\",\"message\":\"Subject not found: sDoesNotExist99\"...}

# Old PATCH route is gone
curl -sS -w '\nHTTP %{http_code}\n' -X PATCH \"http://localhost:8484/rest.php/neowiki/v0/subject/s1demo7aaaaaaa4\"
# Expect: 405 \"allowed methods for this path (HEAD, GET, PUT, DELETE)\"

Manual Browser Check

The SubjectsManager UI is the only consumer of the endpoint today. Run make ts-build first so the live wiki uses the updated bundle.

  1. Visit a subject-bearing page, e.g. http://localhost:8484/index.php/ACME_Amsterdam_HQ
  2. Click the Edit button on the infobox.
  3. Modify any field (e.g. add a suffix to Description). Click Save changes.
  4. In devtools Network tab, confirm:
    • Method is PUT (not PATCH).
    • URL is /rest.php/neowiki/v0/subject/s1demo7aaaaaaa4.
    • Status is 200.
    • Response body is {\"status\":\"updated\",\"subjectId\":\"s1demo7aaaaaaa4\"}.
  5. Reload the page and confirm the change persisted.
  6. Open the editor again, clear the Address field (also tweak another field to enable the Save button), save. Confirm the PUT body omits the Address key (it's deleted, not sent as null/empty).
  7. Reload and confirm Address is gone from the infobox.

Known limitations / follow-ups

These are intentionally out of scope here:

  • SubjectsManager UI audit for omit-to-delete safety. Browser test in this PR confirmed the editor flow is correct: empty optional field → omitted key → server-side deletion. Worth a deeper audit covering property-rename and any other paths that build the in-memory statement list, since the new contract makes any incomplete in-memory build a silent-deletion bug. Tracking issue to follow.
  • Save button doesn't enable on field-clear alone. Clearing a text field requires touching another field to make the SubjectsManager treat the form as dirty. Pre-existing behavior, not a regression, but it makes the deletion path discoverable only via accident. Worth folding into the SubjectsManager audit.
  • type (read) vs propertyType (write) asymmetry on Statement objects.
  • Statement-level CRUD endpoint (REST API endpoint for updating a single Statement #591) — depends on this PR for the addressing scheme to stabilize.

Result of extensive back and forth with @alistair3149.
Context: the NeoWiki codebase, issue #280, malberts's open comment about UI delete/rename flows, and the project's hexagonal-architecture conventions.
Written by Claude Code, `Opus 4.7`

alistair3149 and others added 14 commits May 12, 2026 17:33
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CreateSubjectApi: catch InvalidArgumentException → 400. SubjectLabel hardening
  in commit d0c4f7b would otherwise let an empty-label POST escape as 500.
  Add testEmptyLabelReturns400 regression test.
- TS spec: add explicit assertion that updateSubject calls httpClient.put (not
  patch). InMemoryHttpClient routes by URL only, so the prior tests passed
  regardless of method.
- SubjectFormat.md: restore POST in the doc intro's write-methods list (PUT
  alone misrepresents the API; the Create endpoint is still POST).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Split testOmittedStatementIsDeleted into two tests, one per behavior
  (omitted-key deletion and empty-map deletion). Per AGENTS.md "test one
  thing per test function".
- Add testNonExistentSubjectReturns404. Spec required this case but it was
  missed in T4. Action-level coverage existed; this exercises the handler's
  RuntimeException → 404 dispatch end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The select-property write description still said 'create/patch statement'.
Replace endpoint is now PUT, so 'create/replace' matches the surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReplaceSubjectApi was using str_contains($e->getMessage(), 'not found')
to dispatch RuntimeException to either 404 or 403. A future refactor
that touched the message would have silently broken the 404 behavior.

Introduce SubjectNotFoundException and SubjectEditNotAuthorizedException
in src/Application/. ReplaceSubjectAction throws the specific types;
ReplaceSubjectApi catches each and maps to its status. The bare
RuntimeException catch is removed — anything unexpected from deeper
layers now surfaces as 500, which is more accurate than silent 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ct exceptions

- Rename SelectPatchResolver → SelectStatementResolver. The "Patch" name
  was tied to the old PATCH endpoint; the resolver itself just walks an
  incoming statements array and resolves select-typed values to canonical
  option IDs, regardless of which write endpoint called it.
- Move Subject exceptions to src/Application/Subject/Exception/, mirroring
  the src/Application/Query/Exception/ layout introduced by the public
  query API. Keeps domain-grouped exceptions together as both areas grow.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After PATCH→PUT, the patcher's merge-into-existing-list code path was
unreachable: every caller (CreateSubject, ReplaceSubject) builds a
StatementList from an empty starting point. That left two classes for
one job — Builder wrapping Patcher with an empty seed.

Inline the deserialization logic into StatementListBuilder and delete
StatementListPatcher. Subject::patchStatements (last user of the patcher
via Subject) is dead and removed. CreateSubjectAction now takes a
StatementListBuilder directly.

No behavior change. Pre-existing baselined phpstan errors moved with the
code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alistair3149 alistair3149 marked this pull request as ready for review May 12, 2026 23:22
@JeroenDeDauw JeroenDeDauw merged commit 01d9479 into master May 13, 2026
9 checks passed
@JeroenDeDauw JeroenDeDauw deleted the 280-put-subject-api branch May 13, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor Subject PATCH endpoint/Action to PUT

2 participants