Skip to content

Commit

Permalink
Add option to deconflict errors with same status code for OpenAPI
Browse files Browse the repository at this point in the history
  • Loading branch information
srchase committed Oct 2, 2023
1 parent 2d062d0 commit 5bcf02c
Show file tree
Hide file tree
Showing 13 changed files with 851 additions and 30 deletions.
45 changes: 15 additions & 30 deletions docs/source-2.0/guides/converting-to-openapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -598,37 +598,22 @@ disableIntegerFormat (``boolean``)
.. _generate-openapi-setting-onErrorStatusConflict:

onErrorStatusConflict (``String``)
Specifies how to resolve multiple error responses that share a same HTTP status code.
This behavior can be customized using the following values for the ``onErrorStatusConflict`` setting:
Specifies how to resolve multiple error responses that share the same HTTP
status code. This behavior can be enabled using the following values for
the ``onErrorStatusConflict`` setting:

``oneOf``
Use OpenAPI's ``oneOf`` keyword to combine error responses with same HTTP status code. The ``oneOf`` option
wraps schemas for contents of conflicting errors responses schemas into a synthetic union schema using
OpenAPI's ``oneOf`` keyword.
``properties``
Use ``properties`` field of OpenAPI schema object to combine error responses with same HTTP status code.
The ``properties`` option combines the conflicting error structure shapes into one union error shape that
contains all members from each and every conflicting error.

.. note::
``oneOf`` keyword is not supported by Amazon API Gateway.

Both options generate a single combined response object called "UnionError XXX Response" in the
OpenAPI model output, where "XXX" is the status code shared by multiple errors. Both options drop
the ``@required`` trait from all members of conflicting error structures, making them optional.

.. warning::
When using ``properties`` option, make sure that conflicting error structure shapes do not have member(s)
that have same name while having different target shapes. If member shapes with same name
(in conflicting error structures) target
different shapes, error shapes will not be able to be merged into one union error shape, and
an exception will be thrown.

.. warning::
Regardless of the setting, an exception will be thrown if any one of conflicting error structure shape
has a member shape with ``@httpPayload`` trait.

By default, this setting is set to ``oneOf``.
Use OpenAPI's ``oneOf`` keyword to combine error responses with same
HTTP status code. The ``oneOf`` option wraps schemas for contents of
conflicting errors responses schemas into a synthetic union schema
using OpenAPI's ``oneOf`` keyword.

By default, this setting is disabled. When enabled, a single combined
response object will be included in the OpenAPI model output. Any member of
the conflicting errors bound to a HTTP header will be added to the
top-level response. If any headers conflict, an error will be thrown.
Remaining members will be left in place on the conflicting errors. The
modified conflicting errors are then added to the combined response object.

.. code-block:: json
:caption: smithy-build.json
Expand All @@ -638,7 +623,7 @@ onErrorStatusConflict (``String``)
"plugins": {
"openapi": {
"service": "smithy.example#Weather",
"onErrorStatusConflict": "properties"
"onErrorStatusConflict": "oneOf"
}
}
}
Expand Down
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;
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -665,5 +665,16 @@ public Model downgradeToV1(Model model) {
*/
public Model removeInvalidDefaults(Model model) {
return new RemoveInvalidDefaults().transform(this, model);

}

/**
* Deconflicts errors that share a status code.
*
* @param model Model to transform.
* @return Returns the transformed model.
*/
public Model deconflictErrorsWithSharedStatusCode(Model model, ServiceShape forService) {
return new DeconflictErrorsWithSharedStatusCode(forService).transform(this, model);
}
}
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));
}
}
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
}
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
}
Loading

0 comments on commit 5bcf02c

Please sign in to comment.