Skip to content

Commit ecfc2f7

Browse files
eduardomourargosar
authored andcommitted
feat: allow non backward compatible required member (smithy-lang#566)
* feat: allow non backward-compatible required member --------- Co-authored-by: Eduardo Rodrigues <[email protected]> Co-authored-by: Jaykumar Gosar <[email protected]>
1 parent a09695b commit ecfc2f7

File tree

7 files changed

+138
-12
lines changed

7 files changed

+138
-12
lines changed

smithy-typescript-codegen-test/smithy-build.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"packageVersion": "0.0.1",
99
"packageJson": {
1010
"license": "Apache-2.0"
11-
}
11+
},
12+
"requiredMemberMode": "strict"
1213
}
1314
}
1415
}

smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ public void generateStructure(GenerateStructureDirective<TypeScriptCodegenContex
331331
directive.symbolProvider(),
332332
writer,
333333
directive.shape(),
334-
directive.settings().generateServerSdk()
334+
directive.settings().generateServerSdk(),
335+
directive.settings().getRequiredMemberMode()
335336
);
336337
generator.run();
337338
});
@@ -345,7 +346,8 @@ public void generateError(GenerateErrorDirective<TypeScriptCodegenContext, TypeS
345346
directive.symbolProvider(),
346347
writer,
347348
directive.shape(),
348-
directive.settings().generateServerSdk()
349+
directive.settings().generateServerSdk(),
350+
directive.settings().getRequiredMemberMode()
349351
);
350352
generator.run();
351353
});

smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/StructureGenerator.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import software.amazon.smithy.model.shapes.MemberShape;
2828
import software.amazon.smithy.model.shapes.StructureShape;
2929
import software.amazon.smithy.model.traits.ErrorTrait;
30+
import software.amazon.smithy.typescript.codegen.TypeScriptSettings.RequiredMemberMode;
3031
import software.amazon.smithy.typescript.codegen.integration.HttpProtocolGeneratorUtils;
3132
import software.amazon.smithy.utils.SmithyInternalApi;
3233

@@ -67,24 +68,29 @@ final class StructureGenerator implements Runnable {
6768
private final TypeScriptWriter writer;
6869
private final StructureShape shape;
6970
private final boolean includeValidation;
71+
private final RequiredMemberMode requiredMemberMode;
7072

7173
/**
72-
* sets 'includeValidation' to 'false' for backwards compatibility.
74+
* sets 'includeValidation' to 'false' and requiredMemberMode
75+
* to {@link RequiredMemberMode#NULLABLE}.
7376
*/
7477
StructureGenerator(Model model, SymbolProvider symbolProvider, TypeScriptWriter writer, StructureShape shape) {
75-
this(model, symbolProvider, writer, shape, false);
78+
this(model, symbolProvider, writer, shape, false,
79+
RequiredMemberMode.NULLABLE);
7680
}
7781

7882
StructureGenerator(Model model,
7983
SymbolProvider symbolProvider,
8084
TypeScriptWriter writer,
8185
StructureShape shape,
82-
boolean includeValidation) {
86+
boolean includeValidation,
87+
RequiredMemberMode requiredMemberMode) {
8388
this.model = model;
8489
this.symbolProvider = symbolProvider;
8590
this.writer = writer;
8691
this.shape = shape;
8792
this.includeValidation = includeValidation;
93+
this.requiredMemberMode = requiredMemberMode;
8894
}
8995

9096
@Override
@@ -158,7 +164,7 @@ private void renderNonErrorStructure() {
158164
}
159165

160166
StructuredMemberWriter config = new StructuredMemberWriter(
161-
model, symbolProvider, shape.getAllMembers().values());
167+
model, symbolProvider, shape.getAllMembers().values(), this.requiredMemberMode);
162168
config.writeMembers(writer, shape);
163169
writer.closeBlock("}");
164170
writer.write("");
@@ -255,7 +261,7 @@ private void renderErrorStructure() {
255261
HttpProtocolGeneratorUtils.writeRetryableTrait(writer, shape, ";");
256262
}
257263
StructuredMemberWriter structuredMemberWriter = new StructuredMemberWriter(model, symbolProvider,
258-
shape.getAllMembers().values());
264+
shape.getAllMembers().values(), this.requiredMemberMode);
259265
// since any error interface must extend from JavaScript Error interface, message member is already
260266
// required in the JavaScript Error interface
261267
structuredMemberWriter.skipMembers.add("message");

smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/StructuredMemberWriter.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import software.amazon.smithy.model.traits.StreamingTrait;
4747
import software.amazon.smithy.model.traits.Trait;
4848
import software.amazon.smithy.model.traits.UniqueItemsTrait;
49+
import software.amazon.smithy.typescript.codegen.TypeScriptSettings.RequiredMemberMode;
4950
import software.amazon.smithy.utils.SmithyInternalApi;
5051

5152
/**
@@ -61,12 +62,19 @@ final class StructuredMemberWriter {
6162
Collection<MemberShape> members;
6263
String memberPrefix = "";
6364
boolean noDocs;
65+
RequiredMemberMode requiredMemberMode;
6466
final Set<String> skipMembers = new HashSet<>();
6567

6668
StructuredMemberWriter(Model model, SymbolProvider symbolProvider, Collection<MemberShape> members) {
69+
this(model, symbolProvider, members, RequiredMemberMode.NULLABLE);
70+
}
71+
72+
StructuredMemberWriter(Model model, SymbolProvider symbolProvider, Collection<MemberShape> members,
73+
RequiredMemberMode requiredMemberMode) {
6774
this.model = model;
6875
this.symbolProvider = symbolProvider;
6976
this.members = new LinkedHashSet<>(members);
77+
this.requiredMemberMode = requiredMemberMode;
7078
}
7179

7280
void writeMembers(TypeScriptWriter writer, Shape shape) {
@@ -80,7 +88,8 @@ void writeMembers(TypeScriptWriter writer, Shape shape) {
8088
boolean wroteDocs = !noDocs && writer.writeMemberDocs(model, member);
8189
String memberName = getSanitizedMemberName(member);
8290
String optionalSuffix = shape.isUnionShape() || !isRequiredMember(member) ? "?" : "";
83-
String typeSuffix = isRequiredMember(member) ? " | undefined" : "";
91+
String typeSuffix = requiredMemberMode == RequiredMemberMode.NULLABLE
92+
&& isRequiredMember(member) ? " | undefined" : "";
8493
writer.write("${L}${L}${L}: ${T}${L};", memberPrefix, memberName, optionalSuffix,
8594
symbolProvider.toSymbol(member), typeSuffix);
8695

smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptSettings.java

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
import software.amazon.smithy.model.shapes.ServiceShape;
3535
import software.amazon.smithy.model.shapes.Shape;
3636
import software.amazon.smithy.model.shapes.ShapeId;
37+
import software.amazon.smithy.model.traits.DefaultTrait;
38+
import software.amazon.smithy.model.traits.RequiredTrait;
3739
import software.amazon.smithy.utils.SmithyUnstableApi;
3840

3941
/**
@@ -43,6 +45,7 @@
4345
public final class TypeScriptSettings {
4446

4547
static final String DISABLE_DEFAULT_VALIDATION = "disableDefaultValidation";
48+
static final String REQUIRED_MEMBER_MODE = "requiredMemberMode";
4649
static final String TARGET_NAMESPACE = "targetNamespace";
4750
private static final Logger LOGGER = Logger.getLogger(TypeScriptSettings.class.getName());
4851

@@ -66,6 +69,8 @@ public final class TypeScriptSettings {
6669
private boolean isPrivate;
6770
private ArtifactType artifactType = ArtifactType.CLIENT;
6871
private boolean disableDefaultValidation = false;
72+
private RequiredMemberMode requiredMemberMode =
73+
RequiredMemberMode.NULLABLE;
6974
private PackageManager packageManager = PackageManager.YARN;
7075

7176
@Deprecated
@@ -106,6 +111,10 @@ public static TypeScriptSettings from(Model model, ObjectNode config, ArtifactTy
106111
if (artifactType == ArtifactType.SSDK) {
107112
settings.setDisableDefaultValidation(config.getBooleanMemberOrDefault(DISABLE_DEFAULT_VALIDATION));
108113
}
114+
settings.setRequiredMemberMode(
115+
config.getStringMember(REQUIRED_MEMBER_MODE)
116+
.map(s -> RequiredMemberMode.fromString(s.getValue()))
117+
.orElse(RequiredMemberMode.NULLABLE));
109118

110119
settings.setPluginSettings(config);
111120
return settings;
@@ -296,6 +305,28 @@ public void setDisableDefaultValidation(boolean disableDefaultValidation) {
296305
this.disableDefaultValidation = disableDefaultValidation;
297306
}
298307

308+
/**
309+
* Returns the code generation mode for required members.
310+
*
311+
* @return the configured mode for required members.
312+
* Defaults to {@link RequiredMemberMode#NULLABLE}
313+
*/
314+
public RequiredMemberMode getRequiredMemberMode() {
315+
return requiredMemberMode;
316+
}
317+
318+
public void setRequiredMemberMode(
319+
RequiredMemberMode requiredMemberMode) {
320+
if (requiredMemberMode != RequiredMemberMode.NULLABLE) {
321+
LOGGER.warning(String.format("By setting the required member mode to '%s', a"
322+
+ " member that has the '@required' trait applied CANNOT be 'undefined'."
323+
+ " It will be considered a BACKWARDS INCOMPATIBLE change for"
324+
+ " Smithy services even when the required constraint is dropped from a member.",
325+
requiredMemberMode.mode, RequiredMemberMode.NULLABLE.mode));
326+
}
327+
this.requiredMemberMode = requiredMemberMode;
328+
}
329+
299330
/**
300331
* Returns the package manager used by the generated package.
301332
*
@@ -396,10 +427,11 @@ public String getDefaultSigningName() {
396427
public enum ArtifactType {
397428
CLIENT(SymbolVisitor::new,
398429
Arrays.asList(PACKAGE, PACKAGE_DESCRIPTION, PACKAGE_JSON, PACKAGE_VERSION, PACKAGE_MANAGER,
399-
SERVICE, PROTOCOL, TARGET_NAMESPACE, PRIVATE)),
430+
SERVICE, PROTOCOL, TARGET_NAMESPACE, PRIVATE, REQUIRED_MEMBER_MODE)),
400431
SSDK((m, s) -> new ServerSymbolVisitor(m, new SymbolVisitor(m, s)),
401432
Arrays.asList(PACKAGE, PACKAGE_DESCRIPTION, PACKAGE_JSON, PACKAGE_VERSION, PACKAGE_MANAGER,
402-
SERVICE, PROTOCOL, TARGET_NAMESPACE, PRIVATE, DISABLE_DEFAULT_VALIDATION));
433+
SERVICE, PROTOCOL, TARGET_NAMESPACE, PRIVATE, REQUIRED_MEMBER_MODE,
434+
DISABLE_DEFAULT_VALIDATION));
403435

404436
private final BiFunction<Model, TypeScriptSettings, SymbolProvider> symbolProviderFactory;
405437
private final List<String> configProperties;
@@ -422,6 +454,52 @@ public SymbolProvider createSymbolProvider(Model model, TypeScriptSettings setti
422454
}
423455
}
424456

457+
/**
458+
* An enum indicating the code generation mode for required members.
459+
*/
460+
public enum RequiredMemberMode {
461+
/**
462+
* This is the current behavior and it will be the default. When set,
463+
* it allows a member that has the {@link RequiredTrait} applied to be {@code undefined}.
464+
* By doing so it can still be considered a backwards compatible change fo
465+
* Smithy services even when the required constraint is dropped from a member.
466+
*/
467+
NULLABLE("nullable"),
468+
469+
/**
470+
* This will dissallow members marked as {@link RequiredTrait} to be {@code undefined}.
471+
* Use this mode with CAUTION because it comes with certain risks. When a server drops
472+
* {@link RequiredTrait} from an output shape (and it is replaced with {@link DefaultTrait}
473+
* as defined by the spec), if the server does not always serialize a value,
474+
* customer code consuming the client and trying to access this member, may get a
475+
* NullPointerException. Smithy spec says: "Authoritative model consumers like servers
476+
* SHOULD always serialize default values to remove any ambiguity about the value of
477+
* the most up to default value." So one should use this mode on the client, only if
478+
* the server is following the approach proposed by the spec.
479+
*/
480+
STRICT("strict");
481+
482+
private final String mode;
483+
484+
RequiredMemberMode(String mode) {
485+
this.mode = mode;
486+
}
487+
488+
public String getMode() {
489+
return mode;
490+
}
491+
492+
public static RequiredMemberMode fromString(String s) {
493+
if ("nullable".equals(s)) {
494+
return NULLABLE;
495+
}
496+
if ("strict".equals(s)) {
497+
return STRICT;
498+
}
499+
throw new CodegenException(String.format("Unsupported required member mode: %s", s));
500+
}
501+
}
502+
425503
public enum PackageManager {
426504
YARN("yarn"),
427505
NPM("npm");

smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/StructureGeneratorTest.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
import org.junit.jupiter.api.Test;
77
import software.amazon.smithy.build.MockManifest;
88
import software.amazon.smithy.build.PluginContext;
9-
import software.amazon.smithy.codegen.core.SymbolProvider;
109
import software.amazon.smithy.model.Model;
1110
import software.amazon.smithy.model.loader.ModelAssembler;
1211
import software.amazon.smithy.model.node.Node;
1312
import software.amazon.smithy.model.shapes.MemberShape;
1413
import software.amazon.smithy.model.shapes.OperationShape;
1514
import software.amazon.smithy.model.shapes.StructureShape;
15+
import software.amazon.smithy.typescript.codegen.TypeScriptSettings.RequiredMemberMode;
1616

1717
public class StructureGeneratorTest {
1818
@Test
@@ -508,7 +508,19 @@ public void skipsFilterOnEncounteringRecursiveShapes() {
508508
+ "})");
509509
}
510510

511+
@Test
512+
public void properlyGeneratesRequiredMessageMemberNotBackwardCompatible() {
513+
testStructureCodegenBase("test-required-member.smithy",
514+
"export interface GetFooOutput {\n"
515+
+ " someRequiredMember: string;\n"
516+
+ "}\n", RequiredMemberMode.STRICT);
517+
}
518+
511519
private String testStructureCodegen(String file, String expectedType) {
520+
return testStructureCodegenBase(file, expectedType, RequiredMemberMode.NULLABLE);
521+
}
522+
523+
private String testStructureCodegenBase(String file, String expectedType, RequiredMemberMode requiredMemberMode) {
512524
Model model = Model.assembler()
513525
.addImport(getClass().getResource(file))
514526
.assemble()
@@ -521,6 +533,7 @@ private String testStructureCodegen(String file, String expectedType) {
521533
.withMember("service", Node.from("smithy.example#Example"))
522534
.withMember("package", Node.from("example"))
523535
.withMember("packageVersion", Node.from("1.0.0"))
536+
.withMember("requiredMemberMode", Node.from(requiredMemberMode.getMode()))
524537
.build())
525538
.build();
526539

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace smithy.example
2+
3+
service Example {
4+
version: "1.0.0",
5+
operations: [GetFoo]
6+
}
7+
8+
operation GetFoo {
9+
input: GetFooInput,
10+
output: GetFooOutput
11+
}
12+
13+
structure GetFooInput {}
14+
structure GetFooOutput {
15+
@required
16+
someRequiredMember: String
17+
}

0 commit comments

Comments
 (0)