Demo ready

This commit is contained in:
Johan Maasing 2025-03-16 14:16:16 +01:00
parent 743432e071
commit 620999a992
Signed by: johan
GPG key ID: FFD31BABEE2DEED2
7 changed files with 190 additions and 74 deletions

View file

@ -0,0 +1,9 @@
projekt/create/ -> createProjekt(
id: ProjektId,
properties: ProjektProperties
)
projekt/update/ -> updateProjekt(
id: ProjektId,
properties: ProjektProperties
)

View file

@ -53,5 +53,9 @@
<groupId>info.picocli</groupId> <groupId>info.picocli</groupId>
<artifactId>picocli</artifactId> <artifactId>picocli</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>

View file

@ -1,70 +1,99 @@
package nu.zoom.tapir; package nu.zoom.tapir;
import nu.zoom.tapir.parser.*; import nu.zoom.tapir.parser.*;
import java.io.IOException; import java.io.IOException;
import java.io.StringReader; import java.io.StringReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import picocli.CommandLine; import picocli.CommandLine;
import picocli.CommandLine.Command; import picocli.CommandLine.Command;
import picocli.CommandLine.Option; import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters; import picocli.CommandLine.Parameters;
@Command(name = "tapirgen", mixinStandardHelpOptions = true, description = "Generate source code from a tapir endpoint specification file.") @Command(name = "tapirgen", mixinStandardHelpOptions = true, description = "Generate source code from a tapir endpoint specification file.")
public class Generator implements Callable<Integer> { public class Generator implements Callable<Integer> {
@Parameters(index = "0", description = "The source tapir file.") @Parameters(index = "0", description = "The source tapir file.")
private Path file; private Path file;
@Option(names = {"-t", "--template"}, description = "The template directory. Default is ~/tapir-templates") @Option(names = {"-t", "--template"}, description = "The template directory. Default is ~/tapir-templates")
private Path templateDir = Paths.get(System.getProperty("user.dir"), "tapir-templates"); private Path templateDir = Paths.get(System.getProperty("user.dir"), "tapir-templates");
@Option(names = {"-o", "--output"}, description = "The directory to write the gerenated code to. Default is ~/tapir-output") @Option(names = {"-o", "--output"}, description = "The directory to write the gerenated code to. Default is ~/tapir-output")
private Path outputDir = Paths.get(System.getProperty("user.dir"), "tapir-output"); private Path outputDir = Paths.get(System.getProperty("user.dir"), "tapir-output");
public static void main(String[] args) throws ParseException { @Option(names = {"-v", "--verbose"}, description = "Print verbose debug messages.")
int exitCode = new CommandLine(new Generator()).execute(args); private Boolean verbose = false;
System.exit(exitCode);
} public static void main(String[] args) throws ParseException {
int exitCode = new CommandLine(new Generator()).execute(args);
@Override System.exit(exitCode);
public Integer call() { }
try {
validateTemplateDirectory(); @Override
validateInputFile(); public Integer call() {
StringReader reader = new StringReader("foo/bar/baz/ -> fooHandler(id:String, name:NonEmptyString)foo/baz/->bazHandler(nothing:Any)"); try {
var rootNode = new TapirParser(reader).endpoints(); validateTemplateDirectory();
var endpoints = NodeTransformer.transform(rootNode); validateInputFile();
rootNode.dump(""); validateOutputDirectory();
endpoints.forEach(endpoint -> { var rootNode = new TapirParser(Files.newBufferedReader(this.file)).endpoints();
System.out.println(endpoint); if (this.verbose) {
}); System.out.println("====== Parse Tree ======");
return 0; rootNode.dump("");
} catch (Exception e) { }
System.err.println(e.getMessage()); var endpoints = NodeTransformer.transform(rootNode);
return 1; if (endpoints.isEmpty()) {
} System.err.println("No tapir endpoints found.");
} return 2;
}
private void validateTemplateDirectory() throws IOException { if (this.verbose) {
if (!Files.isDirectory(this.templateDir)) { System.out.println("\n====== AST ======");
throw new IllegalArgumentException("Template directory '" + this.templateDir + "' does not exist."); endpoints.forEach(endpoint -> {
} System.out.println(endpoint);
Path endpointTemplate = this.templateDir.resolve("endpoints.ftl") ; });
if (Files.notExists(endpointTemplate)) { }
throw new IllegalArgumentException("Can not find: '" + endpointTemplate + "'."); TargetGenerator targetGenerator = new TargetGenerator(
} this.verbose,
} this.outputDir,
this.templateDir,
private void validateInputFile() throws IOException { endpoints
if (Files.notExists(this.file)) { );
throw new IllegalArgumentException("Input file '" + this.file + "' does not exist."); targetGenerator.generate();
} return 0;
if (!Files.isReadable(this.file) || !Files.isRegularFile(this.file)) { } catch (Exception e) {
throw new IllegalArgumentException("Input file '" + this.file + "' is not a readable file."); System.err.println(e.getMessage());
} return 1;
} }
} }
private void validateOutputDirectory() throws IOException {
if (Files.notExists(this.outputDir)) {
Files.createDirectories(this.outputDir);
}
if (!Files.isDirectory(this.outputDir)) {
throw new IllegalArgumentException("Output directory: '" + this.outputDir + " 'is not a directory.");
}
}
private void validateTemplateDirectory() throws IOException {
if (!Files.isDirectory(this.templateDir)) {
throw new IllegalArgumentException("Template directory '" + this.templateDir + "' does not exist.");
}
Path endpointTemplate = this.templateDir.resolve(TargetGenerator.ENDPOINTS_TEMPLATE_NAME);
if (Files.notExists(endpointTemplate)) {
throw new IllegalArgumentException("Can not find: '" + endpointTemplate + "'.");
}
}
private void validateInputFile() throws IOException {
if (Files.notExists(this.file)) {
throw new IllegalArgumentException("Input file '" + this.file + "' does not exist.");
}
if (!Files.isReadable(this.file) || !Files.isRegularFile(this.file)) {
throw new IllegalArgumentException("Input file '" + this.file + "' is not a readable file.");
}
}
}

View file

@ -1,12 +1,24 @@
package nu.zoom.tapir; package nu.zoom.tapir;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
public class TargetGenerator { public class TargetGenerator {
private final Path outputPath; private final Path outputPath;
private final Path templatePath; private final Path templatePath;
private final boolean verbose;
public static String ENDPOINTS_TEMPLATE_NAME = "endpoints.ftl";
private final List<EndpointNode> endpoints;
public static class TargetGeneratorException extends Exception { public static class TargetGeneratorException extends Exception {
public TargetGeneratorException(String message) { public TargetGeneratorException(String message) {
@ -18,10 +30,12 @@ public class TargetGenerator {
} }
public TargetGenerator( public TargetGenerator(
final boolean verbose,
Path outputPath, Path outputPath,
Path templatePath, Path templatePath,
List<EndpointNode> endpoints List<EndpointNode> endpoints
) { ) {
this.verbose = verbose;
this.outputPath = Objects.requireNonNull( this.outputPath = Objects.requireNonNull(
outputPath, outputPath,
"Output path is required" "Output path is required"
@ -30,9 +44,26 @@ public class TargetGenerator {
templatePath, templatePath,
"Template path is required" "Template path is required"
); );
this.endpoints = Objects.requireNonNull(endpoints) ;
} }
public void generate() throws TargetGeneratorException { public void generate() throws TargetGeneratorException, IOException, TemplateException {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_34);
cfg.setDirectoryForTemplateLoading(this.templatePath.toFile());
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setLogTemplateExceptions(false);
cfg.setWrapUncheckedExceptions(true);
cfg.setFallbackOnNullLoopVariable(false);
Template temp = cfg.getTemplate(ENDPOINTS_TEMPLATE_NAME);
try (var outputFile = Files.newBufferedWriter(
outputPath.resolve("endpoints.scala"),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)) {
HashMap<String, List<EndpointNode>> templateData = new HashMap<>();
templateData.put("endpoints", endpoints);
temp.process(templateData, outputFile);
}
} }
} }

View file

@ -27,8 +27,9 @@ TOKEN : {
| <SLASH: "/"> | <SLASH: "/">
| <COLON: ":"> | <COLON: ":">
| <COMMA: ","> | <COMMA: ",">
| <LETTER: [ "A"-"Z", "a"-"z" ]> | <FIRST_LETTER: [ "A"-"Z", "a"-"z" ]>
| <IDENTIFIER: ( <LETTER>)+ > | <LETTER: [ "A"-"Z", "a"-"z", "0"-"9", "[", "]"] >
| <IDENTIFIER: <FIRST_LETTER> (<LETTER>)* >
} }
void path() : void path() :

View file

@ -30,6 +30,11 @@
<artifactId>picocli</artifactId> <artifactId>picocli</artifactId>
<version>4.7.6</version> <version>4.7.6</version>
</dependency> </dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.34</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View file

@ -0,0 +1,37 @@
package se.senashdev.projekt.api
import se.rutdev.projekt.api.HttpProtocol.VersionedResponse
import se.rutdev.framework.json.circe.RutUtilsCodec
import se.rutdev.framework
import se.rutdev.framework.service.api.{OAuthUtils, RequestMeta, RutTapir}
import se.rutdev.pd.ProblemDetailProtocol.ProblemDetail
import sttp.tapir.Schema
class Endpoints(override val config: OAuthUtils.OAuthConfig) extends framework.service.api.Endpoints with RutTapir with RutUtilsCodec:
type ApiEndpoint[I, O] = OAuthEndpoint[RequestMeta.OAuthRequestMeta, I, ProblemDetail, O]
<#list endpoints as endpoint>
case class ${endpoint.handler.name?cap_first}(
<#list endpoint.handler.fields as field>
${field.name} : ${field.type},
</#list>
)
</#list>
<#list endpoints as endpoint>
given Codec[${endpoint.handler.name?cap_first}] = deriveCodec
</#list>
<#list endpoints as endpoint>
val ${endpoint.handler.name}Endpoint = ApiEndpoint[${endpoint.handler.name?cap_first}, VersionedResponse] =
<#list endpoint.paths.paths>
apiV1Endpoint
.post
<#items as segment>
.in("${segment}")
</#items>
.post
.in(jsonBody[${endpoint.handler.name?cap_first}])
.out(jsonBody[VersionedResponse])
</#list>
</#list>