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
8 changes: 4 additions & 4 deletions .agentWork/milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ Milestones are planned, testable increments. They may change until implementatio
independent foundations with no functional third-party runtime dependencies.
12. **Phase 2.1 — Jackson 3 document writer:** creates the first major-specific codec artifact and
proves deterministic model-to-wire behavior.
13. **Phase 1.3 — update validation**, **Phase 2.2 — Jackson 3 write mapping**, **Phase 2.4 —
document reader**, and **Phase 2.5 — draft-schema cross-check:** may proceed in parallel after
their listed dependencies.
13. **Phase 1.3 — update validation:** completed; **Phase 2.2 — Jackson 3 write mapping**, **Phase
2.4 — document reader**, and **Phase 2.5 — draft-schema cross-check:** may proceed in parallel
after their listed dependencies.
14. **Phase 2.3 — Jackson 3 compound serialization** and **Phase 2.9 — flat DTO reader:** add
explicit inclusion and validated resource-to-DTO binding independently.
15. **Phase 2.8 — Jackson 3 sparse fieldsets** and **Phase 2.10 — typed domain envelope:** build on
Expand Down Expand Up @@ -63,7 +63,7 @@ milestone file.
- [Phase 0.10 — Task-Scoped Discovery and Documentation Pattern](phase-0-10-task-scoped-discovery-and-doc-pattern.md) — repository workflow and agent guidance — Complete
- [Phase 1.1 — Document Model and Validation](phase-1-1-spec-data-model.md) — `jsonapi-java-core` — Complete
- [Phase 1.2 — Domain-Mapping Annotations](phase-1-2-annotations.md) — `jsonapi-java-annotations` — Complete
- [Phase 1.3 — Resource Update Request Validation](phase-1-3-update-request-validation.md) — `jsonapi-java-core` — Not started
- [Phase 1.3 — Resource Update Request Validation](phase-1-3-update-request-validation.md) — `jsonapi-java-core` — Complete
- [Phase 2.1 — Jackson 3 Document Writer](phase-2-1-jackson-document-codec.md) — `jsonapi-java-jackson3` — Complete
- [Phase 2.2 — Jackson 3 Domain-to-Resource Mapping](phase-2-2-domain-resource-mapping.md) — `jsonapi-java-jackson3` — Complete
- [Phase 2.3 — Jackson 3 Compound Serialization Context](phase-2-3-compound-serialization.md) — `jsonapi-java-jackson3` — Not started
Expand Down
251 changes: 212 additions & 39 deletions .agentWork/milestones/phase-1-3-update-request-validation.md

Large diffs are not rendered by default.

24 changes: 19 additions & 5 deletions docs/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
Conformance is reported per feature as: **supported**, **pass-through**, **delegated**, **deferred**, or **out of scope**.

This checklist is seeded by Phase 1.1 (`jsonapi-java-core`), Phase 1.2
(`jsonapi-java-annotations`), Phase 2.1 (`jsonapi-java-jackson3` document writer), Phase 2.2
(`jsonapi-java-jackson3` domain-to-resource write mapping), and Phase 2.4
(`jsonapi-java-jackson3` document reader). Read-side mapping and typed envelopes remain
**deferred** to their Phase 2 milestones.
(`jsonapi-java-annotations`), Phase 1.3 (`jsonapi-java-core` update validation), Phase 2.1
(`jsonapi-java-jackson3` document writer), Phase 2.2 (`jsonapi-java-jackson3` domain-to-resource
write mapping), and Phase 2.4 (`jsonapi-java-jackson3` document reader). Read-side mapping and
typed envelopes remain **deferred** to their Phase 2 milestones.

## Document structure (Phase 1.1 — supported)

Expand Down Expand Up @@ -57,6 +57,20 @@ This checklist is seeded by Phase 1.1 (`jsonapi-java-core`), Phase 1.2
| Defensive collection copies | supported | Model types and `ValidationContext` |
| URI-reference syntax | supported | ASCII RFC 3986; structured authority; empty string allowed; raw non-ASCII rejected |

## Resource update request validation (Phase 1.3 — supported)

| Rule | Status | Notes |
|-----------------------------------------------------------------------------------------------------------------|--------------|----------------------------------------------------------------------------------------------|
| Update primary data must be one resource object (absent, null, collection, or identifier primary data rejected) | supported | `UPDATE_REQUIRES_SINGLE_RESOURCE` at `/data` |
| Update resource `id` required; lid-only rejected | supported | Reuses `RESOURCE_ID_REQUIRED` at `/data/id`; inherited non-create identity rule |
| Every supplied relationship must contain replacement `data` | supported | `RELATIONSHIP_DATA_REQUIRED` at `/data/relationships/<name>/data`; primary resource only |
| Relationship linkage preserved: null, single, empty and non-empty collection | supported | All `RelationshipData` variants valid replacements |
| Omitted/present-empty attribute and relationship wrappers; explicit-null attribute values preserved | supported | Absent vs `Attributes.empty()` vs explicit null values; no normalization |
| Optional expected endpoint identity comparison | supported | `ENDPOINT_IDENTITY_MISMATCH` at `/data/type` or `/data/id`; supplied via `ValidationContext` |
| Update rules scoped to the primary resource | supported | `included` resources keep response semantics; full linkage still enforced |
| Command application (PATCH binding) | deferred | Phases 2.11 and 2.17; adapters bind, applications apply |
| HTTP/route identity derivation and mutation | out of scope | Application-owned; core compares only a supplied expected identity |

## Annotation metadata (Phase 1.2 — supported)

| Rule | Status | Notes |
Expand Down Expand Up @@ -84,7 +98,7 @@ This checklist is seeded by Phase 1.1 (`jsonapi-java-core`), Phase 1.2
| Flat resource-to-DTO binding | deferred | Phases 2.9 and 2.15; validated document first |
| Typed domain document envelopes | deferred | Phases 2.10 and 2.16 |
| Independent typed binding of `included` resources | deferred | Phases 2.10 and 2.16; no relationship injection |
| Presence-aware resource-update commands | deferred | Phases 1.3, 2.11, and 2.17 |
| Presence-aware resource-update commands | deferred | Core update validation supported (Phase 1.3); command binding deferred to Phases 2.11 and 2.17 |
| Automatic domain graph hydration | out of scope | Linkage resolution remains application policy |
| Automatic mutation of domain or persistence objects | out of scope | Applications apply authorized update commands |

Expand Down
2 changes: 1 addition & 1 deletion jsonapi-java-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ JsonApiDocument document = JsonApiDocument.withData(
new JsonApiDocumentValidator().validate(document, ValidationContext.defaults());
```

Construct model types first (local invariants run in constructors). Call `JsonApiDocumentValidator` with a `ValidationContext` for rules that need the whole document (identity uniqueness, full linkage, extension/profile policy, and similar).
Construct model types first (local invariants run in constructors). Call `JsonApiDocumentValidator` with a `ValidationContext` for rules that need the whole document (identity uniqueness, full linkage, extension/profile policy, and similar). For update requests use `DocumentUsage.UPDATE_REQUEST`; a `withExpectedEndpointIdentity(EndpointIdentity)` context makes the validator compare the primary resource `type`+`id` against a caller-derived expected endpoint identity. HTTP/route derivation and mutation remain application-owned.

## Non-goals

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
/** Declares how a document is used for context-sensitive validation. */
public enum DocumentUsage {
CREATE_REQUEST,
UPDATE_REQUEST,
RESPONSE_OR_OTHER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.github.kazemek.jsonapi.core.validation;

/**
* Expected identity of the resource an HTTP update endpoint addresses.
*
* <p>Supplied by applications/adapters (from the request route) and compared by {@link
* JsonApiDocumentValidator} against the primary resource {@code type} and {@code id} of an {@link
* DocumentUsage#UPDATE_REQUEST} document. An absent expected identity on {@link ValidationContext}
* disables the comparison; the library never derives it from HTTP concerns.
*/
public record EndpointIdentity(String type, String id) {

public EndpointIdentity {
type =
LocalValidation.requireNonNull(
type, "/endpointIdentity/type", "Endpoint identity type must not be null");
id =
LocalValidation.requireNonNull(
id, "/endpointIdentity/id", "Endpoint identity id must not be null");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ public void validate(JsonApiDocument document, ValidationContext context) {
if (document.errors() != null) {
validateErrors(document.errors(), context);
}
if (document.data() != null) {
if (document.data() == null) {
if (context.documentUsage() == DocumentUsage.UPDATE_REQUEST) {
throw new JsonApiValidationException(
ValidationRuleCode.UPDATE_REQUIRES_SINGLE_RESOURCE,
PATH_DATA,
"Update request requires primary data as a single resource object");
}
} else {
validatePrimaryData(document.data(), context);
}
if (document.included() != null || document.data() != null) {
Expand All @@ -84,15 +91,22 @@ private void validatePrimaryData(@Nullable DocumentData data, ValidationContext
if (data == null) {
return;
}
if (context.documentUsage() == DocumentUsage.UPDATE_REQUEST
&& !(data instanceof DocumentData.SingleResource)) {
throw new JsonApiValidationException(
ValidationRuleCode.UPDATE_REQUIRES_SINGLE_RESOURCE,
PATH_DATA,
"Update request requires primary data as a single resource object");
}
switch (data) {
case DocumentData.NullData ignored -> {
// Explicit null primary data has no nested members to validate.
}
case DocumentData.SingleResource(ResourceObject resource) ->
validateResource(resource, PATH_DATA, context);
validateResource(resource, PATH_DATA, true, context);
case DocumentData.ResourceCollection(List<ResourceObject> resources) -> {
for (int index = 0; index < resources.size(); index++) {
validateResource(resources.get(index), PATH_DATA + "/" + index, context);
validateResource(resources.get(index), PATH_DATA + "/" + index, false, context);
}
}
case DocumentData.SingleIdentifier(ResourceIdentifier identifier) ->
Expand All @@ -106,14 +120,16 @@ private void validatePrimaryData(@Nullable DocumentData data, ValidationContext
}
}

private void validateResource(ResourceObject resource, String path, ValidationContext context) {
private void validateResource(
ResourceObject resource, String path, boolean primary, ValidationContext context) {
validateResourceIdentity(resource, path, context);
validateUpdateEndpointIdentity(resource, path, primary, context);
if (resource.attributes() != null) {
validateAdditionalMembers(
resource.attributes().additionalMembers(), path + "/attributes", context);
}
if (resource.relationships() != null) {
validateResourceRelationships(resource, path, context);
validateResourceRelationships(resource, path, primary, context);
}
if (resource.links() != null) {
validateLinks(
Expand All @@ -132,13 +148,22 @@ private void validateResource(ResourceObject resource, String path, ValidationCo
}

private void validateResourceRelationships(
ResourceObject resource, String path, ValidationContext context) {
ResourceObject resource, String path, boolean primary, ValidationContext context) {
Relationships relationships = Objects.requireNonNull(resource.relationships());
validateAdditionalMembers(
relationships.additionalMembers(), path + PATH_RELATIONSHIPS, context);
for (Map.Entry<String, Relationship> entry : relationships.relationships().entrySet()) {
Relationship relationship = entry.getValue();
if (context.documentUsage() == DocumentUsage.UPDATE_REQUEST
&& primary
&& !relationship.hasDataMember()) {
throw new JsonApiValidationException(
ValidationRuleCode.RELATIONSHIP_DATA_REQUIRED,
JsonPointers.child(path + PATH_RELATIONSHIPS, entry.getKey()) + PATH_DATA,
"Update request relationship must contain data");
}
validateRelationship(
entry.getValue(),
relationship,
JsonPointers.child(path + PATH_RELATIONSHIPS, entry.getKey()),
context,
resource.type(),
Expand Down Expand Up @@ -300,6 +325,29 @@ private void validateResourceIdentity(
}
}

private void validateUpdateEndpointIdentity(
ResourceObject resource, String path, boolean primary, ValidationContext context) {
if (context.documentUsage() != DocumentUsage.UPDATE_REQUEST || !primary) {
return;
}
EndpointIdentity expected = context.expectedEndpointIdentity();
if (expected == null) {
return;
}
if (!expected.type().equals(resource.type())) {
throw new JsonApiValidationException(
ValidationRuleCode.ENDPOINT_IDENTITY_MISMATCH,
path + "/type",
"Update resource type does not match the expected endpoint identity: " + expected.type());
}
if (!expected.id().equals(Objects.requireNonNull(resource.id()))) {
throw new JsonApiValidationException(
ValidationRuleCode.ENDPOINT_IDENTITY_MISMATCH,
path + "/id",
"Update resource id does not match the expected endpoint identity: " + expected.id());
}
}

private void validateError(ErrorObject error, String path, ValidationContext context) {
if (error.links() != null) {
validateLinks(
Expand Down Expand Up @@ -377,7 +425,7 @@ private void validateCompoundDocument(
for (int index = 0; index < included.size(); index++) {
ResourceObject resource = included.get(index);
String path = "/included/" + index;
validateResource(resource, path, context);
validateResource(resource, path, false, context);
registerIncludedResource(resource, path, registry);
registerLinkageFromResource(resource, path, registry);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.jspecify.annotations.Nullable;

/**
* Context for aggregate document validation.
*
* <p>Carries document usage (for example create vs response), allowed extension namespaces and
* profile URIs/member names, the sparse-fieldset full-linkage exception, the current links context,
* and occurrence-keyed relationship pagination hints for link-only relationships.
* <p>Carries document usage (for example create, update, or response), allowed extension namespaces
* and profile URIs/member names, the sparse-fieldset full-linkage exception, the current links
* context, occurrence-keyed relationship pagination hints for link-only relationships, and an
* optional expected endpoint identity compared against {@link DocumentUsage#UPDATE_REQUEST}
* documents.
*
* <p>{@link #defaults()} uses {@link DocumentUsage#RESPONSE_OR_OTHER}, empty policy sets, no sparse
* fieldset exception, {@link LinksContext#TOP_LEVEL}, and no pagination hints—suitable for
* base-spec response documents without extensions or profiles.
* fieldset exception, {@link LinksContext#TOP_LEVEL}, no pagination hints, and no expected endpoint
* identity—suitable for base-spec response documents without extensions or profiles.
*/
public record ValidationContext(
DocumentUsage documentUsage,
Expand All @@ -25,7 +28,8 @@ public record ValidationContext(
Set<String> allowedProfileMemberNames,
boolean sparseFieldsetException,
LinksContext linksContext,
Map<RelationshipPaginationKey, RelationshipCardinality> relationshipPaginationHints) {
Map<RelationshipPaginationKey, RelationshipCardinality> relationshipPaginationHints,
@Nullable EndpointIdentity expectedEndpointIdentity) {

private static final String PATH_RELATIONSHIP_PAGINATION_HINTS = "/relationshipPaginationHints";

Expand Down Expand Up @@ -62,7 +66,8 @@ public static ValidationContext defaults() {
Set.of(),
false,
LinksContext.TOP_LEVEL,
Map.of());
Map.of(),
null);
}

public ValidationContext withDocumentUsage(DocumentUsage usage) {
Expand All @@ -73,7 +78,8 @@ public ValidationContext withDocumentUsage(DocumentUsage usage) {
allowedProfileMemberNames,
sparseFieldsetException,
linksContext,
relationshipPaginationHints);
relationshipPaginationHints,
expectedEndpointIdentity);
}

public ValidationContext withLinksContext(LinksContext context) {
Expand All @@ -84,7 +90,8 @@ public ValidationContext withLinksContext(LinksContext context) {
allowedProfileMemberNames,
sparseFieldsetException,
context,
relationshipPaginationHints);
relationshipPaginationHints,
expectedEndpointIdentity);
}

public ValidationContext withSparseFieldsetException(boolean enabled) {
Expand All @@ -95,7 +102,21 @@ public ValidationContext withSparseFieldsetException(boolean enabled) {
allowedProfileMemberNames,
enabled,
linksContext,
relationshipPaginationHints);
relationshipPaginationHints,
expectedEndpointIdentity);
}

/** Returns a context whose expected endpoint identity is compared for update documents. */
public ValidationContext withExpectedEndpointIdentity(@Nullable EndpointIdentity identity) {
return new ValidationContext(
documentUsage,
allowedExtensionNamespaces,
allowedProfileUris,
allowedProfileMemberNames,
sparseFieldsetException,
linksContext,
relationshipPaginationHints,
identity);
}

/** Returns the explicit cardinality hint for a relationship occurrence, if present. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@ public enum ValidationRuleCode {
UNKNOWN_ADDITIONAL_MEMBER,
DISALLOWED_ADDITIONAL_MEMBER,
DUPLICATE_RESOURCE_IDENTITY,
ENDPOINT_IDENTITY_MISMATCH,
FULL_LINKAGE_VIOLATION,
INCONSISTENT_LOCAL_IDENTIFIER,
INCLUDED_RESOURCE_IDENTITY_REQUIRED,
RESOURCE_ID_REQUIRED,
NULL_RELATIONSHIP_VALUE,
INVALID_LINKS_CONTEXT,
RELATIONSHIP_DATA_REQUIRED,
RELATIONSHIP_PAGINATION_REQUIRES_HINT,
PAGINATION_REQUIRES_COLLECTION,
NULL_COLLECTION_PAYLOAD,
NULL_COLLECTION_ELEMENT,
NULL_REQUIRED_VALUE
NULL_REQUIRED_VALUE,
UPDATE_REQUIRES_SINGLE_RESOURCE
}
Loading