From 81cf5b3ae45bb540249c5d7297a80642e5c27bfe Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 04:17:01 +0200 Subject: [PATCH] fix: register the HAL beans when HateoasProperties is absent Requiring HateoasProperties on the classpath disabled the whole hateoas configuration for applications that only depend on spring-boot-starter-data-rest, since HateoasProperties lives in the separate spring-boot-hateoas auto-configuration module. Without CollectionModelContentConverter the _embedded schema was left without properties and /v3/api-docs failed with a NullPointerException. Restore the LinkRelationProvider-only class condition and pick the HateoasHalProvider bean definition through nested configurations, and guard the data-rest schema post-processing against empty properties. Fixes #3328 Fixes #3337 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../SpringDocHateoasConfiguration.java | 60 +++++++++++++++---- .../core/utils/SpringDocDataRestUtils.java | 11 ++-- .../SpringDocHateoasConfigurationTest.java | 52 ++++++++++++++++ 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fab0e465..63d6679d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- #3328, #3337 – `/v3/api-docs` fails with a `NullPointerException` when spring-hateoas is on the classpath without `HateoasProperties` - #3320 – `@Order` and `Ordered` ignored when applying customizers - #3319 – A `Page` nested in another schema is not replaced by `PagedModel` - #3313 – Springdoc auto-configurations rely on unspecified auto-configuration ordering diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java index 7663e6328e..a79ac4da70 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java @@ -42,6 +42,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.hateoas.autoconfigure.HateoasProperties; import org.springframework.context.annotation.Bean; @@ -58,23 +59,62 @@ @Configuration(proxyBeanMethods = false) @AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-hateoas:true}") -@ConditionalOnClass({LinkRelationProvider.class, HateoasProperties.class}) +@ConditionalOnClass(LinkRelationProvider.class) @ConditionalOnWebApplication @ConditionalOnBean(SpringDocConfiguration.class) public class SpringDocHateoasConfiguration { /** - * Hateoas hal provider hateoas hal provider. + * Configuration for HateoasHalProvider when HateoasProperties is on the classpath. * - * @param hateoasPropertiesOptional the hateoas properties optional - * @param objectMapperProvider the object mapper provider - * @return the hateoas hal provider + * @author bnasslahsen */ - @Bean - @ConditionalOnMissingBean - @Lazy(false) - HateoasHalProvider hateoasHalProvider(Optional hateoasPropertiesOptional, ObjectMapperProvider objectMapperProvider) { - return new HateoasHalProvider(hateoasPropertiesOptional, objectMapperProvider); + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = { + "org.springframework.hateoas.server.LinkRelationProvider", + "org.springframework.boot.hateoas.autoconfigure.HateoasProperties" + }) + static class HateoasPropertiesConfiguration { + + /** + * Hateoas hal provider hateoas hal provider. + * + * @param hateoasPropertiesOptional the hateoas properties optional + * @param objectMapperProvider the object mapper provider + * @return the hateoas hal provider + */ + @Bean + @ConditionalOnMissingBean + @Lazy(false) + HateoasHalProvider hateoasHalProvider(Optional hateoasPropertiesOptional, ObjectMapperProvider objectMapperProvider) { + return new HateoasHalProvider(hateoasPropertiesOptional, objectMapperProvider); + } + + } + + /** + * Fallback configuration for HateoasHalProvider when HateoasProperties is absent. + * + * @author bnasslahsen + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "org.springframework.hateoas.server.LinkRelationProvider") + @ConditionalOnMissingClass("org.springframework.boot.hateoas.autoconfigure.HateoasProperties") + static class NoHateoasPropertiesConfiguration { + + /** + * Hateoas hal provider hateoas hal provider. + * + * @param objectMapperProvider the object mapper provider + * @return the hateoas hal provider + */ + @Bean + @ConditionalOnMissingBean + @Lazy(false) + HateoasHalProvider hateoasHalProvider(ObjectMapperProvider objectMapperProvider) { + return new HateoasHalProvider(Optional.empty(), objectMapperProvider); + } + } /** diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java index c37857f982..483a0beada 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java @@ -265,6 +265,8 @@ private void updateRequestBodySchemaProperties(String key, Schema referencedSche * @return the schema */ private Schema updateResponseSchema(String className, Schema existingSchema, Components components, boolean openapi31) { + if (existingSchema == null) + return null; Map properties = existingSchema.getProperties(); EntityInfo entityInfo = entityInoMap.get(className); if (!CollectionUtils.isEmpty(properties)) { @@ -292,15 +294,16 @@ else if (EMBEDDED.equals(propId)) { */ private void updateResponseSchemaEmbedded(Components components, EntityInfo entityInfo, Entry entry, boolean openapi31) { String entityClassName = linkRelationProvider.getCollectionResourceRelFor(entityInfo.getDomainType()).value(); + Map embeddedProperties = entry.getValue().getProperties(); + if (CollectionUtils.isEmpty(embeddedProperties)) + return; Schema itemsSchema = null; if (openapi31) { - JsonSchema jsonSchema = (JsonSchema) entry.getValue().getProperties().get(entityClassName); - if (jsonSchema != null) + if (embeddedProperties.get(entityClassName) instanceof JsonSchema jsonSchema) itemsSchema = jsonSchema.getItems(); } else { - ArraySchema arraySchema = (ArraySchema) entry.getValue().getProperties().get(entityClassName); - if (arraySchema != null) + if (embeddedProperties.get(entityClassName) instanceof ArraySchema arraySchema) itemsSchema = arraySchema.getItems(); } if (itemsSchema != null) { diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocHateoasConfigurationTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocHateoasConfigurationTest.java index 347aedac04..7db8bb8e79 100644 --- a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocHateoasConfigurationTest.java +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/configuration/SpringDocHateoasConfigurationTest.java @@ -1,11 +1,17 @@ package org.springdoc.core.configuration; import org.junit.jupiter.api.Test; +import org.springdoc.core.configuration.SpringDocHateoasConfiguration.HateoasPropertiesConfiguration; +import org.springdoc.core.configuration.SpringDocHateoasConfiguration.NoHateoasPropertiesConfiguration; +import org.springdoc.core.converters.CollectionModelContentConverter; import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springdoc.core.customizers.OpenApiHateoasLinksCustomizer; import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.providers.HateoasHalProvider; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.hateoas.autoconfigure.HateoasProperties; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.hateoas.config.HateoasConfiguration; @@ -82,4 +88,50 @@ void linksSchemaCustomizerShouldNotBeRegisteredIfBeanWithSameNameAlreadyExists() assertThat(context.getBean(LINKS_SCHEMA_CUSTOMIZER)).isNotExactlyInstanceOf(OpenApiHateoasLinksCustomizer.class); }); } + + @Test + void halBeansShouldBeRegisteredWhenHateoasPropertiesIsPresent() { + hateoasContextRunner() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(HateoasHalProvider.class); + assertThat(context).hasSingleBean(CollectionModelContentConverter.class); + assertThat(context).hasSingleBean(HateoasPropertiesConfiguration.class); + assertThat(context).doesNotHaveBean(NoHateoasPropertiesConfiguration.class); + }); + } + + /** + * spring-hateoas can be on the classpath without the Spring Boot auto-configuration + * module that carries {@link HateoasProperties}, which is what happens with + * spring-boot-starter-data-rest alone. The HAL beans, and above all the + * {@code _embedded} converter, must still be registered then. + */ + @Test + void halBeansShouldBeRegisteredWhenHateoasPropertiesIsAbsent() { + hateoasContextRunner() + .withClassLoader(new FilteredClassLoader(HateoasProperties.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(HateoasHalProvider.class); + assertThat(context).hasSingleBean(CollectionModelContentConverter.class); + assertThat(context).hasSingleBean(NoHateoasPropertiesConfiguration.class); + assertThat(context).doesNotHaveBean(HateoasPropertiesConfiguration.class); + }); + } + + private WebApplicationContextRunner hateoasContextRunner() { + return new WebApplicationContextRunner() + .withPropertyValues( + "springdoc.api-docs.enabled=true", + "springdoc.enable-hateoas=true" + ) + .withConfiguration(AutoConfigurations.of( + WebMvcAutoConfiguration.class, + HateoasConfiguration.class, + SpringDocConfiguration.class, + SpringDocConfigProperties.class, + SpringDocHateoasConfiguration.class + )); + } } \ No newline at end of file