fi.evolver.ai.spring.json.JsonSpec Maven / Gradle / Ivy
package fi.evolver.ai.spring.json;
import java.io.IOException;
import java.io.StringWriter;
import java.io.UncheckedIOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.net.URI;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import fi.evolver.ai.spring.json.annotation.Description;
import fi.evolver.ai.spring.json.annotation.Example;
import fi.evolver.ai.spring.json.annotation.Format;
import fi.evolver.ai.spring.json.annotation.Strict;
import fi.evolver.ai.spring.json.annotation.Title;
import fi.evolver.ai.spring.util.Json;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Valid;
import jakarta.validation.Validation;
import jakarta.validation.ValidatorFactory;
import jakarta.validation.constraints.*;
public class JsonSpec {
private static final Logger LOG = LoggerFactory.getLogger(JsonSpec.class);
private static final ValidatorFactory VALIDATOR_FACTORY = Validation.buildDefaultValidatorFactory();
protected final Class spec;
public JsonSpec(Class spec) {
this.spec = spec;
}
/**
* Parse a JSON object into an instance of the specification class.
*
* @param json The JSON object.
* @return The parsed object.
*/
public T parse(String json) {
try {
T result = Json.OBJECT_MAPPER.readValue(json, spec);
Set> violations = VALIDATOR_FACTORY.getValidator().validate(result);
if (!violations.isEmpty()) {
String errors = "Invalid %s response:\n\t- %s".formatted(spec.getSimpleName(), violations.stream()
.map(v -> String.format("%s: %s", v.getPropertyPath(), v.getMessage())).collect(Collectors.joining("\n\t- ")));
LOG.warn(errors);
if (isValidityRequired())
throw new JsonSpecException(errors);
}
return result;
}
catch (JsonProcessingException e) {
throw new JsonSpecException(e, "Failed parsing response");
}
}
/**
* Generate an JSON Schema from the object.
*
* @return The generated JSON Schema specification.
*/
public String toJsonSchema() {
StringWriter writer = new StringWriter();
try (JsonGenerator json = Json.JSON_FACTORY.createGenerator(writer)) {
json.useDefaultPrettyPrinter();
generateJsonSchema(json);
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
return writer.toString();
}
/**
* Generate an JSON Schema from the object.
*
* @param json The target JSON generator.
* @return The generated JSON Schema specification.
*/
public void generateJsonSchema(JsonGenerator json) {
try {
json.writeStartObject();
writeTypeSpec(json, spec);
json.writeEndObject();
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void writeField(JsonGenerator json, String name, Boolean value) {
if (value == null)
return;
try {
json.writeBooleanField(name, value);
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void writeField(JsonGenerator json, String name, Long value) {
if (value == null)
return;
try {
json.writeNumberField(name, value);
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void writeField(JsonGenerator json, String name, Integer value) {
if (value == null)
return;
writeField(json, name, (long)value);
}
private static void writeField(JsonGenerator json, String name, Optional value) {
value.ifPresent(v -> writeField(json, name, v));
}
private static void writeField(JsonGenerator json, String name, String value) {
if (value == null)
return;
try {
json.writeStringField(name, value);
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void writeTypeSpec(JsonGenerator json, Type type) throws IOException {
if (type instanceof Class c)
writeClassTypeSpec(json, c);
else if (type instanceof ParameterizedType t)
writeParameterizedTypeSpec(json, t);
else
throw new IllegalArgumentException("Unsupported type: " + type);
}
private static void writeClassTypeSpec(JsonGenerator json, Class> type) throws IOException {
if (type.isArray())
writeArrayTypeSpec(json, type);
else if (type.isEnum())
writeEnumTypeSpec(json, type);
else if (type.isRecord())
writeRecordTypeSpec(json, type);
else if (boolean.class.isAssignableFrom(type) || Boolean.class.isAssignableFrom(type))
writeField(json, "type", "boolean");
else if (int.class.isAssignableFrom(type) || Integer.class.isAssignableFrom(type) || long.class.isAssignableFrom(type) || Long.class.isAssignableFrom(type))
writeField(json, "type", "integer");
else if (float.class.isAssignableFrom(type) || Float.class.isAssignableFrom(type) || double.class.isAssignableFrom(type) || Double.class.isAssignableFrom(type))
writeField(json, "type", "number");
else if (String.class.isAssignableFrom(type))
writeField(json, "type", "string");
else if (ZonedDateTime.class.isAssignableFrom(type) || OffsetDateTime.class.isAssignableFrom(type))
writeStringTypeSpec(json, "date-time");
else if (LocalDate.class.isAssignableFrom(type))
writeStringTypeSpec(json, "date");
else if (LocalTime.class.isAssignableFrom(type))
writeStringTypeSpec(json, "time");
else if (Duration.class.isAssignableFrom(type))
writeStringTypeSpec(json, "duration");
else if (URI.class.isAssignableFrom(type))
writeStringTypeSpec(json, "uri");
else if (UUID.class.isAssignableFrom(type))
writeStringTypeSpec(json, "uuid");
else {
throw new IllegalArgumentException("Unsupported type: " + type.getName());
}
}
private static void writeParameterizedTypeSpec(JsonGenerator json, ParameterizedType type) throws IOException {
if (type.getRawType() instanceof Class c) {
if (List.class.isAssignableFrom(c))
writeArrayTypeSpec(json, type, false);
else if (Set.class.isAssignableFrom(c))
writeArrayTypeSpec(json, type, true);
else if (Map.class.isAssignableFrom(c))
writeMapTypeSpec(json, type);
else
throw new IllegalArgumentException("Unsupported type: " + c.getName());
}
else {
throw new IllegalArgumentException("Unsupported type: " + type);
}
}
private static void writeMapTypeSpec(JsonGenerator json, ParameterizedType type) throws IOException {
if (type.getActualTypeArguments()[0] != String.class)
throw new IllegalArgumentException("Maps must have String keys, not " + type.getActualTypeArguments()[0]);
writeField(json, "type", "object");
json.writeObjectFieldStart("additionalProperties");
writeTypeSpec(json, type.getActualTypeArguments()[1]);
json.writeEndObject();
}
private static void writeArrayTypeSpec(JsonGenerator json, ParameterizedType type, boolean uniqueItems) throws IOException {
writeField(json, "type", "array");
json.writeObjectFieldStart("items");
writeTypeSpec(json, type.getActualTypeArguments()[0]);
if (uniqueItems)
writeField(json, "uniqueItems", true);
json.writeEndObject();
}
private static void writeArrayTypeSpec(JsonGenerator json, Class> type) throws IOException {
writeField(json, "type", "array");
json.writeObjectFieldStart("items");
writeTypeSpec(json, type.componentType());
json.writeEndObject();
}
private static void writeEnumTypeSpec(JsonGenerator json, Class> type) throws IOException {
writeField(json, "type", "string");
json.writeArrayFieldStart("enum");
for (Field field: type.getFields())
json.writeString(getName(field, field.getName()));
json.writeEndArray();
}
private static void writeStringTypeSpec(JsonGenerator json, String format) {
writeField(json, "type", "string");
writeField(json, "format", format);
}
private static void writeRecordTypeSpec(JsonGenerator json, Class> type) throws IOException {
writeField(json, "type", "object");
json.writeObjectFieldStart("properties");
for (RecordComponent component: type.getRecordComponents()) {
json.writeObjectFieldStart(getName(component, component.getName()));
writeTypeSpec(json, component.getGenericType());
getAnnotation(component, Format.class).map(Format::value).ifPresent(v -> writeField(json, "format", v));
getAnnotation(component, Min.class).map(Min::value).ifPresent(v -> writeField(json, "minimum", v));
getAnnotation(component, DecimalMin.class).map(DecimalMin::value).ifPresent(v -> writeField(json, "minimum", v));
getAnnotation(component, Max.class).map(Max::value).ifPresent(v -> writeField(json, "maximum", v));
getAnnotation(component, DecimalMax.class).map(DecimalMax::value).ifPresent(v -> writeField(json, "maximum", v));
writeSizeRestrictions(json, component);
writePatternRestriction(json, component);
writeField(json, "description", getAnnotation(component, Description.class).map(Description::value));
writeExamples(json, component);
json.writeEndObject();
}
json.writeEndObject();
writeRequired(json, type);
writeField(json, "additionalProperties", false);
}
private static void writeRequired(JsonGenerator json, Class> type) throws IOException {
List required = Arrays.stream(type.getRecordComponents())
.filter(JsonSpec::isRequired)
.map(RecordComponent::getName)
.toList();
if (!required.isEmpty()) {
json.writeArrayFieldStart("required");
for (String r: required)
json.writeString(r);
json.writeEndArray();
}
}
private static void writeExamples(JsonGenerator json, RecordComponent component) throws IOException {
List examples = getAnnotations(component, Example.class);
if (!examples.isEmpty()) {
json.writeArrayFieldStart("examples");
for (Example example: examples)
json.writeString(example.value());
json.writeEndArray();
}
}
private static void writeSizeRestrictions(JsonGenerator json, RecordComponent component) {
String suffix;
Class> type = component.getType();
if (type.isArray() || List.class.isAssignableFrom(type))
suffix = "Items";
else if (String.class.isAssignableFrom(type))
suffix = "Length";
else
suffix = "Properties";
Optional minSize = getAnnotations(component, Size.class).stream()
.map(Size::min)
.filter(Objects::nonNull)
.filter(v -> v > 0)
.max(Integer::compareTo);
if (minSize.isEmpty() && getAnnotation(component, NotEmpty.class).isPresent())
minSize = Optional.of(1);
Optional maxSize = getAnnotations(component, Size.class).stream()
.map(Size::max)
.filter(Objects::nonNull)
.filter(v -> v < Integer.MAX_VALUE)
.min(Integer::compareTo);
writeField(json, "min" + suffix, minSize.orElse(null));
writeField(json, "max" + suffix, maxSize.orElse(null));
}
private static void writePatternRestriction(JsonGenerator json, RecordComponent component) {
getAnnotations(component, Pattern.class).stream()
.map(Pattern::regexp)
.filter(Objects::nonNull)
.forEach(v -> writeField(json, "pattern", v));
}
/**
* The description of the function, if specified.
*
* @return The description of the function.
*/
public Optional getDescription() {
return Optional.ofNullable(spec.getAnnotation(Description.class)).map(Description::value);
}
/**
* The title of the function, if specified.
*
* @return The title of the function.
*/
public Optional getTitle() {
return Optional.ofNullable(spec.getAnnotation(Title.class)).map(Title::value);
}
/**
* Does this function require valid parameters.
*
* @return Whether valid parameters are required.
*/
public boolean isValidityRequired() {
return getAnnotation(spec, Valid.class).isPresent() || isStrictValidityRequired();
}
/**
* Does this function absolutely require valid parameters.
*
* @return Whether absolutely valid parameters are required.
*/
public boolean isStrictValidityRequired() {
return getAnnotation(spec, Strict.class).isPresent();
}
@Override
public String toString() {
return toJsonSchema();
}
private static List getAnnotations(AnnotatedElement annotated, Class annotation) {
return Arrays.asList(annotated.getAnnotationsByType(annotation));
}
private static List getAnnotations(RecordComponent component, Class annotation) {
List results = new ArrayList<>();
results.addAll(getAnnotations(component.getAccessor(), annotation));
results.addAll(getAnnotations((AnnotatedElement)component, annotation));
return results;
}
private static Optional getAnnotation(AnnotatedElement annotated, Class annotation) {
return Optional.ofNullable(annotated.getAnnotation(annotation));
}
private static Optional getAnnotation(RecordComponent component, Class annotation) {
return getAnnotation(component.getAccessor(), annotation).or(() -> getAnnotation((AnnotatedElement)component, annotation)) ;
}
private static String getName(AnnotatedElement annotated, String defaultName) {
return getAnnotation(annotated, JsonProperty.class).map(JsonProperty::value).orElse(defaultName);
}
private static boolean isRequired(RecordComponent component) {
return component.getType().isPrimitive() || getAnnotation(component, NotNull.class).isPresent() || getAnnotation(component, NotEmpty.class).isPresent();
}
/**
* Create a function specification from the given type.
*
* @param The result type of the function.
* @param spec The specification and result type of the function.
* @return The function specification.
*/
public static JsonSpec of(Class spec) {
return new JsonSpec<>(spec);
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy