Skip to content

Commit 2be29a8

Browse files
mridangclaude
andcommitted
feat: add Dart integration tests with testcontainers-dart achieving full cross-language parity
Add DartClientSpec, DartFormattingSpec, DartLintingSpec, and DartReservedWordsSpec Java specs that run Dart tests inside Docker containers. Add testcontainers_core dependency and testcontainers_helper that starts WireMock, Squid proxy, and Prism mock containers. Fix model template fromJson/toJson to handle byte arrays with base64 decode/encode, DateTime parsing, nested model deserialization, and array mapping. Fix API template Map return type casting. All 245 Dart tests pass with full parity against Go test templates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4787d9b commit 2be29a8

102 files changed

Lines changed: 2627 additions & 1858 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

codegen-plus.iml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/kotlin/.out" />
1717
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/python/.ruff" />
1818
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/csharp/.out" />
19+
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/dart/.dart_tool" />
20+
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/dart/.out" />
1921
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/php/vendor" />
2022
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/ruby/.rubocop" />
2123
<excludeFolder url="file://$MODULE_DIR$/src/spec/resources/generated/rust/.out" />

src/main/java/io/github/mridang/codegen/generators/dart/BetterDartCodegen.java

Lines changed: 166 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.openapitools.codegen.SupportingFile;
2828
import org.openapitools.codegen.model.ModelMap;
2929
import org.openapitools.codegen.model.ModelsMap;
30+
import org.openapitools.codegen.model.OperationsMap;
3031
import org.openapitools.codegen.utils.ModelUtils;
3132
import org.slf4j.Logger;
3233
import org.slf4j.LoggerFactory;
@@ -100,9 +101,11 @@ public BetterDartCodegen() {
100101
"Object",
101102
"void",
102103
"Null",
103-
"DateTime"));
104+
"DateTime",
105+
"List<int>"));
104106

105107
reservedWords = loadReservedWords("/reserved-words/dart.txt");
108+
106109
}
107110

108111
/** Returns the generator name used to select this codegen via the {@code -g} flag. */
@@ -111,6 +114,16 @@ public String getName() {
111114
return "dart-plus";
112115
}
113116

117+
/**
118+
* Escapes a reserved word by appending an underscore suffix
119+
* instead of prepending one. In Dart, names starting with
120+
* underscore are library-private, so we avoid the prefix.
121+
*/
122+
@Override
123+
public String escapeReservedWord(String name) {
124+
return name + "_";
125+
}
126+
114127
/** Returns a short description shown in the help output. */
115128
@Override
116129
public String getHelp() {
@@ -171,7 +184,7 @@ protected String getFormatterDockerImage() {
171184
/** {@inheritDoc} */
172185
@Override
173186
protected String[] getFormatterCommands() {
174-
return new String[] {"dart format ."};
187+
return new String[] {"dart pub get", "dart fix --apply", "dart format ."};
175188
}
176189

177190
/** {@inheritDoc} */
@@ -262,6 +275,9 @@ public void processOpts() {
262275
new SupportingFile("api_error.mustache", srcDir, "api_error.dart"));
263276

264277
final String errorsDir = Path.of(srcDir, "errors").toString();
278+
supportingFiles.add(
279+
new SupportingFile(
280+
"errors/api_error.mustache", errorsDir, "api_error.dart"));
265281
supportingFiles.add(
266282
new SupportingFile(
267283
"errors/client_error.mustache", errorsDir, "client_error.dart"));
@@ -351,6 +367,11 @@ public void processOpts() {
351367

352368
if (generateTests) {
353369
supportingFiles.add(new SupportingFile("test/gitignore", "", ".gitignore"));
370+
supportingFiles.add(
371+
new SupportingFile(
372+
"test/testcontainers_helper.mustache",
373+
"test",
374+
"testcontainers_helper.dart"));
354375
supportingFiles.add(
355376
new SupportingFile(
356377
"test/api/pet_api_test.mustache", "test", "pet_api_test.dart"));
@@ -513,10 +534,104 @@ public ModelsMap postProcessModels(ModelsMap objs) {
513534
for (final CodegenProperty prop : model.requiredVars) {
514535
fixEnumDefaultValue(prop);
515536
}
537+
538+
for (final CodegenProperty prop : model.vars) {
539+
clearEnumOnPrimitives(prop);
540+
}
541+
for (final CodegenProperty prop : model.allVars) {
542+
clearEnumOnPrimitives(prop);
543+
}
544+
for (final CodegenProperty prop : model.optionalVars) {
545+
clearEnumOnPrimitives(prop);
546+
}
547+
for (final CodegenProperty prop : model.requiredVars) {
548+
clearEnumOnPrimitives(prop);
549+
}
550+
551+
final List<Map<String, String>> dartImports = new ArrayList<>();
552+
for (final String importName : model.imports) {
553+
if (!languageSpecificPrimitives.contains(importName)
554+
&& !typeMapping.containsValue(importName)) {
555+
final Map<String, String> dartImport = new HashMap<>();
556+
dartImport.put("classname", importName);
557+
dartImport.put("filename", toModelFilename(importName));
558+
dartImports.add(dartImport);
559+
}
560+
}
561+
modelMap.put("dartImports", dartImports);
562+
modelMap.put("hasDartImports", !dartImports.isEmpty());
563+
564+
final Set<String> filteredOneOf = new java.util.LinkedHashSet<>();
565+
for (final String typeName : model.oneOf) {
566+
if (!languageSpecificPrimitives.contains(typeName)
567+
&& !typeName.startsWith("List<")
568+
&& !typeName.startsWith("Map<")
569+
&& !typeName.startsWith("Set<")) {
570+
filteredOneOf.add(typeName);
571+
}
572+
}
573+
model.oneOf = filteredOneOf;
574+
575+
final Set<String> filteredAnyOf = new java.util.LinkedHashSet<>();
576+
for (final String typeName : model.anyOf) {
577+
if (!languageSpecificPrimitives.contains(typeName)
578+
&& !typeName.startsWith("List<")
579+
&& !typeName.startsWith("Map<")
580+
&& !typeName.startsWith("Set<")) {
581+
filteredAnyOf.add(typeName);
582+
}
583+
}
584+
model.anyOf = filteredAnyOf;
516585
}
517586
return result;
518587
}
519588

589+
@SuppressWarnings("unchecked")
590+
@Override
591+
public OperationsMap postProcessOperationsWithModels(
592+
OperationsMap objs, List<ModelMap> allModels) {
593+
objs = super.postProcessOperationsWithModels(objs, allModels);
594+
595+
final List<Map<String, String>> imports =
596+
(List<Map<String, String>>) objs.get("imports");
597+
if (imports != null) {
598+
imports.removeIf(imp -> {
599+
final String importName = imp.getOrDefault("import", "");
600+
final String className =
601+
importName.contains(".")
602+
? importName.substring(importName.lastIndexOf('.') + 1)
603+
: importName;
604+
return languageSpecificPrimitives.contains(className)
605+
|| typeMapping.containsValue(className)
606+
|| className.startsWith("List<")
607+
|| className.equals("List")
608+
|| className.startsWith("Map<")
609+
|| className.equals("Map")
610+
|| className.startsWith("Set<")
611+
|| className.equals("Set");
612+
});
613+
for (final Map<String, String> imp : imports) {
614+
if (!imp.containsKey("className") && imp.containsKey("import")) {
615+
String className = imp.get("import");
616+
if (className.contains(".")) {
617+
className = className.substring(className.lastIndexOf('.') + 1);
618+
}
619+
imp.put("className", className);
620+
imp.put("filename", toModelFilename(className));
621+
}
622+
}
623+
}
624+
return objs;
625+
}
626+
627+
private void clearEnumOnPrimitives(CodegenProperty prop) {
628+
if (prop.isEnum
629+
&& (languageSpecificPrimitives.contains(prop.dataType)
630+
|| typeMapping.containsValue(prop.dataType))) {
631+
prop.isEnum = false;
632+
}
633+
}
634+
520635
private void fixEnumDefaultValue(CodegenProperty prop) {
521636
if (prop.defaultValue != null && prop.isEnum && prop.defaultValue.contains(".")) {
522637
final String enumValue = prop.defaultValue.substring(
@@ -696,10 +811,10 @@ private String generateDartAuthClass(
696811
final String url = scheme.getOpenIdConnectUrl();
697812
return renderDartSchemeAuth(className + "Authenticator",
698813
"OpenIdConnectAuthenticator",
699-
List.of("oauth/openid_connect_authenticator.dart"),
814+
List.of("openid_connect_authenticator.dart"),
700815
"required String host, required String clientId, "
701816
+ "required String clientSecret, required String redirectUri",
702-
"host: host, discoveryUrl: '" + url + "', clientId: clientId, "
817+
"host: host, openIdConnectUrl: '" + url + "', clientId: clientId, "
703818
+ "clientSecret: clientSecret, redirectUri: redirectUri, "
704819
+ "scopes: []");
705820
}
@@ -716,7 +831,7 @@ private String generateDartOAuthClass(
716831
return renderDartSchemeAuth(
717832
className + "ClientCredentialsAuthenticator",
718833
"OAuth2ClientCredentialsAuthenticator",
719-
List.of("oauth/oauth2_client_credentials_authenticator.dart"),
834+
List.of("oauth2_client_credentials_authenticator.dart"),
720835
"required String host, required String clientId, "
721836
+ "required String clientSecret",
722837
"host: host, clientId: clientId, clientSecret: clientSecret, "
@@ -731,7 +846,7 @@ private String generateDartOAuthClass(
731846
return renderDartSchemeAuth(
732847
className + "PasswordAuthenticator",
733848
"OAuth2PasswordAuthenticator",
734-
List.of("oauth/oauth2_password_authenticator.dart"),
849+
List.of("oauth2_password_authenticator.dart"),
735850
"required String host, required String clientId, "
736851
+ "required String clientSecret, required String username, "
737852
+ "required String password",
@@ -749,7 +864,7 @@ private String generateDartOAuthClass(
749864
return renderDartSchemeAuth(
750865
className + "AuthorizationCodeAuthenticator",
751866
"OAuth2AuthorizationCodeAuthenticator",
752-
List.of("oauth/oauth2_auth_code_authenticator.dart"),
867+
List.of("oauth2_auth_code_authenticator.dart"),
753868
"required String host, required String clientId, "
754869
+ "required String clientSecret, required String redirectUri",
755870
"host: host, clientId: clientId, clientSecret: clientSecret, "
@@ -764,7 +879,7 @@ private String generateDartOAuthClass(
764879
return renderDartSchemeAuth(
765880
className + "ImplicitAuthenticator",
766881
"OAuth2ImplicitAuthenticator",
767-
List.of("oauth/oauth2_implicit_authenticator.dart"),
882+
List.of("oauth2_implicit_authenticator.dart"),
768883
"required String host, required String clientId",
769884
"host: host, clientId: clientId, authorizationUrl: '"
770885
+ authUrl + "', scopes: " + scopes);
@@ -852,11 +967,25 @@ protected String generateOptionsFileContent(
852967
params.add(param);
853968
}
854969

970+
final List<Map<String, String>> optionsImports = new ArrayList<>();
971+
for (final CodegenParameter p : optionsParams) {
972+
if (p.baseType != null
973+
&& !languageSpecificPrimitives.contains(p.baseType)
974+
&& !typeMapping.containsValue(p.baseType)) {
975+
final Map<String, String> imp = new HashMap<>();
976+
imp.put("classname", p.baseType);
977+
imp.put("filename", toModelFilename(p.baseType));
978+
optionsImports.add(imp);
979+
}
980+
}
981+
855982
final Map<String, Object> context = new HashMap<>();
856983
context.put("className", className);
857984
context.put("packageName", packageName);
858985
context.put("operationId", op.operationId);
859986
context.put("params", params);
987+
context.put("dartImports", optionsImports);
988+
context.put("hasDartImports", !optionsImports.isEmpty());
860989
return renderOptionsTemplate("api/options.mustache", context);
861990
}
862991

@@ -868,4 +997,33 @@ protected String getOptionsFilePath(String operationId, String optionsClassName)
868997
getOutputDir(), "lib", "src", "api", "options", fileName + ".dart")
869998
.toString();
870999
}
1000+
1001+
/** Appends options class exports to the barrel file. */
1002+
@Override
1003+
protected void writeOptionsBarrelFiles(List<Map<String, String>> optionsFiles) {
1004+
if (optionsFiles.isEmpty()) {
1005+
return;
1006+
}
1007+
final Path barrelPath =
1008+
Path.of(getOutputDir(), "lib", packageName + ".dart");
1009+
try {
1010+
final StringBuilder sb = new StringBuilder();
1011+
for (final Map<String, String> meta : optionsFiles) {
1012+
final String className = meta.get("optionsClassName");
1013+
if (className == null) {
1014+
continue;
1015+
}
1016+
final String fileName = NamingConvention.SNAKE_CASE.apply(className);
1017+
sb.append("export 'src/api/options/")
1018+
.append(fileName)
1019+
.append(".dart';\n");
1020+
}
1021+
Files.writeString(
1022+
barrelPath,
1023+
Files.readString(barrelPath) + sb,
1024+
StandardCharsets.UTF_8);
1025+
} catch (IOException e) {
1026+
LOGGER.warn("Failed to append options exports to barrel: {}", e.getMessage());
1027+
}
1028+
}
8711029
}

0 commit comments

Comments
 (0)