Skip to content

Commit f8f7a7b

Browse files
authored
Skip generation if spec or configuration is unchanged (quarkiverse#1514)
1 parent f2b687b commit f8f7a7b

File tree

8 files changed

+393
-28
lines changed

8 files changed

+393
-28
lines changed

client/deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/CodegenConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ enum ConfigName {
4545
EXCLUDE_GAVS("exclude-gavs"),
4646
VALIDATE_SPEC("validateSpec"),
4747
DEFAULT_SECURITY_SCHEME("default-security-scheme"),
48+
SKIP_IF_UNCHANGED("skip-if-unchanged"),
4849

4950
//spec configs only
5051
BASE_PACKAGE("base-package"),

client/deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/GlobalCodegenConfig.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,10 @@ public interface GlobalCodegenConfig extends CommonItemConfig {
9494
@WithName("default-security-scheme")
9595
Optional<String> defaultSecuritySchema();
9696

97+
/**
98+
* Whether to skip code generation if spec or configuration has not changed
99+
*/
100+
@WithName("skip-if-unchanged")
101+
@WithDefault("false")
102+
Optional<Boolean> skipIfUnchanged();
97103
}

client/deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/codegen/OpenApiGeneratorCodeGenBase.java

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@
2020
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.ConfigName.VALIDATE_SPEC;
2121

2222
import java.io.IOException;
23+
import java.nio.charset.StandardCharsets;
2324
import java.nio.file.Files;
2425
import java.nio.file.Path;
26+
import java.security.MessageDigest;
2527
import java.util.Arrays;
28+
import java.util.HexFormat;
2629
import java.util.List;
2730
import java.util.Map;
2831
import java.util.Optional;
@@ -205,10 +208,33 @@ private static String determineRestClientReactiveJacksonCapabilityId() {
205208
return Capability.REST_CLIENT_REACTIVE_JACKSON;
206209
}
207210

208-
// TODO: do not generate if the output dir has generated files and the openapi file has the same checksum of the previous run
209211
protected void generate(OpenApiGeneratorOptions options) {
210212
Config config = options.config();
211213
Path openApiFilePath = options.openApiFilePath();
214+
215+
boolean skipIfUnchanged = getValues(
216+
config,
217+
openApiFilePath,
218+
CodegenConfig.ConfigName.SKIP_IF_UNCHANGED,
219+
Boolean.class).orElse(false);
220+
221+
if (!skipIfUnchanged) {
222+
doGenerate(options);
223+
return;
224+
}
225+
226+
String fingerprint = computeFingerprint(options);
227+
if (shouldSkipGeneration(options, fingerprint)) {
228+
return;
229+
}
230+
231+
doGenerate(options);
232+
persistFingerprint(options, fingerprint);
233+
}
234+
235+
protected void doGenerate(OpenApiGeneratorOptions options) {
236+
Config config = options.config();
237+
Path openApiFilePath = options.openApiFilePath();
212238
Path outDir = options.outDir();
213239
boolean isRestEasyReactive = options.isRestEasyReactive();
214240

@@ -503,4 +529,101 @@ private <K, V> Optional<Map<K, V>> getConfigKeyValues(final SmallRyeConfig confi
503529
return possibleConfigKey.flatMap(s -> getValuesByConfigKey(config, configName, kClass, vClass, s));
504530

505531
}
532+
533+
private Path resolveChecksumFile(Path outDir, Path openApiFilePath) {
534+
return outDir.resolve(".quarkus-openapi-generator")
535+
.resolve(getSanitizedFileName(openApiFilePath) + ".sha256");
536+
}
537+
538+
private String computeFingerprint(OpenApiGeneratorOptions options) {
539+
try {
540+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
541+
542+
updateDigestWithFile(digest, options.openApiFilePath());
543+
updateDigestWithString(digest, "reactive=" + options.isRestEasyReactive());
544+
updateDigestWithString(digest, "generator=" + getClass().getName());
545+
updateDigestWithString(digest, "quarkusVersion=" + Version.getVersion());
546+
547+
addRelevantConfig(digest, options.config());
548+
549+
Path templateDir = options.templateDir();
550+
if (templateDir != null && Files.isDirectory(templateDir)) {
551+
try (Stream<Path> paths = Files.walk(templateDir).sorted()) {
552+
for (Path path : paths.filter(Files::isRegularFile).toList()) {
553+
updateDigestWithString(digest, "template:" + templateDir.relativize(path));
554+
updateDigestWithFile(digest, path);
555+
}
556+
}
557+
}
558+
559+
return HexFormat.of().formatHex(digest.digest());
560+
} catch (Exception e) {
561+
throw new RuntimeException("Unable to compute fingerprint for " + options.openApiFilePath(), e);
562+
}
563+
}
564+
565+
private void updateDigestWithString(MessageDigest digest, String value) {
566+
digest.update(value.getBytes(StandardCharsets.UTF_8));
567+
}
568+
569+
private void updateDigestWithFile(MessageDigest digest, Path file) throws IOException {
570+
digest.update(Files.readAllBytes(file));
571+
}
572+
573+
// Includes configuration in the fingerprint so changes force regeneration
574+
private void addRelevantConfig(MessageDigest digest, Config config) {
575+
String prefix = "quarkus." + CodegenConfig.CODEGEN_TIME_CONFIG_PREFIX + ".";
576+
577+
StreamSupport.stream(config.getPropertyNames().spliterator(), false)
578+
.filter(propertyName -> propertyName.startsWith(prefix))
579+
.sorted()
580+
.forEach(propertyName -> config.getOptionalValue(propertyName, String.class)
581+
.ifPresent(value -> updateDigestWithString(digest, propertyName + "=" + value)));
582+
}
583+
584+
private boolean shouldSkipGeneration(OpenApiGeneratorOptions options, String fingerprint) {
585+
Path checksumFile = resolveChecksumFile(options.outDir(), options.openApiFilePath());
586+
587+
if (!Files.exists(checksumFile)) {
588+
return false;
589+
}
590+
591+
if (!hasGeneratedFiles(options)) {
592+
return false;
593+
}
594+
595+
try {
596+
String previous = Files.readString(checksumFile);
597+
return previous.equals(fingerprint);
598+
} catch (IOException e) {
599+
return false;
600+
}
601+
}
602+
603+
// Store a checksum based on spec and configuration,
604+
// in order to avoid regenerating code when the latter hasn't changed
605+
private void persistFingerprint(OpenApiGeneratorOptions options, String fingerprint) {
606+
Path checksumFile = resolveChecksumFile(options.outDir(), options.openApiFilePath());
607+
608+
try {
609+
Files.createDirectories(checksumFile.getParent());
610+
Files.writeString(checksumFile, fingerprint);
611+
} catch (IOException e) {
612+
String message = "Unable to persist OpenAPI generation fingerprint for " + options.openApiFilePath();
613+
throw new RuntimeException(message, e);
614+
}
615+
}
616+
617+
private boolean hasGeneratedFiles(OpenApiGeneratorOptions options) {
618+
Path outDir = options.outDir();
619+
if (!Files.isDirectory(outDir)) {
620+
return false;
621+
}
622+
623+
try (Stream<Path> paths = Files.walk(outDir)) {
624+
return paths.anyMatch(path -> Files.isRegularFile(path) && path.toString().endsWith(".java"));
625+
} catch (IOException e) {
626+
return false;
627+
}
628+
}
506629
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package io.quarkiverse.openapi.generator.deployment;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.io.IOException;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.util.Map;
9+
import java.util.concurrent.atomic.AtomicInteger;
10+
11+
import org.eclipse.microprofile.config.Config;
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.io.TempDir;
14+
15+
import io.quarkiverse.openapi.generator.deployment.codegen.OpenApiGeneratorCodeGenBase;
16+
import io.smallrye.config.SmallRyeConfig;
17+
import io.smallrye.config.SmallRyeConfigBuilder;
18+
19+
class OpenApiGeneratorCodeGenSkipIfUnchangedTest {
20+
21+
private static final String SPEC_YAML = "petstore.yaml";
22+
private static final String CONFIG_PROPERTY = "quarkus.openapi-generator.codegen.skip-if-unchanged";
23+
24+
private static final String SPEC = """
25+
openapi: 3.0.1
26+
info:
27+
title: Test
28+
version: 1.0.0
29+
paths: {}""";
30+
31+
private static final String ANOTHER_SPEC = """
32+
openapi: 3.0.1
33+
info:
34+
title: Another Test
35+
version: 1.0.0
36+
paths: {}""";
37+
38+
@TempDir
39+
Path tempDir;
40+
41+
@Test
42+
void shouldSkipGenerationWhenEnabledAndUnchanged() throws Exception {
43+
Path spec = tempDir.resolve(SPEC_YAML);
44+
Files.writeString(spec, SPEC);
45+
46+
Path outDir = tempDir.resolve("generated");
47+
Files.createDirectories(outDir);
48+
49+
TestCodegen codegen = new TestCodegen();
50+
Config config = config(Map.of(CONFIG_PROPERTY, "true"));
51+
52+
OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
53+
config,
54+
spec,
55+
outDir,
56+
tempDir.resolve("templates"),
57+
false);
58+
59+
codegen.generate(options);
60+
codegen.generate(options);
61+
62+
assertThat(codegen.generateCount).hasValue(1);
63+
}
64+
65+
@Test
66+
void shouldGenerateAgainWhenOptionDisabled() throws Exception {
67+
Path spec = tempDir.resolve(SPEC_YAML);
68+
Files.writeString(spec, SPEC);
69+
70+
Path outDir = tempDir.resolve("generated");
71+
Files.createDirectories(outDir);
72+
73+
TestCodegen codegen = new TestCodegen();
74+
Config config = config(Map.of(CONFIG_PROPERTY, "false"));
75+
76+
OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
77+
config,
78+
spec,
79+
outDir,
80+
tempDir.resolve("templates"),
81+
false);
82+
83+
codegen.generate(options);
84+
codegen.generate(options);
85+
86+
assertThat(codegen.generateCount).hasValue(2);
87+
}
88+
89+
@Test
90+
void shouldGenerateAgainWhenFingerprintChanges() throws Exception {
91+
Path spec = tempDir.resolve(SPEC_YAML);
92+
Files.writeString(spec, SPEC);
93+
94+
Path outDir = tempDir.resolve("generated");
95+
Files.createDirectories(outDir);
96+
97+
TestCodegen codegen = new TestCodegen();
98+
Config config = config(Map.of(CONFIG_PROPERTY, "true"));
99+
100+
OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
101+
config,
102+
spec,
103+
outDir,
104+
tempDir.resolve("templates"),
105+
false);
106+
codegen.generate(options);
107+
108+
// Change the spec, to trigger a new generation
109+
Files.writeString(spec, ANOTHER_SPEC);
110+
codegen.generate(options);
111+
112+
assertThat(codegen.generateCount).hasValue(2);
113+
}
114+
115+
@Test
116+
void shouldGenerateAgainWhenConfigKeyChanges() throws Exception {
117+
Path spec = tempDir.resolve(SPEC_YAML);
118+
Files.writeString(spec, SPEC);
119+
120+
Path outDir = tempDir.resolve("generated");
121+
Files.createDirectories(outDir);
122+
123+
TestCodegen codegen = new TestCodegen();
124+
125+
Config firstConfig = config(Map.of(
126+
CONFIG_PROPERTY, "true",
127+
"quarkus.openapi-generator.codegen.spec.petstore_yaml.config-key", "petstore",
128+
"quarkus.openapi-generator.codegen.spec.petstore.additional-api-type-annotations", "@org.test.Foo"));
129+
130+
OpenApiGeneratorOptions firstOptions = new OpenApiGeneratorOptions(
131+
firstConfig,
132+
spec,
133+
outDir,
134+
tempDir.resolve("templates"),
135+
false);
136+
137+
codegen.generate(firstOptions);
138+
139+
Config secondConfig = config(Map.of(
140+
CONFIG_PROPERTY, "true",
141+
"quarkus.openapi-generator.codegen.spec.petstore_yaml.config-key", "another",
142+
"quarkus.openapi-generator.codegen.spec.another.additional-api-type-annotations", "@org.test.Foo"));
143+
144+
OpenApiGeneratorOptions secondOptions = new OpenApiGeneratorOptions(
145+
secondConfig,
146+
spec,
147+
outDir,
148+
tempDir.resolve("templates"),
149+
false);
150+
151+
codegen.generate(secondOptions);
152+
153+
assertThat(codegen.generateCount).hasValue(2);
154+
}
155+
156+
private static SmallRyeConfig config(Map<String, String> values) {
157+
SmallRyeConfigBuilder builder = new SmallRyeConfigBuilder();
158+
values.forEach(builder::withDefaultValue);
159+
return builder.build();
160+
}
161+
162+
// Test code generator that generates dummy content
163+
static final class TestCodegen extends OpenApiGeneratorCodeGenBase {
164+
final AtomicInteger generateCount = new AtomicInteger();
165+
166+
@Override
167+
protected void doGenerate(OpenApiGeneratorOptions options) {
168+
generateCount.incrementAndGet();
169+
try {
170+
Path javaFile = options.outDir().resolve("org/example/PetApi.java");
171+
Files.createDirectories(javaFile.getParent());
172+
Files.writeString(javaFile, "package org.example; class PetApi {}");
173+
} catch (IOException e) {
174+
throw new RuntimeException(e);
175+
}
176+
}
177+
178+
@Override
179+
protected void generate(final OpenApiGeneratorOptions options) {
180+
super.generate(options);
181+
}
182+
183+
@Override
184+
public String providerId() {
185+
return "openapi-test";
186+
}
187+
188+
@Override
189+
public String[] inputExtensions() {
190+
return new String[] { ".yaml" };
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)