/* * Copyright 2012-2016 the original author or 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 * * http://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 org.springframework.data.rest.webmvc.json; import lombok.NonNull; import lombok.RequiredArgsConstructor; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceResolvable; import org.springframework.context.NoSuchMessageException; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.data.domain.Sort; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.context.PersistentEntities; import org.springframework.data.repository.support.RepositoryInvokerFactory; import org.springframework.data.rest.core.config.JsonSchemaFormat; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceDescription; import org.springframework.data.rest.core.mapping.ResourceMapping; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.webmvc.json.JsonSchema.AbstractJsonSchemaProperty; import org.springframework.data.rest.webmvc.json.JsonSchema.Definitions; import org.springframework.data.rest.webmvc.json.JsonSchema.EnumProperty; import org.springframework.data.rest.webmvc.json.JsonSchema.Item; import org.springframework.data.rest.webmvc.json.JsonSchema.JsonSchemaProperty; import org.springframework.data.rest.webmvc.mapping.Associations; import org.springframework.data.util.ClassTypeInformation; import org.springframework.data.util.Optionals; import org.springframework.data.util.TypeInformation; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.introspect.AnnotatedMember; import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; /** * Converter to create {@link JsonSchema} instances for {@link PersistentEntity}s. * * @author Jon Brisbin * @author Oliver Gierke * @author Greg Turnquist */ public class PersistentEntityToJsonSchemaConverter implements ConditionalGenericConverter { private static final TypeDescriptor STRING_TYPE = TypeDescriptor.valueOf(String.class); private static final TypeDescriptor SCHEMA_TYPE = TypeDescriptor.valueOf(JsonSchema.class); private static final TypeInformation<?> STRING_TYPE_INFORMATION = ClassTypeInformation.from(String.class); private final Set<ConvertiblePair> convertiblePairs = new HashSet<ConvertiblePair>(); private final Associations associations; private final PersistentEntities entities; private final ObjectMapper objectMapper; private final RepositoryRestConfiguration configuration; private final ValueTypeSchemaPropertyCustomizerFactory customizerFactory; private final MessageResolver resolver; /** * Creates a new {@link PersistentEntityToJsonSchemaConverter} for the given {@link PersistentEntities} and * {@link ResourceMappings}. * * @param entities must not be {@literal null}. * @param mappings must not be {@literal null}. * @param accessor must not be {@literal null}. * @param objectMapper must not be {@literal null}. * @param configuration must not be {@literal null}. */ public PersistentEntityToJsonSchemaConverter(PersistentEntities entities, Associations associations, MessageSourceAccessor accessor, ObjectMapper objectMapper, RepositoryRestConfiguration configuration, ValueTypeSchemaPropertyCustomizerFactory customizerFactory) { Assert.notNull(entities, "PersistentEntities must not be null!"); Assert.notNull(associations, "AssociationLinks must not be null!"); Assert.notNull(accessor, "MessageSourceAccessor must not be null!"); Assert.notNull(objectMapper, "ObjectMapper must not be null!"); Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); this.entities = entities; this.associations = associations; this.objectMapper = objectMapper; this.configuration = configuration; this.customizerFactory = customizerFactory; this.resolver = new DefaultMessageResolver(accessor, configuration); for (TypeInformation<?> domainType : entities.getManagedTypes()) { convertiblePairs.add(new ConvertiblePair(domainType.getType(), JsonSchema.class)); } } /* * (non-Javadoc) * @see org.springframework.core.convert.converter.ConditionalConverter#matches(org.springframework.core.convert.TypeDescriptor, org.springframework.core.convert.TypeDescriptor) */ @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { return Class.class.isAssignableFrom(sourceType.getType()) && JsonSchema.class.isAssignableFrom(targetType.getType()); } /* * (non-Javadoc) * @see org.springframework.core.convert.converter.GenericConverter#getConvertibleTypes() */ @Override public Set<ConvertiblePair> getConvertibleTypes() { return convertiblePairs; } /** * Converts the given type into a {@link JsonSchema} instance. * * @param domainType must not be {@literal null}. * @return */ public JsonSchema convert(Class<?> domainType) { return (JsonSchema) convert(domainType, STRING_TYPE, SCHEMA_TYPE); } /* * (non-Javadoc) * @see org.springframework.core.convert.converter.GenericConverter#convert(java.lang.Object, org.springframework.core.convert.TypeDescriptor, org.springframework.core.convert.TypeDescriptor) */ @Override public JsonSchema convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { final PersistentEntity<?, ?> persistentEntity = entities.getRequiredPersistentEntity((Class<?>) source); final ResourceMetadata metadata = associations.getMappings().getMetadataFor(persistentEntity.getType()); Definitions definitions = new Definitions(); List<AbstractJsonSchemaProperty<?>> propertiesFor = getPropertiesFor(persistentEntity.getType(), metadata, definitions); String title = resolver.resolveWithDefault(new ResolvableType(persistentEntity.getType())); return new JsonSchema(title, resolver.resolve(metadata.getItemResourceDescription()), propertiesFor, definitions); } private List<AbstractJsonSchemaProperty<?>> getPropertiesFor(Class<?> type, final ResourceMetadata metadata, final Definitions definitions) { return entities.getPersistentEntity(type).map(entity -> { final JacksonMetadata jackson = new JacksonMetadata(objectMapper, type); JsonSchemaPropertyRegistrar registrar = new JsonSchemaPropertyRegistrar(jackson); for (BeanPropertyDefinition definition : jackson) { JacksonProperty jacksonProperty = new JacksonProperty(jackson, entity.getPersistentProperty(definition.getInternalName()), definition); Optional<? extends PersistentProperty<?>> prop = entity.getPersistentProperty(definition.getInternalName()); // First pass, early drops to avoid unnecessary calculation if (prop.isPresent()) { PersistentProperty<?> persistentProperty = prop.get(); if (persistentProperty.isIdProperty() && !configuration.isIdExposedFor(type)) { continue; } if (persistentProperty.isVersionProperty()) { continue; } if (!definition.couldSerialize()) { continue; } } AnnotatedMember primaryMember = definition.getPrimaryMember(); if (primaryMember == null) { continue; } TypeInformation<?> propertyType = jacksonProperty.getPropertyType(); TypeInformation<?> actualPropertyType = propertyType.getActualType(); Class<?> rawPropertyType = propertyType.getType(); JsonSchemaFormat format = configuration.getMetadataConfiguration().getSchemaFormatFor(rawPropertyType); ResourceDescription description = prop.map(it -> getDescriptionFor(it, metadata)) .orElseGet(() -> jackson.getFallbackDescription(metadata, definition)); JsonSchemaProperty property = jacksonProperty.getSchemaProperty(description, resolver); if (format != null) { // Types with explicitly registered format -> value object with format registrar.register(property.withFormat(format), actualPropertyType); continue; } Pattern pattern = configuration.getMetadataConfiguration().getPatternFor(rawPropertyType); if (pattern != null) { registrar.register(property.withPattern(pattern), actualPropertyType); continue; } if (jackson.isValueType()) { registrar.register(property.with(STRING_TYPE_INFORMATION), actualPropertyType); continue; } Optionals.ifPresentOrElse(prop, it -> { if (configuration.isLookupType(it.getActualType())) { registrar.register(property.with(propertyType), actualPropertyType); } else if (associations.isLinkableAssociation(it)) { registrar.register(property.asAssociation(), null); } else { if (it.isEntity()) { if (!definitions.hasDefinitionFor(propertyType)) { definitions.addDefinition(propertyType, new Item(propertyType, getNestedPropertiesFor(it, definitions))); } registrar.register(property.with(propertyType, Definitions.getReference(propertyType)), actualPropertyType); } else { registrar.register(property.with(propertyType), actualPropertyType); } } }, () -> registrar.register(property, actualPropertyType)); } return registrar.getProperties(); }).orElse(Collections.emptyList()); } private Collection<AbstractJsonSchemaProperty<?>> getNestedPropertiesFor(PersistentProperty<?> property, Definitions descriptors) { if (!property.isEntity()) { return Collections.emptyList(); } return getPropertiesFor(property.getActualType(), associations.getMappings().getMetadataFor(property.getActualType()), descriptors); } // // private JsonSchemaProperty getSchemaProperty(BeanPropertyDefinition definition, TypeInformation<?> type, // ResourceDescription description) { // // String name = definition.getName(); // String title = resolver.resolveWithDefault(new ResolvableProperty(definition)); // String resolvedDescription = resolver.resolve(description); // boolean required = definition.isRequired(); // Class<?> rawType = type.getType(); // // if (!rawType.isEnum()) { // return new JsonSchemaProperty(name, title, resolvedDescription, required).with(type); // } // // String message = resolver.resolve(new DefaultMessageSourceResolvable(description.getMessage())); // // return new EnumProperty(name, title, rawType, // description.getDefaultMessage().equals(resolvedDescription) ? message : resolvedDescription, required); // } private ResourceDescription getDescriptionFor(PersistentProperty<?> property, ResourceMetadata metadata) { ResourceMapping propertyMapping = metadata.getMappingFor(property); return propertyMapping.getDescription(); } /** * Helper to register {@link JsonSchemaProperty} instances after post-processing them. * * @author Oliver Gierke * @since 2.4 */ private class JsonSchemaPropertyRegistrar { private final JacksonMetadata metadata; private final List<AbstractJsonSchemaProperty<?>> properties; /** * Creates a new {@link JsonSchemaPropertyRegistrar} using the given {@link JacksonMetadata}. * * @param metadata must not be {@literal null}. */ public JsonSchemaPropertyRegistrar(JacksonMetadata metadata) { Assert.notNull(metadata, "Metadata must not be null!"); this.metadata = metadata; this.properties = new ArrayList<AbstractJsonSchemaProperty<?>>(); } public void register(JsonSchemaProperty property, TypeInformation<?> type) { if (type == null) { properties.add(property); return; } JsonSerializer<?> serializer = metadata.getTypeSerializer(type.getType()); if ((serializer instanceof JsonSchemaPropertyCustomizer)) { properties.add(((JsonSchemaPropertyCustomizer) serializer).customize(property, type)); return; } if (configuration.isLookupType(type.getType())) { properties.add(customizerFactory.getCustomizerFor(type.getType()).customize(property, type)); return; } properties.add(property); } public List<AbstractJsonSchemaProperty<?>> getProperties() { return properties; } } @RequiredArgsConstructor public static class ValueTypeSchemaPropertyCustomizerFactory { private final @NonNull RepositoryInvokerFactory factory; public JsonSchemaPropertyCustomizer getCustomizerFor(final Class<?> type) { return new JsonSchemaPropertyCustomizer() { /* * (non-Javadoc) * @see org.springframework.data.rest.webmvc.json.JsonSchemaPropertyCustomizer#customize(org.springframework.data.rest.webmvc.json.JsonSchema.JsonSchemaProperty, org.springframework.data.util.TypeInformation) */ @Override public JsonSchemaProperty customize(JsonSchemaProperty property, TypeInformation<?> type) { List<String> result = new ArrayList<String>(); for (Object element : factory.getInvokerFor(type.getType()).invokeFindAll((Sort) null)) { result.add(element.toString()); } Collections.sort(result); return new EnumProperty(property.getName(), property.getTitle(), result, property.description, true); } }; } } /** * A {@link BeanPropertyDefinition} that can be resolved via a {@link MessageSource}. * * @author Oliver Gierke * @since 2.4.1 */ private static class ResolvableProperty extends DefaultMessageSourceResolvable { private static final long serialVersionUID = -5603381674553244480L; /** * Creates a new {@link ResolvableProperty} for the given {@link BeanPropertyDefinition}. * * @param property must not be {@literal null}. */ public ResolvableProperty(BeanPropertyDefinition property) { super(getCodes(property)); } private static String[] getCodes(BeanPropertyDefinition property) { Assert.notNull(property, "BeanPropertyDefinition must not be null!"); Class<?> owner = property.getPrimaryMember().getDeclaringClass(); String propertyTitle = property.getInternalName().concat("._title"); String localName = owner.getSimpleName().concat(".").concat(propertyTitle); String fullName = owner.getName().concat(".").concat(propertyTitle); return new String[] { fullName, localName, propertyTitle }; } } /** * A type whose title can be resolved through a {@link MessageSource}. * * @author Oliver Gierke * @since 2.4.1 */ private static class ResolvableType extends DefaultMessageSourceResolvable { private static final long serialVersionUID = -7199875272753949857L; /** * Creates a new {@link ResolvableType} for the given type. * * @param type must not be {@literal null}. */ public ResolvableType(Class<?> type) { super(getTitleCodes(type)); } private static String[] getTitleCodes(Class<?> type) { Assert.notNull(type, "Type must not be null!"); return new String[] { type.getName().concat("._title"), type.getSimpleName().concat("._title") }; } } @RequiredArgsConstructor private static class JacksonProperty { private final JacksonMetadata metadata; private final Optional<? extends PersistentProperty<?>> property; private final BeanPropertyDefinition definition; @SuppressWarnings("rawtypes") public TypeInformation<?> getPropertyType() { return property.map(it -> (TypeInformation) it.getTypeInformation()) .orElseGet(() -> ClassTypeInformation.from(definition.getPrimaryMember().getRawType())); } public JsonSchemaProperty getSchemaProperty(ResourceDescription description, MessageResolver resolver) { JsonSchemaProperty result = getSchemaProperty(definition, getPropertyType(), description, resolver); boolean isSyntheticProperty = !property.isPresent(); boolean isNotWritable = property.map(it -> !it.isWritable()).orElse(false); boolean isJacksonReadOnly = property.map(it -> metadata.isReadOnly(it)).orElse(false); if (isSyntheticProperty || isNotWritable || isJacksonReadOnly) { result = result.withReadOnly(); } return result; } private JsonSchemaProperty getSchemaProperty(BeanPropertyDefinition definition, TypeInformation<?> type, ResourceDescription description, MessageResolver resolver) { String name = definition.getName(); String title = resolver.resolveWithDefault(new ResolvableProperty(definition)); String resolvedDescription = resolver.resolve(description); boolean required = definition.isRequired(); Class<?> rawType = type.getType(); if (!rawType.isEnum()) { return new JsonSchemaProperty(name, title, resolvedDescription, required).with(type); } String message = resolver.resolve(new DefaultMessageSourceResolvable(description.getMessage())); return new EnumProperty(name, title, rawType, description.getDefaultMessage().equals(resolvedDescription) ? message : resolvedDescription, required); } } private interface MessageResolver { String resolve(MessageSourceResolvable resolvable); default String resolveWithDefault(MessageSourceResolvable resolvable) { return resolve(new DefaultingMessageSourceResolvable(resolvable)); } } @RequiredArgsConstructor private static class DefaultMessageResolver implements MessageResolver { private final MessageSourceAccessor accessor; private final RepositoryRestConfiguration configuration; public String resolve(MessageSourceResolvable resolvable) { if (resolvable == null) { return null; } try { return accessor.getMessage(resolvable); } catch (NoSuchMessageException o_O) { if (configuration.getMetadataConfiguration().omitUnresolvableDescriptionKeys()) { return null; } else { throw o_O; } } } } /** * Message source resolvable that defaults the messages to the last segment of the dot-separated code in case the * configured delegate doesn't return a default message itself. * * @author Oliver Gierke * @since 2.4 */ private static class DefaultingMessageSourceResolvable implements MessageSourceResolvable { private static Pattern SPLIT_CAMEL_CASE = Pattern.compile("(?<!(^|[A-Z]))(?=[A-Z])|(?<!^)(?=[A-Z][a-z])"); private final MessageSourceResolvable delegate; /** * Creates a new {@link DefaultingMessageSourceResolvable} for the given delegate {@link MessageSourceResolvable}. * * @param delegate must not be {@literal null}. */ public DefaultingMessageSourceResolvable(MessageSourceResolvable delegate) { this.delegate = delegate; } /* * (non-Javadoc) * @see org.springframework.context.MessageSourceResolvable#getArguments() */ @Override public Object[] getArguments() { return delegate.getArguments(); } /* * (non-Javadoc) * @see org.springframework.context.MessageSourceResolvable#getCodes() */ @Override public String[] getCodes() { return delegate.getCodes(); } /* * (non-Javadoc) * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage() */ @Override public String getDefaultMessage() { String defaultMessage = delegate.getDefaultMessage(); if (defaultMessage != null) { return defaultMessage; } String[] split = getCodes()[0].split("\\."); String tail = split[split.length - 1]; tail = "_title".equals(tail) ? split[split.length - 2] : tail; return StringUtils.capitalize(StringUtils .collectionToDelimitedString(Arrays.asList(SPLIT_CAMEL_CASE.split(tail)), " ").toLowerCase(Locale.US)); } } }