Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
// If you don't need any common settings/dependencies/... for everything, remove this convention plugin and the reference to it in `io.micronaut.build.internal.project-template-module.gradle` file
repositories {
maven {
url("https://s01.oss.sonatype.org/content/repositories/snapshots")
}
}
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
# a managed version (a version which alias starts with "managed-"

[versions]
micronaut = "4.6.5"
micronaut = "4.7.0"
micronaut-docs = "2.0.0"
micronaut-logging = "1.3.0"
micronaut-serde = "2.11.0"
micronaut-test = "4.4.0"
micronaut-validation = "4.7.0"
micronaut-sourcegen = "1.3.2-SNAPSHOT"
groovy = "4.0.18"
managed-json-schema-validator = "1.5.2"
kotlin = "1.9.25"
Expand All @@ -34,6 +35,7 @@ micronaut-logging = { module = "io.micronaut.logging:micronaut-logging-bom", ver
micronaut-serde = { module = "io.micronaut.serde:micronaut-serde-bom", version.ref = "micronaut-serde" }
micronaut-test = { module = "io.micronaut.test:micronaut-test-bom", version.ref = "micronaut-test" }
micronaut-validation = { module = "io.micronaut.validation:micronaut-validation-bom", version.ref = "micronaut-validation" }
micronaut-sourcegen = { module = "io.micronaut.sourcegen:micronaut-sourcegen-bom", version.ref = "micronaut-sourcegen" }

managed-json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "managed-json-schema-validator" }

Expand Down
17 changes: 17 additions & 0 deletions json-schema-generator/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
plugins {
id("io.micronaut.build.internal.json-schema-module")
}

dependencies {
compileOnly(mn.micronaut.core.processor)

implementation(mnSourcegen.micronaut.sourcegen.model)
implementation(mnSourcegen.micronaut.sourcegen.generator)
implementation(mnSourcegen.micronaut.sourcegen.generator.java)
implementation(mnSourcegen.micronaut.sourcegen.annotations)
implementation(mnSerde.micronaut.serde.jackson)

api(projects.micronautJsonSchemaAnnotations)
api(mn.jackson.databind)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.jsonschema.generator;

import com.fasterxml.jackson.databind.json.JsonMapper;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.io.ResourceLoader;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.inject.processing.ProcessingException;
import io.micronaut.inject.visitor.VisitorContext;
import io.micronaut.jsonschema.generator.aggregator.AnnotationInfoAggregator;
import io.micronaut.serde.annotation.Serdeable;
import io.micronaut.sourcegen.generator.SourceGenerator;
import io.micronaut.sourcegen.generator.SourceGenerators;
import io.micronaut.sourcegen.model.*;
import jakarta.inject.Singleton;

import javax.lang.model.element.Modifier;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import static io.micronaut.core.util.StringUtils.capitalize;

/**
* A generator to create Java Beans from Json Schema.
*
* @author Elif Kurtay
* @since 1.2
*/

@Internal
@Singleton
public final class RecordGenerator {

private static final Map<String, TypeDef> TYPE_MAP = CollectionUtils.mapOf(new Object[]{
"integer", TypeDef.Primitive.INT, "boolean", TypeDef.Primitive.BOOLEAN,
"void", TypeDef.VOID, "string", TypeDef.STRING, "object", TypeDef.OBJECT,
"number", TypeDef.Primitive.FLOAT, "null", TypeDef.OBJECT});

private static final Map<String, Class> CLASS_MAP = CollectionUtils.mapOf(new Object[]{
"integer", Integer.class, "boolean", Boolean.class, "string", String.class,
"object", Object.class, "number", Float.class, "null", Object.class});

private final ResourceLoader resourceLoader;
private List<EnumDef> enums = new ArrayList<>();

public RecordGenerator(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}

public boolean generate(File jsonFileLocation, Optional<File> outputFileLocation) throws IOException {
try {
SourceGenerator sourceGenerator = SourceGenerators
.findByLanguage(VisitorContext.Language.JAVA).orElse(null);
if (sourceGenerator == null) {
return false;
}

var jsonSchema = getJsonSchema(jsonFileLocation.getPath());
String objectName = jsonSchema.get("title").toString() + "Record";

File outputFile = getOutputFile(outputFileLocation, objectName);
try (FileWriter writer = new FileWriter(outputFile)) {
var objectDef = build(jsonSchema, objectName);
for (EnumDef enumDef : enums) {
sourceGenerator.write(enumDef, writer);
}
sourceGenerator.write(objectDef, writer);
}
return true;
} catch (ProcessingException | IOException e) {
throw e;
}
}

private static File getOutputFile(Optional<File> outputFileLocation, String objectName) throws IOException {
File outputFile = outputFileLocation.orElse(null);
if (outputFile == null) { // default file
outputFile = new File(objectName + ".java");
}
if (!outputFile.exists() && !outputFile.createNewFile()) {
throw new IOException("Could not create file " + outputFile.getAbsolutePath());
}
return outputFile;
}

private Map<String, ?> getJsonSchema(String path) throws IOException {
JsonMapper jsonMapper = new JsonMapper();

Optional<InputStream> jsonOptional = resourceLoader.getResourceAsStream(path);
if (jsonOptional.isEmpty()) {
throw new FileNotFoundException("Resource file is not found.");
}
String jsonString = new String(jsonOptional.get().readAllBytes(), StandardCharsets.UTF_8);
return (Map<String, ?>) jsonMapper.readValue(jsonString, HashMap.class);
}

private RecordDef build(Map<String, ?> jsonSchema, String builderClassName) throws IOException {
/* TODO: decide between record vs class
* For now, only record def
*/
RecordDef.RecordDefBuilder objectBuilder = RecordDef.builder(builderClassName)
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Serdeable.class);

if (jsonSchema.containsKey("properties")) {
Map<String, ?> properties = (Map<String, ?>) jsonSchema.get("properties");
List<String> requiredProperties;
if (jsonSchema.containsKey("required")) {
requiredProperties = (List<String>) jsonSchema.get("required");
} else {
requiredProperties = new ArrayList<>();
}
properties.entrySet().forEach(entry ->
addField(objectBuilder, entry.getKey(), (Map<String, Object>) entry.getValue(), requiredProperties.contains(entry.getKey())));
}
return objectBuilder.build();
}

private void addField(RecordDef.RecordDefBuilder objectBuilder, String propertyName, Map<String, Object> description, boolean isRequired) {
String typeName = getPropertyType(description);
boolean isEnum = description.containsKey("enum");
TypeDef propertyType;
if (typeName.equals("array")) {
// checking for multidimensional arrays
var items = (Map<String, Object>) description.get("items");
int dimensions = 1;
var arrayTypeName = getPropertyType(items);
while (arrayTypeName.equals("array")) {
items = (Map<String, Object>) items.get("items");
dimensions++;
arrayTypeName = getPropertyType(items);
}
isEnum = items.containsKey("enum");
description = items;

// TODO: do the multiple dimensions
if (description.containsKey("uniqueItems") && description.get("uniqueItems").toString().equals("true")) {
propertyType = TypeDef.parameterized(Set.class, CLASS_MAP.get(arrayTypeName));
} else {
propertyType = TypeDef.parameterized(List.class, CLASS_MAP.get(arrayTypeName));
}
// propertyType = new TypeDef.Array(TYPE_MAP.get(arrayTypeName), dimensions, true);
} else {
propertyType = TYPE_MAP.get(typeName);
}

if (isEnum) {
EnumDef.EnumDefBuilder enumBuilder = EnumDef.builder(capitalize(propertyName));
for (Object anEnum : ((List<?>) description.get("enum"))) {
enumBuilder.addEnumConstant(anEnum.toString());
}
EnumDef enumDef = enumBuilder.build();
this.enums.add(enumDef);
propertyType = enumDef.asTypeDef();
}
PropertyDef.PropertyDefBuilder propertyDef = PropertyDef.builder(propertyName).ofType(propertyType);
AnnotationInfoAggregator.addAnnotations(propertyDef, description, propertyType, isRequired);

objectBuilder.addProperty(propertyDef.build());
}

private static String getPropertyType(Map<String, Object> description) {
var type = description.getOrDefault("type", "object");
String typeName;
if (type.getClass() == ArrayList.class) {
typeName = ((ArrayList<?>) type).get(0).toString();
} else {
typeName = type.toString();
}
return typeName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2017-2024 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.jsonschema.generator.aggregator;

import io.micronaut.core.annotation.Internal;
import io.micronaut.sourcegen.model.AnnotationDef;
import io.micronaut.sourcegen.model.ClassTypeDef;
import io.micronaut.sourcegen.model.PropertyDef;
import io.micronaut.sourcegen.model.TypeDef;

import java.util.Map;

/**
* An aggregator for adding annotation information from json schema.
*/
@Internal
public class AnnotationInfoAggregator {

private static final String JAKARTA_VALIDATION_PREFIX = "jakarta.validation.constraints.";
private static final String NOT_NULL_ANN = JAKARTA_VALIDATION_PREFIX + "NotNull";
private static final String ASSERT_FALSE_ANN = JAKARTA_VALIDATION_PREFIX + "AssertFalse";
private static final String ASSERT_TRUE_ANN = JAKARTA_VALIDATION_PREFIX + "AssertTrue";
private static final String SIZE_ANN = JAKARTA_VALIDATION_PREFIX + "Size";
private static final String MIN_ANN = JAKARTA_VALIDATION_PREFIX + "Min";
private static final String MAX_ANN = JAKARTA_VALIDATION_PREFIX + "Max";
private static final String DECIMAL_MIN_ANN = JAKARTA_VALIDATION_PREFIX + "DecimalMin";
private static final String DECIMAL_MAX_ANN = JAKARTA_VALIDATION_PREFIX + "DecimalMax";
private static final String PATTERN_ANN = JAKARTA_VALIDATION_PREFIX + "Pattern";
private static final String EMAIL_ANN = JAKARTA_VALIDATION_PREFIX + "Email";
private static final float EXCLUSIVE_DELTA = Float.MIN_VALUE;

public static void addAnnotations(PropertyDef.PropertyDefBuilder propertyDef, Map<String, Object> schemaMap, TypeDef propertyType, boolean isRequired) {
var minAnn = (propertyType == TypeDef.Primitive.FLOAT) ? DECIMAL_MIN_ANN : MIN_ANN;
var maxAnn = (propertyType == TypeDef.Primitive.FLOAT) ? DECIMAL_MAX_ANN : MAX_ANN;
if (isRequired) {
propertyDef.addAnnotation(NOT_NULL_ANN);
}
schemaMap.forEach((key, value) -> {
AnnotationDef.AnnotationDefBuilder annBuilder = null;
switch (key) {
// check annotation related to numbers
case "minimum":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(minAnn))
.addMember("value", value);
break;
case "maximum":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(maxAnn))
.addMember("value", value);
break;
case "exclusiveMinimum":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(minAnn))
.addMember("value", ((float) value) + EXCLUSIVE_DELTA);
break;
case "exclusiveMaximum":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(maxAnn))
.addMember("value", ((float) value) - EXCLUSIVE_DELTA);
break;
// list annotations
case "maxLength", "maxItems":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(SIZE_ANN))
.addMember("max", value);
break;
case "minLength", "minItems":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(SIZE_ANN))
.addMember("min", value);
break;
case "pattern":
annBuilder = AnnotationDef
.builder(ClassTypeDef.of(PATTERN_ANN))
.addMember("regexp", value);
break;
// string annotations
case "email": annBuilder = AnnotationDef
.builder(ClassTypeDef.of(EMAIL_ANN));
// boolean annotations
case "const":
if (propertyType == TypeDef.Primitive.BOOLEAN) {
var assertAnn = (value.toString().equals(Boolean.TRUE.toString())) ? ASSERT_TRUE_ANN : ASSERT_FALSE_ANN;
annBuilder = AnnotationDef.builder(ClassTypeDef.of(assertAnn));
}
// TODO: handle all const values
break;
default:
break;
}
if (annBuilder != null) {
propertyDef.addAnnotation(annBuilder.build());
}
});
}
}
13 changes: 13 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ pluginManagement {
plugins {
id 'io.micronaut.build.shared.settings' version '7.2.1'
}

dependencyResolutionManagement {
repositories {
gradlePluginPortal()
mavenCentral()
maven {
url("https://s01.oss.sonatype.org/content/repositories/snapshots")
}
}
}

enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS'

rootProject.name = 'json-schema-parent'
Expand All @@ -16,6 +27,7 @@ include 'json-schema-validation'
include 'json-schema-bom'
include 'json-schema-annotations'
include 'json-schema-processor'
include 'json-schema-generator'
include 'test-suite'
include 'test-suite-groovy'
include 'test-suite-kotlin'
Expand All @@ -27,4 +39,5 @@ micronautBuild {
importMicronautCatalog()
importMicronautCatalog('micronaut-validation')
importMicronautCatalog('micronaut-serde')
importMicronautCatalog('micronaut-sourcegen')
}
Loading