All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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