Skip to content

Commit 37d6a7d

Browse files
authored
fix!: correct JSON:API 1.1 conformance defects and harden codecs
BREAKING CHANGE: Link.ObjectLink.describedby is now a nested Link, and ValidationRuleCode.RELATIONSHIP_PAGINATION_REQUIRES_HINT is removed.
1 parent f17cfff commit 37d6a7d

36 files changed

Lines changed: 989 additions & 94 deletions

‎docs/conformance.md‎

Lines changed: 43 additions & 3 deletions
Large diffs are not rendered by default.

‎jsonapi-java-core/src/main/java/io/github/kazemek/jsonapi/core/model/Link.java‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
*
1515
* @apiNote {@link StringLink} is the string form (URI reference). {@link ObjectLink} requires
1616
* {@code href} and may carry {@code rel}, {@code describedby}, {@code title}, {@code type},
17-
* {@code hreflang}, {@code meta}, and additional members. {@code hreflang} is modeled as a
18-
* list; codec emission of single vs array forms is deferred to the Jackson module.
17+
* {@code hreflang}, {@code meta}, and additional members. JSON:API permits a link to be an
18+
* explicit {@code null}; that state shares this model's {@code null} absence representation, so
19+
* an omitted or explicitly-null {@code describedby} is {@code null}. When present it is another
20+
* {@link Link} in string or object form. {@code hreflang} is modeled as a list; codec emission
21+
* of single vs array forms is deferred to the Jackson module.
1922
*/
2023
public sealed interface Link permits Link.StringLink, Link.ObjectLink {
2124

@@ -28,7 +31,7 @@ record StringLink(String href) implements Link {
2831
record ObjectLink(
2932
String href,
3033
@Nullable String rel,
31-
@Nullable String describedby,
34+
@Nullable Link describedby,
3235
@Nullable String title,
3336
@Nullable String type,
3437
@Nullable List<String> hreflang,
@@ -54,12 +57,6 @@ record ObjectLink(
5457
path() + "/rel",
5558
"Invalid link relation: " + rel);
5659
}
57-
if (describedby != null && !SyntaxValidators.isValidUriReference(describedby)) {
58-
LocalValidation.fail(
59-
ValidationRuleCode.INVALID_URI_REFERENCE,
60-
path() + "/describedby",
61-
"Invalid describedby URI: " + describedby);
62-
}
6360
if (type != null && !SyntaxValidators.isValidMediaType(type)) {
6461
LocalValidation.fail(
6562
ValidationRuleCode.INVALID_MEDIA_TYPE, path() + "/type", "Invalid media type: " + type);

‎jsonapi-java-core/src/main/java/io/github/kazemek/jsonapi/core/model/ResourceObject.java‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.kazemek.jsonapi.core.model;
22

33
import io.github.kazemek.jsonapi.core.internal.AdditionalMembers;
4+
import io.github.kazemek.jsonapi.core.internal.JsonPointers;
45
import io.github.kazemek.jsonapi.core.validation.LocalValidation;
56
import io.github.kazemek.jsonapi.core.validation.MemberNames;
67
import io.github.kazemek.jsonapi.core.validation.ValidationRuleCode;
@@ -9,7 +10,13 @@
910
import java.util.Set;
1011
import org.jspecify.annotations.Nullable;
1112

12-
/** A JSON:API resource object. */
13+
/**
14+
* A JSON:API resource object.
15+
*
16+
* <p>Attributes and relationships share one field namespace. Semantic members and non-{@code @}
17+
* pass-through members cannot use the same name; {@code @}-prefixed pass-through members remain
18+
* outside that namespace. Extension and profile authorization remains aggregate-validator policy.
19+
*/
1320
public record ResourceObject(
1421
String type,
1522
@Nullable String id,
@@ -68,6 +75,9 @@ public boolean hasLid() {
6875
return null;
6976
}
7077

78+
// NullAway misreads ResourceIdentifier's type-use @Nullable on class-path inputs during
79+
// incremental compiles; the component types are identical and the conversion is safe.
80+
@SuppressWarnings("NullAway")
7181
public ResourceIdentifier toIdentifier() {
7282
return new ResourceIdentifier(type, id, lid, meta, additionalMembers);
7383
}
@@ -85,12 +95,23 @@ private static void validateFieldNamespace(
8595
return;
8696
}
8797
for (String name : attributes.attributes().keySet()) {
88-
if (relationships.relationships().containsKey(name)) {
89-
LocalValidation.fail(
90-
ValidationRuleCode.MEMBER_NAME_COLLISION,
91-
"/data",
92-
"Attribute and relationship name collision: " + name);
93-
}
98+
validateAttributeFieldName(name, relationships);
99+
}
100+
for (String name : attributes.additionalMembers().keySet()) {
101+
validateAttributeFieldName(name, relationships);
102+
}
103+
}
104+
105+
private static void validateAttributeFieldName(String name, Relationships relationships) {
106+
if (MemberNames.isAtMember(name)) {
107+
return;
108+
}
109+
if (relationships.relationships().containsKey(name)
110+
|| relationships.additionalMembers().containsKey(name)) {
111+
LocalValidation.fail(
112+
ValidationRuleCode.MEMBER_NAME_COLLISION,
113+
JsonPointers.child("/data/relationships", name),
114+
"Attribute and relationship name collision: " + name);
94115
}
95116
}
96117
}

‎jsonapi-java-core/src/main/java/io/github/kazemek/jsonapi/core/validation/JsonApiDocumentValidator.java‎

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import java.util.List;
2323
import java.util.Map;
2424
import java.util.Objects;
25-
import java.util.Optional;
2625
import java.util.Queue;
2726
import java.util.Set;
2827
import org.jspecify.annotations.Nullable;
@@ -794,13 +793,7 @@ private void validateLinks(
794793
}
795794
validateAdditionalMembers(links.additionalMembers(), path, context);
796795
for (Map.Entry<String, @Nullable Link> entry : links.links().entrySet()) {
797-
if (entry.getValue() instanceof Link.ObjectLink objectLink) {
798-
String linkPath = JsonPointers.child(path, entry.getKey());
799-
validateAdditionalMembers(objectLink.additionalMembers(), linkPath, context);
800-
if (objectLink.meta() != null) {
801-
validateMeta(objectLink.meta(), linkPath + PATH_META, context);
802-
}
803-
}
796+
validateLinkValue(entry.getValue(), JsonPointers.child(path, entry.getKey()), context);
804797
validateLinkEntry(
805798
entry.getKey(),
806799
path,
@@ -812,6 +805,20 @@ private void validateLinks(
812805
}
813806
}
814807

808+
private void validateLinkValue(@Nullable Link link, String path, ValidationContext context) {
809+
if (!(link instanceof Link.ObjectLink objectLink)) {
810+
return;
811+
}
812+
validateAdditionalMembers(objectLink.additionalMembers(), path, context);
813+
if (objectLink.meta() != null) {
814+
validateMeta(objectLink.meta(), path + PATH_META, context);
815+
}
816+
if (objectLink.describedby() != null) {
817+
validateLinkValue(
818+
objectLink.describedby(), JsonPointers.child(path, JsonApiMembers.DESCRIBEDBY), context);
819+
}
820+
}
821+
815822
private void validateLinkEntry(
816823
String name,
817824
String path,
@@ -881,17 +888,9 @@ private void validatePaginationLink(
881888
}
882889
return;
883890
}
884-
Optional<RelationshipCardinality> hint =
885-
resourceType == null
886-
? Optional.empty()
887-
: context.relationshipPaginationHint(resourceType, relationshipName);
888-
if (hint.isEmpty()) {
889-
throw new JsonApiValidationException(
890-
ValidationRuleCode.RELATIONSHIP_PAGINATION_REQUIRES_HINT,
891-
JsonPointers.child(path, name),
892-
"Relationship pagination link requires cardinality hint: " + relationshipName);
893-
}
894-
if (hint.get() == RelationshipCardinality.TO_ONE) {
891+
if (resourceType != null
892+
&& context.relationshipPaginationHint(resourceType, relationshipName).orElse(null)
893+
== RelationshipCardinality.TO_ONE) {
895894
throw paginationRequiresCollection(path, name, relationshipName);
896895
}
897896
}

‎jsonapi-java-core/src/main/java/io/github/kazemek/jsonapi/core/validation/RelationshipCardinality.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package io.github.kazemek.jsonapi.core.validation;
22

3-
/** Explicit cardinality for link-only relationship pagination hints. */
3+
/** Optional cardinality hint for relationship pagination when linkage is absent. */
44
public enum RelationshipCardinality {
55
TO_ONE,
66
TO_MANY

‎jsonapi-java-core/src/main/java/io/github/kazemek/jsonapi/core/validation/ValidationContext.java‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
*
1515
* <p>Carries document usage (for example create, update, or response), allowed extension namespaces
1616
* and profile URIs/member names, sparse-fieldset linkage exemptions, the current links context,
17-
* occurrence-keyed relationship pagination hints for link-only relationships, and an optional
18-
* expected endpoint identity compared against {@link DocumentUsage#UPDATE_REQUEST} documents.
17+
* optional occurrence-keyed relationship pagination cardinality hints, and an optional expected
18+
* endpoint identity compared against {@link DocumentUsage#UPDATE_REQUEST} documents. Relationship
19+
* pagination is allowed with absent or collection linkage; explicit null and single linkage are
20+
* rejected, and a {@link RelationshipCardinality#TO_ONE} hint rejects pagination when linkage is
21+
* absent.
1922
*
2023
* <p>Sparse-fieldset linkage exemptions name included resources whose inbound linkage was removed
2124
* by an applied sparse fieldset, so full-linkage validation treats those resources as reachable

‎jsonapi-java-core/src/main/java/io/github/kazemek/jsonapi/core/validation/ValidationRuleCode.java‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ public enum ValidationRuleCode {
3737
NULL_RELATIONSHIP_VALUE,
3838
INVALID_LINKS_CONTEXT,
3939
RELATIONSHIP_DATA_REQUIRED,
40-
RELATIONSHIP_PAGINATION_REQUIRES_HINT,
4140
PAGINATION_REQUIRES_COLLECTION,
4241
NULL_COLLECTION_PAYLOAD,
4342
NULL_COLLECTION_ELEMENT,

‎jsonapi-java-core/src/test/groovy/io/github/kazemek/jsonapi/core/model/LinkSpec.groovy‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,30 @@ class LinkSpec extends Specification {
3838
!differentAdditional.isEmpty()
3939
}
4040

41-
def "object link factory and describedby validation are covered"() {
41+
def "object link factory preserves an omitted describedby link"() {
4242
when:
4343
def link = Link.ObjectLink.ofHref("https://example.com")
4444

4545
then:
4646
link.href() == "https://example.com"
47+
link.describedby() == null
48+
}
49+
50+
def "object link describedby accepts string and object links recursively"() {
51+
given:
52+
def stringDescription = new Link.StringLink("https://example.com/schema")
53+
def objectDescription = new Link.ObjectLink(
54+
"https://example.com/description", null, stringDescription, null, null, null, null, [:])
4755

4856
when:
49-
new Link.ObjectLink("https://example.com", null, "bad href", null, null, null, null, [:])
57+
def link = new Link.ObjectLink(
58+
"https://example.com/resource", null, objectDescription, null, null, null, null, [:])
5059

5160
then:
52-
def ex = thrown(JsonApiValidationException)
53-
ex.ruleCode() == ValidationRuleCode.INVALID_URI_REFERENCE
61+
link.describedby() == objectDescription
62+
((Link.ObjectLink) link.describedby()).describedby() == stringDescription
5463
}
64+
5565
def "hreflang canonical list representation accepts single language"() {
5666
when:
5767
def link = Link.ObjectLink.withHreflang("http://example.com", "en")

‎jsonapi-java-core/src/test/groovy/io/github/kazemek/jsonapi/core/model/ResourceObjectSpec.groovy‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,34 @@ class ResourceObjectSpec extends Specification {
134134
def ex = thrown(JsonApiValidationException)
135135
ex.ruleCode() == ValidationRuleCode.INVALID_MEMBER_NAME
136136
}
137+
138+
def "non-at pass-through fields share the resource field namespace"() {
139+
when:
140+
new ResourceObject("articles", "1", null, attributes, relationships, null, null, [:])
141+
142+
then:
143+
def ex = thrown(JsonApiValidationException)
144+
ex.ruleCode() == ValidationRuleCode.MEMBER_NAME_COLLISION
145+
ex.jsonPointer() == "/data/relationships/" + name
146+
147+
where:
148+
name | attributes | relationships
149+
"author" | Attributes.ofAttributes([author: "semantic"]) | Relationships.ofRelationships([author: Relationship.metaOnly(Meta.of([count: 1]))])
150+
"author" | Attributes.ofAttributes([author: "semantic"]) | Relationships.of([:], [author: "pass-through"])
151+
"author" | Attributes.of([:], [author: "pass-through"]) | Relationships.ofRelationships([author: Relationship.metaOnly(Meta.of([count: 1]))])
152+
"author" | Attributes.of([:], [author: "attribute-pass-through"]) | Relationships.of([:], [author: "relationship-pass-through"])
153+
"ext:author" | Attributes.of([:], ["ext:author": "attribute"]) | Relationships.of([:], ["ext:author": "relationship"])
154+
}
155+
156+
def "at pass-through fields are outside the resource field namespace"() {
157+
when:
158+
new ResourceObject(
159+
"articles", "1", null,
160+
Attributes.of([:], ["@context": "attribute-context"]),
161+
Relationships.of([:], ["@context": "relationship-context"]),
162+
null, null, [:])
163+
164+
then:
165+
noExceptionThrown()
166+
}
137167
}

‎jsonapi-java-core/src/test/groovy/io/github/kazemek/jsonapi/core/validation/JsonApiDocumentValidatorSpec.groovy‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ class JsonApiDocumentValidatorSpec extends Specification {
260260
noExceptionThrown()
261261
}
262262

263-
def "relationship pagination requires cardinality hint"() {
263+
def "relationship pagination with absent linkage is allowed without cardinality hint"() {
264264
given:
265265
def article = new ResourceObject(
266266
"articles", "1", null, null,
@@ -277,11 +277,10 @@ class JsonApiDocumentValidatorSpec extends Specification {
277277
validator.validate(doc, ValidationContext.defaults())
278278

279279
then:
280-
def ex = thrown(JsonApiValidationException)
281-
ex.ruleCode() == ValidationRuleCode.RELATIONSHIP_PAGINATION_REQUIRES_HINT
280+
noExceptionThrown()
282281
}
283282

284-
def "relationship pagination passes with hint"() {
283+
def "relationship pagination with absent linkage passes with TO_MANY hint"() {
285284
given:
286285
def article = new ResourceObject(
287286
"articles", "1", null, null,
@@ -312,7 +311,7 @@ class JsonApiDocumentValidatorSpec extends Specification {
312311
noExceptionThrown()
313312
}
314313

315-
def "explicit to-one pagination hint rejects pagination"() {
314+
def "TO_ONE pagination hint rejects absent linkage"() {
316315
given:
317316
def article = new ResourceObject(
318317
"articles", "1", null, null,
@@ -342,6 +341,7 @@ class JsonApiDocumentValidatorSpec extends Specification {
342341
then:
343342
def ex = thrown(JsonApiValidationException)
344343
ex.ruleCode() == ValidationRuleCode.PAGINATION_REQUIRES_COLLECTION
344+
ex.jsonPointer() == "/data/relationships/comments/links/first"
345345
}
346346

347347
def "same relationship name with TO_MANY hint allows pagination"() {
@@ -743,6 +743,29 @@ class JsonApiDocumentValidatorSpec extends Specification {
743743
noExceptionThrown()
744744
}
745745

746+
def "disallowed extension member on recursive describedby object link is rejected"() {
747+
given:
748+
def describedby = new Link.ObjectLink(
749+
"https://example.com/articles/schema", null, null, null, null, null, null,
750+
["ext:flag": true])
751+
def doc = new JsonApiDocument(
752+
new DocumentData.SingleResource(ResourceObject.of("articles", "1")),
753+
null, null, null,
754+
Links.ofLinks([
755+
self: new Link.ObjectLink(
756+
"https://example.com/articles/1", null, describedby, null, null, null, null, [:])
757+
]),
758+
null, [:])
759+
760+
when:
761+
validator.validate(doc, ValidationContext.defaults())
762+
763+
then:
764+
def ex = thrown(JsonApiValidationException)
765+
ex.ruleCode() == ValidationRuleCode.DISALLOWED_ADDITIONAL_MEMBER
766+
ex.jsonPointer() == "/links/self/describedby/ext:flag"
767+
}
768+
746769
def "profile-permitted links-only relationship is accepted"() {
747770
given:
748771
def article = new ResourceObject(
@@ -868,6 +891,29 @@ class JsonApiDocumentValidatorSpec extends Specification {
868891
noExceptionThrown()
869892
}
870893

894+
def "null relationship pagination is rejected"() {
895+
given:
896+
def article = new ResourceObject(
897+
"articles", "1", null, null,
898+
Relationships.ofRelationships([
899+
author: new Relationship(
900+
RelationshipData.NullLinkage.INSTANCE,
901+
Links.ofLinks([first: new Link.StringLink("https://example.com/a?page=1")]),
902+
null,
903+
[:])
904+
]),
905+
null, null, [:])
906+
def doc = JsonApiDocument.withData(new DocumentData.SingleResource(article))
907+
908+
when:
909+
validator.validate(doc, ValidationContext.defaults())
910+
911+
then:
912+
def ex = thrown(JsonApiValidationException)
913+
ex.ruleCode() == ValidationRuleCode.PAGINATION_REQUIRES_COLLECTION
914+
ex.jsonPointer() == "/data/relationships/author/links/first"
915+
}
916+
871917
def "to-many linkage validates each identifier with indexed pointer"() {
872918
given:
873919
def article = new ResourceObject(

0 commit comments

Comments
 (0)