|
| 1 | +package org.openapitools.codegen.typescript.axios; |
| 2 | + |
| 3 | +import org.openapitools.codegen.ClientOptInput; |
| 4 | +import org.openapitools.codegen.DefaultGenerator; |
| 5 | +import org.openapitools.codegen.config.CodegenConfigurator; |
| 6 | +import org.openapitools.codegen.typescript.TypeScriptGroups; |
| 7 | +import org.testng.annotations.Test; |
| 8 | + |
| 9 | +import java.io.File; |
| 10 | +import java.io.IOException; |
| 11 | +import java.nio.file.Files; |
| 12 | +import java.nio.file.Path; |
| 13 | +import java.util.ArrayList; |
| 14 | +import java.util.Arrays; |
| 15 | +import java.util.Collections; |
| 16 | +import java.util.LinkedHashSet; |
| 17 | +import java.util.List; |
| 18 | +import java.util.Map; |
| 19 | +import java.util.Set; |
| 20 | +import java.util.TreeMap; |
| 21 | +import java.util.TreeSet; |
| 22 | +import java.util.function.Consumer; |
| 23 | +import java.util.regex.Matcher; |
| 24 | +import java.util.regex.Pattern; |
| 25 | + |
| 26 | +import static org.testng.Assert.assertEquals; |
| 27 | +import static org.testng.Assert.assertFalse; |
| 28 | +import static org.testng.Assert.assertTrue; |
| 29 | + |
| 30 | +@Test(groups = {TypeScriptGroups.TYPESCRIPT, TypeScriptGroups.TYPESCRIPT_AXIOS}) |
| 31 | +public class TypeScriptAxiosSlimParityTest { |
| 32 | + private static final String EDGE_CASE_SPEC = "src/test/resources/3_0/typescript-axios-slim/identity-edge-cases.yaml"; |
| 33 | + |
| 34 | + private static final Pattern REQUEST_INTERFACE_PATTERN = Pattern.compile("export interface (\\w+Request) \\{([\\s\\S]*?)\\n\\}"); |
| 35 | + private static final Pattern API_INTERFACE_PATTERN = Pattern.compile("export interface (\\w+Interface) \\{([\\s\\S]*?)\\n\\}"); |
| 36 | + private static final Pattern API_INTERFACE_METHOD_PATTERN = Pattern.compile("(\\w+)\\(([^)]*)\\):\\s*AxiosPromise<([^;]+)>;"); |
| 37 | + private static final Pattern ENUM_DECL_PATTERN = Pattern.compile("export enum (\\w+) \\{([\\s\\S]*?)\\n\\}"); |
| 38 | + private static final Pattern CONST_ENUM_DECL_PATTERN = Pattern.compile("export const (\\w+) = \\{([\\s\\S]*?)\\} as const;"); |
| 39 | + |
| 40 | + private static final List<String> API_DIR_CANDIDATES = Arrays.asList("api", "apis"); |
| 41 | + private static final List<String> MODEL_DIR_CANDIDATES = Arrays.asList("model", "models"); |
| 42 | + |
| 43 | + private static final Set<String> EXPECTED_EDGE_METHODS = new TreeSet<>(Arrays.asList( |
| 44 | + "getUserByCompany", |
| 45 | + "createUserByCompany", |
| 46 | + "deleteUserByCompany", |
| 47 | + "aliasLookup", |
| 48 | + "listReports", |
| 49 | + "submitForm", |
| 50 | + "uploadEvidence", |
| 51 | + "healthCheck", |
| 52 | + "searchUsers", |
| 53 | + "getUnionPayload", |
| 54 | + "getWithQueryApiKey" |
| 55 | + )); |
| 56 | + |
| 57 | + private static final Consumer<CodegenConfigurator> NO_CUSTOMIZER = cfg -> { |
| 58 | + }; |
| 59 | + |
| 60 | + @Test(description = "identity: comprehensive edge-case spec across option matrix") |
| 61 | + public void shouldKeepIdentityForComprehensiveEdgeCaseSpecAcrossOptionMatrix() throws Exception { |
| 62 | + List<OptionScenario> scenarios = Arrays.asList( |
| 63 | + new OptionScenario("default", NO_CUSTOMIZER), |
| 64 | + new OptionScenario("string-enums", cfg -> cfg.addAdditionalProperty("stringEnums", true)), |
| 65 | + new OptionScenario("node-imports", cfg -> cfg.addAdditionalProperty("withNodeImports", true)), |
| 66 | + new OptionScenario("aws-v4-signature", cfg -> cfg.addAdditionalProperty("withAWSV4Signature", true)), |
| 67 | + new OptionScenario("separate-models-and-api", cfg -> cfg |
| 68 | + .addAdditionalProperty("withSeparateModelsAndApi", true) |
| 69 | + .addAdditionalProperty("apiPackage", "api") |
| 70 | + .addAdditionalProperty("modelPackage", "model")), |
| 71 | + new OptionScenario("import-js-extension", cfg -> cfg.addAdditionalProperty("importFileExtension", ".js")), |
| 72 | + new OptionScenario("square-bracket-form-arrays", cfg -> cfg.addAdditionalProperty("useSquareBracketsInArrayNames", true)) |
| 73 | + ); |
| 74 | + |
| 75 | + for (OptionScenario scenario : scenarios) { |
| 76 | + IdentitySurface axiosSurface = generateIdentity("typescript-axios", EDGE_CASE_SPEC, scenario.customizer); |
| 77 | + IdentitySurface slimSurface = generateIdentity("typescript-axios-slim", EDGE_CASE_SPEC, scenario.customizer); |
| 78 | + |
| 79 | + assertIdentitySurfaceEquals(scenario.name, axiosSurface, slimSurface); |
| 80 | + assertTrue(axiosSurface.allMethodNames().containsAll(EXPECTED_EDGE_METHODS), |
| 81 | + "Scenario " + scenario.name + " did not generate the expected operation set"); |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + @Test(description = "identity: known regression fixtures (req/res surface)") |
| 86 | + public void shouldKeepIdentityAcrossKnownRegressionFixtures() throws Exception { |
| 87 | + List<SpecScenario> scenarios = Arrays.asList( |
| 88 | + new SpecScenario("petstore", "src/test/resources/3_0/petstore.yaml", NO_CUSTOMIZER), |
| 89 | + new SpecScenario("nullable-required", "src/test/resources/3_0/petstore-with-nullable-required.yaml", NO_CUSTOMIZER), |
| 90 | + new SpecScenario("multiple-2xx", "src/test/resources/3_0/petstore-multiple-2xx-responses.yaml", NO_CUSTOMIZER), |
| 91 | + new SpecScenario("query-form", "src/test/resources/3_0/query-param-form.yaml", NO_CUSTOMIZER), |
| 92 | + new SpecScenario("query-deep-object", "src/test/resources/3_0/query-param-deep-object.yaml", NO_CUSTOMIZER), |
| 93 | + new SpecScenario("deepobject", "src/test/resources/3_0/deepobject.yaml", NO_CUSTOMIZER), |
| 94 | + new SpecScenario("parameter-name-mapping", "src/test/resources/3_0/name-parameter-mappings.yaml", NO_CUSTOMIZER), |
| 95 | + new SpecScenario("shared-parameters-3_1", "src/test/resources/3_1/common-parameters.yaml", NO_CUSTOMIZER), |
| 96 | + new SpecScenario("multipart-enum-3_1", "src/test/resources/3_1/enum-in-multipart.yaml", NO_CUSTOMIZER), |
| 97 | + new SpecScenario("map-array-inner-enum", "src/test/resources/3_0/issue_19393_map_of_inner_enum.yaml", NO_CUSTOMIZER), |
| 98 | + new SpecScenario("generic-type-mapping", "src/test/resources/3_1/issue_21317.yaml", cfg -> cfg |
| 99 | + .addTypeMapping("UserSummary", "Pick<User, \"email\">") |
| 100 | + .addTypeMapping("object", "Record<string,unknown>")) |
| 101 | + ); |
| 102 | + |
| 103 | + for (SpecScenario scenario : scenarios) { |
| 104 | + IdentitySurface axiosSurface = generateIdentity("typescript-axios", scenario.specPath, scenario.customizer); |
| 105 | + IdentitySurface slimSurface = generateIdentity("typescript-axios-slim", scenario.specPath, scenario.customizer); |
| 106 | + assertIdentitySurfaceEquals(scenario.name, axiosSurface, slimSurface); |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + @Test(description = "identity: mapped generic response signature is preserved") |
| 111 | + public void shouldPreserveMappedGenericResponseSignature() throws Exception { |
| 112 | + Consumer<CodegenConfigurator> mappedTypeCustomizer = cfg -> cfg |
| 113 | + .addTypeMapping("UserSummary", "Pick<User, \"email\">") |
| 114 | + .addTypeMapping("object", "Record<string,unknown>"); |
| 115 | + |
| 116 | + IdentitySurface axiosSurface = generateIdentity("typescript-axios", "src/test/resources/3_1/issue_21317.yaml", mappedTypeCustomizer); |
| 117 | + IdentitySurface slimSurface = generateIdentity("typescript-axios-slim", "src/test/resources/3_1/issue_21317.yaml", mappedTypeCustomizer); |
| 118 | + |
| 119 | + assertIdentitySurfaceEquals("generic-type-mapping-signature", axiosSurface, slimSurface); |
| 120 | + } |
| 121 | + |
| 122 | + private IdentitySurface generateIdentity(String generatorName, String specPath, Consumer<CodegenConfigurator> customizer) throws Exception { |
| 123 | + File output = Files.createTempDirectory("typescript_axios_identity_").toFile().getCanonicalFile(); |
| 124 | + output.deleteOnExit(); |
| 125 | + |
| 126 | + CodegenConfigurator configurator = new CodegenConfigurator() |
| 127 | + .setGeneratorName(generatorName) |
| 128 | + .setInputSpec(specPath) |
| 129 | + .setOutputDir(output.getAbsolutePath()) |
| 130 | + .addAdditionalProperty("withInterfaces", true) |
| 131 | + .addAdditionalProperty("useSingleRequestParameter", true); |
| 132 | + customizer.accept(configurator); |
| 133 | + |
| 134 | + ClientOptInput clientOptInput = configurator.toClientOptInput(); |
| 135 | + new DefaultGenerator().opts(clientOptInput).generate(); |
| 136 | + |
| 137 | + return extractIdentity(output.toPath()); |
| 138 | + } |
| 139 | + |
| 140 | + private IdentitySurface extractIdentity(Path outputDir) throws IOException { |
| 141 | + IdentitySurface surface = new IdentitySurface(); |
| 142 | + |
| 143 | + List<Path> apiFiles = collectApiFiles(outputDir); |
| 144 | + assertFalse(apiFiles.isEmpty(), "No API source files were generated under " + outputDir); |
| 145 | + |
| 146 | + for (Path apiFile : apiFiles) { |
| 147 | + String content = Files.readString(apiFile); |
| 148 | + String relativePath = normalizePath(outputDir.relativize(apiFile)); |
| 149 | + surface.apiFiles.put(relativePath, normalize(content)); |
| 150 | + |
| 151 | + extractRequestInterfaces(surface.requestInterfaces, content); |
| 152 | + extractApiInterfaceMethods(surface.apiInterfaceMethods, content); |
| 153 | + extractOperationEnums(surface.operationEnums, content); |
| 154 | + } |
| 155 | + |
| 156 | + List<Path> modelFiles = collectModelFiles(outputDir); |
| 157 | + for (Path modelFile : modelFiles) { |
| 158 | + String relativePath = normalizePath(outputDir.relativize(modelFile)); |
| 159 | + surface.modelFiles.put(relativePath, normalize(Files.readString(modelFile))); |
| 160 | + } |
| 161 | + |
| 162 | + assertTrue(!surface.requestInterfaces.isEmpty() || !surface.apiInterfaceMethods.isEmpty(), |
| 163 | + "No comparable request/response surface was extracted from generated sources under " + outputDir); |
| 164 | + |
| 165 | + return surface; |
| 166 | + } |
| 167 | + |
| 168 | + private List<Path> collectApiFiles(Path outputDir) throws IOException { |
| 169 | + LinkedHashSet<Path> files = new LinkedHashSet<>(); |
| 170 | + Path rootApi = outputDir.resolve("api.ts"); |
| 171 | + if (Files.exists(rootApi)) { |
| 172 | + files.add(rootApi); |
| 173 | + } |
| 174 | + |
| 175 | + for (String dirName : API_DIR_CANDIDATES) { |
| 176 | + Path dir = outputDir.resolve(dirName); |
| 177 | + if (Files.isDirectory(dir)) { |
| 178 | + Files.walk(dir) |
| 179 | + .filter(Files::isRegularFile) |
| 180 | + .filter(path -> path.getFileName().toString().endsWith(".ts")) |
| 181 | + .forEach(files::add); |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + List<Path> sorted = new ArrayList<>(files); |
| 186 | + Collections.sort(sorted); |
| 187 | + return sorted; |
| 188 | + } |
| 189 | + |
| 190 | + private List<Path> collectModelFiles(Path outputDir) throws IOException { |
| 191 | + LinkedHashSet<Path> files = new LinkedHashSet<>(); |
| 192 | + for (String dirName : MODEL_DIR_CANDIDATES) { |
| 193 | + Path dir = outputDir.resolve(dirName); |
| 194 | + if (Files.isDirectory(dir)) { |
| 195 | + Files.walk(dir) |
| 196 | + .filter(Files::isRegularFile) |
| 197 | + .filter(path -> path.getFileName().toString().endsWith(".ts")) |
| 198 | + .forEach(files::add); |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + List<Path> sorted = new ArrayList<>(files); |
| 203 | + Collections.sort(sorted); |
| 204 | + return sorted; |
| 205 | + } |
| 206 | + |
| 207 | + private void extractRequestInterfaces(Map<String, String> target, String apiSource) { |
| 208 | + Matcher matcher = REQUEST_INTERFACE_PATTERN.matcher(apiSource); |
| 209 | + while (matcher.find()) { |
| 210 | + target.put(matcher.group(1), normalize(matcher.group(2))); |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + private void extractApiInterfaceMethods(Map<String, Set<String>> target, String apiSource) { |
| 215 | + Matcher interfaceMatcher = API_INTERFACE_PATTERN.matcher(apiSource); |
| 216 | + while (interfaceMatcher.find()) { |
| 217 | + String interfaceName = interfaceMatcher.group(1); |
| 218 | + String interfaceBody = interfaceMatcher.group(2); |
| 219 | + Matcher methodMatcher = API_INTERFACE_METHOD_PATTERN.matcher(interfaceBody); |
| 220 | + while (methodMatcher.find()) { |
| 221 | + String methodName = methodMatcher.group(1); |
| 222 | + String params = normalize(methodMatcher.group(2)); |
| 223 | + String returnType = normalize(methodMatcher.group(3)); |
| 224 | + target.computeIfAbsent(interfaceName, ignored -> new TreeSet<>()) |
| 225 | + .add(methodName + "(" + params + "):" + returnType); |
| 226 | + } |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + private void extractOperationEnums(Map<String, String> target, String apiSource) { |
| 231 | + Matcher enumMatcher = ENUM_DECL_PATTERN.matcher(apiSource); |
| 232 | + while (enumMatcher.find()) { |
| 233 | + target.put("enum:" + enumMatcher.group(1), normalize(enumMatcher.group(2))); |
| 234 | + } |
| 235 | + |
| 236 | + Matcher constEnumMatcher = CONST_ENUM_DECL_PATTERN.matcher(apiSource); |
| 237 | + while (constEnumMatcher.find()) { |
| 238 | + target.put("const:" + constEnumMatcher.group(1), normalize(constEnumMatcher.group(2))); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + private void assertIdentitySurfaceEquals(String scenarioName, IdentitySurface expectedAxios, IdentitySurface actualSlim) { |
| 243 | + assertEquals(actualSlim.requestInterfaces, expectedAxios.requestInterfaces, |
| 244 | + scenarioName + ": request interface identity mismatch"); |
| 245 | + assertEquals(actualSlim.apiInterfaceMethods, expectedAxios.apiInterfaceMethods, |
| 246 | + scenarioName + ": API interface method identity mismatch"); |
| 247 | + assertEquals(actualSlim.operationEnums, expectedAxios.operationEnums, |
| 248 | + scenarioName + ": enum identity mismatch"); |
| 249 | + assertEquals(actualSlim.modelFiles, expectedAxios.modelFiles, |
| 250 | + scenarioName + ": model file identity mismatch"); |
| 251 | + } |
| 252 | + |
| 253 | + private static String normalize(String content) { |
| 254 | + return content |
| 255 | + .replace("\r\n", "\n") |
| 256 | + .replace('\r', '\n') |
| 257 | + .replaceAll("\\s+", " ") |
| 258 | + .trim(); |
| 259 | + } |
| 260 | + |
| 261 | + private static String normalizePath(Path path) { |
| 262 | + return path.toString().replace('\\', '/'); |
| 263 | + } |
| 264 | + |
| 265 | + private static final class IdentitySurface { |
| 266 | + private final Map<String, String> requestInterfaces = new TreeMap<>(); |
| 267 | + private final Map<String, Set<String>> apiInterfaceMethods = new TreeMap<>(); |
| 268 | + private final Map<String, String> operationEnums = new TreeMap<>(); |
| 269 | + private final Map<String, String> modelFiles = new TreeMap<>(); |
| 270 | + private final Map<String, String> apiFiles = new TreeMap<>(); |
| 271 | + |
| 272 | + private Set<String> allMethodNames() { |
| 273 | + Set<String> names = new TreeSet<>(); |
| 274 | + |
| 275 | + for (Set<String> signatures : apiInterfaceMethods.values()) { |
| 276 | + for (String signature : signatures) { |
| 277 | + names.add(methodNameFromSignature(signature)); |
| 278 | + } |
| 279 | + } |
| 280 | + return names; |
| 281 | + } |
| 282 | + |
| 283 | + private String methodNameFromSignature(String signature) { |
| 284 | + int index = signature.indexOf('('); |
| 285 | + return index >= 0 ? signature.substring(0, index) : signature; |
| 286 | + } |
| 287 | + } |
| 288 | + |
| 289 | + private static final class OptionScenario { |
| 290 | + private final String name; |
| 291 | + private final Consumer<CodegenConfigurator> customizer; |
| 292 | + |
| 293 | + private OptionScenario(String name, Consumer<CodegenConfigurator> customizer) { |
| 294 | + this.name = name; |
| 295 | + this.customizer = customizer; |
| 296 | + } |
| 297 | + } |
| 298 | + |
| 299 | + private static final class SpecScenario { |
| 300 | + private final String name; |
| 301 | + private final String specPath; |
| 302 | + private final Consumer<CodegenConfigurator> customizer; |
| 303 | + |
| 304 | + private SpecScenario(String name, String specPath, Consumer<CodegenConfigurator> customizer) { |
| 305 | + this.name = name; |
| 306 | + this.specPath = specPath; |
| 307 | + this.customizer = customizer; |
| 308 | + } |
| 309 | + } |
| 310 | +} |
0 commit comments