Skip to content

Commit d9ec629

Browse files
committed
Rewrite JSON schema conversion
This commit significantly cleans up the JSON schema conversion and automatically inlines primitive references rather than inlining them through a mapper. The previous JSON schema implementation code had several issues: 1. It did not do a good job at handling shape ID conflicts when using namespace stripping. We had to add some pretty bad hacks to achieve this. For examplem, it had implicit state that was tricky to handle (like temporarily setting a ref strategy based on a converted shape index). 2. It didn't detect errors early in the process, resulting in strange errors when you try to use the schema. 3. It exposed too much public API (for example RefStrategy should not be public). Ideally with this trimmed down API surface area, we won't need another breaking change. 4. JSON schema names by default should not include a namespace. 5. Simple shapes by default should always be inlined. Things like list and set shapes aren't that important for generating good JSON schema or OpenAPI schemas. By inlining them, we also ensure that any member documentation attached to members that target list or set shapes isn't lost since that documentation comes from either the member or the targeted shape. This also reduces the possibility for naming conflicts when dropping the namespace from the Smithy shape ID and converting it to JSON Schema. 6. We were dropping member traits in some scenarios like documentation, pattern, range, length. This is now fixed. Because converting shape IDs to JSON pointers can now result in a nested JSON pointer, the ability to select schemas from a SchemaDocument using a JSON pointer has been implemented. Further, the Smithy document shape is actually meant to be a simple type, but it was correctly subclassing SimpleShape, resulting in JSON schema conversions not working correctly (document types were creating distinct named shapes, whereas they are intended to be inlined). Finally, this commit fixes a bug where JSON schema extensions weren't being injected.
1 parent 1e14051 commit d9ec629

25 files changed

+1473
-669
lines changed

config/spotbugs/filter.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,10 @@
9292
<Class name="software.amazon.smithy.model.knowledge.ServiceIndex"/>
9393
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
9494
</Match>
95+
96+
<!-- Using a buffer here would actually allocate more, not less -->
97+
<Match>
98+
<Class name="software.amazon.smithy.jsonschema.SchemaDocument"/>
99+
<Bug pattern="SBSC_USE_STRINGBUFFER_CONCATENATION"/>
100+
</Match>
95101
</FindBugsFilter>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.smithy.jsonschema;
17+
18+
/**
19+
* Thrown when two shapes generate the same JSON schema pointer.
20+
*/
21+
public class ConflictingShapeNameException extends SmithyJsonSchemaException {
22+
ConflictingShapeNameException(String message) {
23+
super(message);
24+
}
25+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.smithy.jsonschema;
17+
18+
import java.util.HashMap;
19+
import java.util.Map;
20+
import java.util.logging.Logger;
21+
import java.util.regex.Pattern;
22+
import software.amazon.smithy.model.Model;
23+
import software.amazon.smithy.model.shapes.CollectionShape;
24+
import software.amazon.smithy.model.shapes.MapShape;
25+
import software.amazon.smithy.model.shapes.Shape;
26+
import software.amazon.smithy.model.shapes.ShapeId;
27+
import software.amazon.smithy.model.shapes.SimpleShape;
28+
import software.amazon.smithy.model.traits.EnumTrait;
29+
import software.amazon.smithy.utils.FunctionalUtils;
30+
import software.amazon.smithy.utils.StringUtils;
31+
32+
/**
33+
* Automatically de-conflicts map shapes, list shapes, and set shapes
34+
* by sorting conflicting shapes by ID and then appending a formatted
35+
* version of the shape ID namespace to the colliding shape.
36+
*
37+
* <p>Simple types are never generated at the top level because they
38+
* are always inlined into complex shapes; however, string shapes
39+
* marked with the enum trait are never allowed to conflict since
40+
* they can easily drift away from compatibility over time.
41+
* Structures and unions are not allowed to conflict either.
42+
*/
43+
final class DeconflictingStrategy implements RefStrategy {
44+
45+
private static final Logger LOGGER = Logger.getLogger(DeconflictingStrategy.class.getName());
46+
private static final Pattern SPLIT_PATTERN = Pattern.compile("\\.");
47+
48+
private final RefStrategy delegate;
49+
private final Map<ShapeId, String> pointers = new HashMap<>();
50+
private final Map<String, ShapeId> reversePointers = new HashMap<>();
51+
52+
DeconflictingStrategy(Model model, RefStrategy delegate) {
53+
this.delegate = delegate;
54+
55+
// Pre-compute a map of all converted shape refs. Sort the shapes
56+
// to make the result deterministic.
57+
model.shapes().filter(FunctionalUtils.not(this::isIgnoredShape)).sorted().forEach(shape -> {
58+
String pointer = delegate.toPointer(shape.getId());
59+
if (!reversePointers.containsKey(pointer)) {
60+
pointers.put(shape.getId(), pointer);
61+
reversePointers.put(pointer, shape.getId());
62+
} else {
63+
String deconflictedPointer = deconflict(shape, pointer, reversePointers);
64+
LOGGER.info(() -> String.format(
65+
"De-conflicted `%s` JSON schema pointer from `%s` to `%s`",
66+
shape.getId(), pointer, deconflictedPointer));
67+
pointers.put(shape.getId(), deconflictedPointer);
68+
reversePointers.put(deconflictedPointer, shape.getId());
69+
}
70+
});
71+
}
72+
73+
// Some shapes aren't converted to JSON schema at all because they
74+
// don't have a corresponding definition.
75+
private boolean isIgnoredShape(Shape shape) {
76+
return (shape instanceof SimpleShape && !shape.hasTrait(EnumTrait.class))
77+
|| shape.isResourceShape()
78+
|| shape.isServiceShape()
79+
|| shape.isOperationShape()
80+
|| shape.isMemberShape();
81+
}
82+
83+
private String deconflict(Shape shape, String pointer, Map<String, ShapeId> reversePointers) {
84+
LOGGER.info(() -> String.format(
85+
"Attempting to de-conflict `%s` JSON schema pointer `%s` that conflicts with `%s`",
86+
shape.getId(), pointer, reversePointers.get(pointer)));
87+
88+
if (!isSafeToDeconflict(shape)) {
89+
throw new ConflictingShapeNameException(String.format(
90+
"Shape %s conflicts with %s using a JSON schema pointer of %s",
91+
shape, reversePointers.get(pointer), pointer));
92+
}
93+
94+
// Create a de-conflicted JSON schema pointer that just appends
95+
// the PascalCase formatted version of the shape's namespace to the
96+
// resulting pointer.
97+
StringBuilder builder = new StringBuilder(pointer);
98+
for (String part : SPLIT_PATTERN.split(shape.getId().getNamespace())) {
99+
builder.append(StringUtils.capitalize(part));
100+
}
101+
102+
String updatedPointer = builder.toString();
103+
104+
if (reversePointers.containsKey(updatedPointer)) {
105+
// Note: I don't know if this can ever actually happen... but just in case.
106+
throw new ConflictingShapeNameException(String.format(
107+
"Unable to de-conflict shape %s because the de-conflicted name resolves "
108+
+ "to another generated name: %s", shape, updatedPointer));
109+
}
110+
111+
return updatedPointer;
112+
}
113+
114+
// We only want to de-conflict shapes that are generally not code-generated
115+
// because the de-conflicted names can potentially change over time as shapes
116+
// are added and removed. Things like structures, unions, and enums should
117+
// never be de-conflicted from this class.
118+
private boolean isSafeToDeconflict(Shape shape) {
119+
return shape instanceof CollectionShape || shape instanceof MapShape;
120+
}
121+
122+
@Override
123+
public String toPointer(ShapeId id) {
124+
return pointers.computeIfAbsent(id, delegate::toPointer);
125+
}
126+
127+
@Override
128+
public boolean isInlined(Shape shape) {
129+
return delegate.isInlined(shape);
130+
}
131+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.smithy.jsonschema;
17+
18+
import java.util.regex.Pattern;
19+
import software.amazon.smithy.model.Model;
20+
import software.amazon.smithy.model.node.ObjectNode;
21+
import software.amazon.smithy.model.shapes.CollectionShape;
22+
import software.amazon.smithy.model.shapes.MemberShape;
23+
import software.amazon.smithy.model.shapes.Shape;
24+
import software.amazon.smithy.model.shapes.ShapeId;
25+
import software.amazon.smithy.model.shapes.SimpleShape;
26+
import software.amazon.smithy.model.traits.EnumTrait;
27+
import software.amazon.smithy.utils.StringUtils;
28+
29+
/**
30+
* This ref strategy converts Smithy shapes into the following:
31+
*
32+
* <ul>
33+
* <li>
34+
* Structures, unions, maps, and enums are always created as a top-level
35+
* JSON schema definition.
36+
* </li>
37+
* <li>
38+
* <p>Members that target structures, unions, enums, and maps use a $ref to the
39+
* targeted shape. With the exception of maps, these kinds of shapes are almost
40+
* always generated as concrete types by code generators, so it's useful to reuse
41+
* them throughout the schema. However, this means that member documentation
42+
* and other member traits need to be moved in some way to the containing
43+
* shape (for example, documentation needs to be appended to the container
44+
* shape).</p>
45+
* <p>Maps are included here because they are represented as objects in
46+
* JSON schema, and many tools will generate a type or require an explicit
47+
* name for all objects. For example, API Gateway will auto-generate a
48+
* non-deterministic name for a map if one is not provided.</p>
49+
* </li>
50+
* <li>
51+
* Members that target a collection or simple type are inlined into the generated
52+
* container (that is, shapes that do not have the enum trait).
53+
* </li>
54+
* </ul>
55+
*/
56+
final class DefaultRefStrategy implements RefStrategy {
57+
58+
private static final Pattern SPLIT_PATTERN = Pattern.compile("\\.");
59+
private static final Pattern NON_ALPHA_NUMERIC = Pattern.compile("[^A-Za-z0-9]");
60+
61+
private final Model model;
62+
private final boolean alphanumericOnly;
63+
private final boolean keepNamespaces;
64+
private final String rootPointer;
65+
private final PropertyNamingStrategy propertyNamingStrategy;
66+
private final ObjectNode config;
67+
68+
DefaultRefStrategy(Model model, ObjectNode config, PropertyNamingStrategy propertyNamingStrategy) {
69+
this.model = model;
70+
this.propertyNamingStrategy = propertyNamingStrategy;
71+
this.config = config;
72+
rootPointer = computePointer(config);
73+
alphanumericOnly = config.getBooleanMemberOrDefault(JsonSchemaConstants.ALPHANUMERIC_ONLY_REFS);
74+
keepNamespaces = config.getBooleanMemberOrDefault(JsonSchemaConstants.KEEP_NAMESPACES);
75+
}
76+
77+
private static String computePointer(ObjectNode config) {
78+
String pointer = config.getStringMemberOrDefault(JsonSchemaConstants.DEFINITION_POINTER, DEFAULT_POINTER);
79+
if (!pointer.endsWith("/")) {
80+
pointer += "/";
81+
}
82+
return pointer;
83+
}
84+
85+
@Override
86+
public String toPointer(ShapeId id) {
87+
if (id.getMember().isPresent()) {
88+
MemberShape member = model.expectShape(id, MemberShape.class);
89+
return createMemberPointer(member);
90+
}
91+
92+
StringBuilder builder = new StringBuilder();
93+
appendNamespace(builder, id);
94+
builder.append(id.getName());
95+
return rootPointer + stripNonAlphaNumericCharsIfNecessary(builder.toString());
96+
}
97+
98+
private String createMemberPointer(MemberShape member) {
99+
if (!isInlined(member)) {
100+
return toPointer(member.getTarget());
101+
}
102+
103+
Shape container = model.expectShape(member.getContainer());
104+
String parentPointer = toPointer(container.getId());
105+
106+
switch (container.getType()) {
107+
case LIST:
108+
case SET:
109+
return parentPointer + "/items";
110+
case MAP:
111+
return member.getMemberName().equals("key")
112+
? parentPointer + "/propertyNames"
113+
: parentPointer + "/additionalProperties";
114+
default: // union | structure
115+
return parentPointer + "/properties/" + propertyNamingStrategy.toPropertyName(
116+
container, member, config);
117+
}
118+
}
119+
120+
@Override
121+
public boolean isInlined(Shape shape) {
122+
// We could add more logic here in the future if needed to account for
123+
// member shapes that absolutely must generate a synthesized schema.
124+
if (shape.asMemberShape().isPresent()) {
125+
MemberShape member = shape.asMemberShape().get();
126+
Shape target = model.expectShape(member.getTarget());
127+
return isInlined(target);
128+
}
129+
130+
// Collections (lists and sets) are always inlined. Most importantly,
131+
// this is done to expose any important traits of list and set members
132+
// in the generated JSON schema document (for example, documentation).
133+
// Without this inlining, list and set member documentation would be
134+
// lost since the items property in the generated JSON schema would
135+
// just be a $ref pointing to the target of the member. The more
136+
// things that can be inlined that don't matter the better since it
137+
// means traits like documentation aren't lost.
138+
//
139+
// Members of lists and sets are basically never a generated type in
140+
// any programming language because most just use some kind of
141+
// standard library feature. This essentially means that the names
142+
// of lists or sets changing when round-tripping
143+
// Smithy -> JSON Schema -> Smithy doesn't matter that much.
144+
if (shape instanceof CollectionShape) {
145+
return true;
146+
}
147+
148+
// Strings with the enum trait are never inlined. This helps to ensure
149+
// that the name of an enum string can be round-tripped from
150+
// Smithy -> JSON Schema -> Smithy, helps OpenAPI code generators to
151+
// use a good name for any generated types, and it cuts down on the
152+
// duplication of documentation and constraints in the generated schema.
153+
if (shape.hasTrait(EnumTrait.class)) {
154+
return false;
155+
}
156+
157+
// Simple types are always inlined unless the type has the enum trait.
158+
return shape instanceof SimpleShape;
159+
}
160+
161+
private void appendNamespace(StringBuilder builder, ShapeId id) {
162+
// Append each namespace part, capitalizing each segment.
163+
// For example, "smithy.example" becomes "SmithyExample".
164+
if (keepNamespaces) {
165+
for (String part : SPLIT_PATTERN.split(id.getNamespace())) {
166+
builder.append(StringUtils.capitalize(part));
167+
}
168+
}
169+
}
170+
171+
private String stripNonAlphaNumericCharsIfNecessary(String result) {
172+
return alphanumericOnly
173+
? NON_ALPHA_NUMERIC.matcher(result).replaceAll("")
174+
: result;
175+
}
176+
}

0 commit comments

Comments
 (0)