-
Notifications
You must be signed in to change notification settings - Fork 218
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add option to deconflict errors with same status code for OpenAPI
- Loading branch information
Showing
13 changed files
with
851 additions
and
30 deletions.
There are no files selected for viewing
This file contains 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
154 changes: 154 additions & 0 deletions
154
...ain/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCode.java
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
/* | ||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
package software.amazon.smithy.model.transform; | ||
|
||
import java.util.ArrayList; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import software.amazon.smithy.model.Model; | ||
import software.amazon.smithy.model.knowledge.TopDownIndex; | ||
import software.amazon.smithy.model.shapes.MemberShape; | ||
import software.amazon.smithy.model.shapes.OperationShape; | ||
import software.amazon.smithy.model.shapes.ServiceShape; | ||
import software.amazon.smithy.model.shapes.Shape; | ||
import software.amazon.smithy.model.shapes.ShapeId; | ||
import software.amazon.smithy.model.shapes.StructureShape; | ||
import software.amazon.smithy.model.shapes.UnionShape; | ||
import software.amazon.smithy.model.traits.ErrorTrait; | ||
import software.amazon.smithy.model.traits.HttpErrorTrait; | ||
import software.amazon.smithy.model.traits.HttpHeaderTrait; | ||
import software.amazon.smithy.model.traits.Trait; | ||
import software.amazon.smithy.utils.Pair; | ||
|
||
/** | ||
* Deconflicts errors on operations that share the same status code by replacing | ||
* the conflicting errors with a synthetic error structure that contains hoisted | ||
* members that were bound to HTTP headers. The conflicting errors are added to | ||
* a union on the synthetic error. | ||
*/ | ||
final class DeconflictErrorsWithSharedStatusCode { | ||
|
||
private final ServiceShape forService; | ||
|
||
DeconflictErrorsWithSharedStatusCode(ServiceShape forService) { | ||
this.forService = forService; | ||
} | ||
|
||
Model transform(ModelTransformer transformer, Model model) { | ||
// Copy any service errors to the operations to find all potential conflicts. | ||
model = transformer.copyServiceErrorsToOperations(model, forService); | ||
TopDownIndex topDownIndex = TopDownIndex.of(model); | ||
List<Shape> shapesToReplace = new ArrayList<>(); | ||
|
||
for (OperationShape operation : topDownIndex.getContainedOperations(forService)) { | ||
OperationShape.Builder replacementOperation = operation.toBuilder(); | ||
boolean replaceOperation = false; | ||
|
||
// Collect errors that share the same status code. | ||
Map<Integer, List<StructureShape>> statusCodesToErrors = new HashMap<>(); | ||
for (ShapeId errorId : operation.getErrors()) { | ||
StructureShape error = model.expectShape(errorId, StructureShape.class); | ||
Integer statusCode = error.hasTrait(HttpErrorTrait.ID) | ||
? error.getTrait(HttpErrorTrait.class).get().getCode() | ||
: error.getTrait(ErrorTrait.class).get().getDefaultHttpStatusCode(); | ||
statusCodesToErrors.computeIfAbsent(statusCode, k -> new ArrayList<>()).add(error); | ||
} | ||
|
||
// Create union error for errors with same status code. | ||
for (Map.Entry<Integer, List<StructureShape>> statusCodeToErrors : statusCodesToErrors.entrySet()) { | ||
if (statusCodeToErrors.getValue().size() > 1) { | ||
replaceOperation = true; | ||
List<StructureShape> errors = statusCodeToErrors.getValue(); | ||
// Create a new top-level synthetic error and all the shapes that need replaced for it. | ||
Pair<Shape, List<Shape>> syntheticErrorPair = synthesizeErrorUnion(operation.getId().getName(), | ||
statusCodeToErrors.getKey(), errors); | ||
for (StructureShape error : errors) { | ||
replacementOperation.removeError(error.getId()); | ||
} | ||
replacementOperation.addError(syntheticErrorPair.getLeft()); | ||
shapesToReplace.add(syntheticErrorPair.getLeft()); | ||
shapesToReplace.addAll(syntheticErrorPair.getRight()); | ||
} | ||
} | ||
// Replace the operation if it has been updated with a synthetic error. | ||
if (replaceOperation) { | ||
replacementOperation.build(); | ||
shapesToReplace.add(replacementOperation.build()); | ||
} | ||
} | ||
|
||
return transformer.replaceShapes(model, shapesToReplace); | ||
} | ||
|
||
// Return synthetic error, along with any updated shapes. | ||
private Pair<Shape, List<Shape>> synthesizeErrorUnion(String operationName, Integer statusCode, | ||
List<StructureShape> errors) { | ||
List<Shape> replacementShapes = new ArrayList<>(); | ||
StructureShape.Builder errorResponse = StructureShape.builder(); | ||
ShapeId errorResponseId = ShapeId.fromParts(forService.getId().getNamespace(), | ||
operationName + statusCode + "Error"); | ||
errorResponse.id(errorResponseId); | ||
errorResponse.addTraits(getErrorTraitsFromStatusCode(statusCode)); | ||
Map<String, HttpHeaderTrait> headerTraitMap = new HashMap<>(); | ||
UnionShape.Builder errorUnion = UnionShape.builder().id( | ||
ShapeId.fromParts(errorResponseId.getNamespace(), errorResponseId.getName() + "Content")); | ||
for (StructureShape error : errors) { | ||
StructureShape newError = createNewError(error, headerTraitMap); | ||
replacementShapes.add(newError); | ||
MemberShape newErrorMember = MemberShape.builder() | ||
.id(errorUnion.getId().withMember(newError.getId().getName())) | ||
.target(newError.getId()) | ||
.build(); | ||
replacementShapes.add(newErrorMember); | ||
errorUnion.addMember(newErrorMember); | ||
} | ||
UnionShape union = errorUnion.build(); | ||
replacementShapes.add(union); | ||
errorResponse.addMember(MemberShape.builder() | ||
.id(errorResponseId.withMember("errorUnion")) | ||
.target(union.getId()) | ||
.build()); | ||
// Add members with hoisted HttpHeader traits. | ||
for (Map.Entry<String, HttpHeaderTrait> entry : headerTraitMap.entrySet()) { | ||
errorResponse.addMember(MemberShape.builder().id(errorResponseId.withMember(entry.getKey())) | ||
.addTrait(entry.getValue()).target("smithy.api#String").build()); | ||
} | ||
StructureShape built = errorResponse.build(); | ||
return Pair.of(built, replacementShapes); | ||
} | ||
|
||
private StructureShape createNewError(StructureShape oldError, Map<String, HttpHeaderTrait> headerMap) { | ||
StructureShape.Builder newErrorBuilder = oldError.toBuilder().clearMembers(); | ||
for (MemberShape member : oldError.getAllMembers().values()) { | ||
String name = member.getMemberName(); | ||
// Collect HttpHeaderTraits to hoist. | ||
if (member.hasTrait(HttpHeaderTrait.ID)) { | ||
HttpHeaderTrait newTrait = member.expectTrait(HttpHeaderTrait.class); | ||
HttpHeaderTrait previousTrait = headerMap.put(name, newTrait); | ||
if (previousTrait != null && !previousTrait.equals(newTrait)) { | ||
throw new ModelTransformException("Conflicting header when de-conflicting"); | ||
} | ||
} else { | ||
newErrorBuilder.addMember(member.toBuilder().id(newErrorBuilder.getId().withMember(name)).build()); | ||
} | ||
} | ||
return newErrorBuilder.build(); | ||
} | ||
|
||
private List<Trait> getErrorTraitsFromStatusCode(Integer statusCode) { | ||
List<Trait> traits = new ArrayList<>(); | ||
if (statusCode >= 400 && statusCode < 500) { | ||
traits.add(new ErrorTrait("client")); | ||
} else { | ||
traits.add(new ErrorTrait("server")); | ||
} | ||
traits.add(new HttpErrorTrait(statusCode)); | ||
return traits; | ||
} | ||
} | ||
|
||
|
This file contains 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
48 changes: 48 additions & 0 deletions
48
...java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCodeTest.java
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
package software.amazon.smithy.model.transform; | ||
|
||
import static org.junit.jupiter.api.Assertions.assertThrows; | ||
|
||
import org.junit.jupiter.api.Test; | ||
import software.amazon.smithy.model.Model; | ||
import software.amazon.smithy.model.node.Node; | ||
import software.amazon.smithy.model.shapes.ModelSerializer; | ||
import software.amazon.smithy.model.shapes.ServiceShape; | ||
import software.amazon.smithy.model.shapes.ShapeId; | ||
|
||
public class DeconflictErrorsWithSharedStatusCodeTest { | ||
@Test | ||
public void deconflictErrorsWithSharedStatusCodes() { | ||
Model input = Model.assembler() | ||
.addImport(getClass().getResource("conflicting-errors.smithy")) | ||
.assemble() | ||
.unwrap(); | ||
Model output = Model.assembler() | ||
.addImport(getClass().getResource("deconflicted-errors.smithy")) | ||
.assemble() | ||
.unwrap(); | ||
|
||
ModelTransformer transformer = ModelTransformer.create(); | ||
|
||
ServiceShape service = input.expectShape(ShapeId.from("smithy.example#MyService"), ServiceShape.class); | ||
|
||
Model result = transformer.deconflictErrorsWithSharedStatusCode(input, service); | ||
|
||
Node actual = ModelSerializer.builder().build().serialize(result); | ||
Node expected = ModelSerializer.builder().build().serialize(output); | ||
Node.assertEquals(actual, expected); | ||
} | ||
|
||
@Test | ||
public void throwsWhenHeadersConflict() { | ||
Model model = Model.assembler() | ||
.addImport(getClass().getResource("conflicting-errors-with-conflicting-headers.smithy")) | ||
.assemble() | ||
.unwrap(); | ||
|
||
ModelTransformer transformer = ModelTransformer.create(); | ||
|
||
ServiceShape service = model.expectShape(ShapeId.from("smithy.example#MyService"), ServiceShape.class); | ||
assertThrows(ModelTransformException.class, | ||
() -> transformer.deconflictErrorsWithSharedStatusCode(model, service)); | ||
} | ||
} |
28 changes: 28 additions & 0 deletions
28
...software/amazon/smithy/model/transform/conflicting-errors-with-conflicting-headers.smithy
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
$version: "2.0" | ||
|
||
namespace smithy.example | ||
|
||
service MyService { | ||
operations: [MyOperation] | ||
} | ||
|
||
operation MyOperation { | ||
input := { | ||
string: String | ||
} | ||
errors: [FooError, BarError] | ||
} | ||
|
||
@error("client") | ||
@httpError(429) | ||
structure FooError { | ||
@httpHeader("x-foo") | ||
xFoo: String | ||
} | ||
|
||
@error("client") | ||
@httpError(429) | ||
structure BarError { | ||
@httpHeader("x-bar") | ||
xFoo: String | ||
} |
55 changes: 55 additions & 0 deletions
55
...model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors.smithy
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
$version: "2.0" | ||
|
||
namespace smithy.example | ||
|
||
service MyService { | ||
operations: [MyOperation] | ||
errors: [FooServiceLevelError, BarServiceLevelError] | ||
} | ||
|
||
operation MyOperation { | ||
input := { | ||
string: String | ||
} | ||
errors: [FooError, BarError] | ||
} | ||
|
||
@error("client") | ||
@httpError(429) | ||
structure FooServiceLevelError { | ||
@httpHeader("x-service-foo") | ||
xServiceFoo: String | ||
|
||
@httpHeader("x-common") | ||
xCommon: String | ||
} | ||
|
||
@error("client") | ||
@httpError(429) | ||
structure BarServiceLevelError { | ||
@httpHeader("x-service-bar") | ||
xServiceBar: String | ||
|
||
@httpHeader("x-common") | ||
xCommon: String | ||
} | ||
|
||
@error("client") | ||
@httpError(429) | ||
structure FooError { | ||
@httpHeader("x-foo") | ||
xFoo: String | ||
|
||
@httpHeader("x-common") | ||
xCommon: String | ||
} | ||
|
||
@error("client") | ||
@httpError(429) | ||
structure BarError { | ||
@httpHeader("x-bar") | ||
xBar: String | ||
|
||
@httpHeader("x-common") | ||
xCommon: String | ||
} |
Oops, something went wrong.