Skip to content

Commit 033132a

Browse files
committed
#1074 - Add option to register additional media types for HAL and HAL-FORMS.
Inside HalConfiguration and HalFormsConfiguration, add a "wither" to support registering additional mediatypes. This makes it possible for Spring Boot to create a bean that will yield HAL when clients ask for application/json. NOTE: This is NOT implemented in Collection+JSON, UBER, or any other mediatypes, and has not been captured as an interface that all custom mediatypes would pick up. So far, it's custom for HAL and HAL-FORMS. If there is a big draw, we can look at extracting some type of interface. But for now, community feedback would be nice before going down that road.
1 parent 9ddfdc8 commit 033132a

11 files changed

+935
-2
lines changed

src/main/java/org/springframework/hateoas/mediatype/hal/HalConfiguration.java

+16
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@
2020
import lombok.Getter;
2121
import lombok.experimental.Wither;
2222

23+
import java.util.ArrayList;
2324
import java.util.LinkedHashMap;
25+
import java.util.List;
2426
import java.util.Map;
2527
import java.util.Map.Entry;
2628

2729
import org.springframework.hateoas.Link;
2830
import org.springframework.hateoas.LinkRelation;
31+
import org.springframework.http.MediaType;
2932
import org.springframework.util.AntPathMatcher;
3033
import org.springframework.util.Assert;
3134
import org.springframework.util.PathMatcher;
@@ -47,6 +50,7 @@ public class HalConfiguration {
4750
*/
4851
private final @Wither @Getter RenderSingleLinks renderSingleLinks;
4952
private final @Wither(AccessLevel.PRIVATE) Map<String, RenderSingleLinks> singleLinksPerPattern;
53+
private final @Getter List<MediaType> additionalMediaTypes = new ArrayList<>();
5054

5155
/**
5256
* Creates a new default {@link HalConfiguration} rendering single links as immediate sub-document.
@@ -122,4 +126,16 @@ public enum RenderSingleLinks {
122126
*/
123127
AS_ARRAY
124128
}
129+
130+
/**
131+
* Register other {@link MediaType}s this one should response to.
132+
*
133+
* @param mediatype
134+
* @return HalFormsConfiguration with new {@link MediaType} added
135+
*/
136+
public HalConfiguration withAdditionalMediatype(MediaType mediatype) {
137+
138+
this.additionalMediaTypes.add(mediatype);
139+
return this;
140+
}
125141
}

src/main/java/org/springframework/hateoas/mediatype/hal/HalMediaTypeConfiguration.java

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package org.springframework.hateoas.mediatype.hal;
1717

18+
import java.util.ArrayList;
1819
import java.util.List;
1920

2021
import org.springframework.beans.factory.ObjectProvider;
@@ -71,7 +72,10 @@ LinkDiscoverer halLinkDisocoverer() {
7172
*/
7273
@Override
7374
public List<MediaType> getMediaTypes() {
74-
return HypermediaType.HAL.getMediaTypes();
75+
76+
List<MediaType> mediaTypes = new ArrayList<>(HypermediaType.HAL.getMediaTypes());
77+
this.halConfiguration.ifAvailable(halConfig -> mediaTypes.addAll(halConfig.getAdditionalMediaTypes()));
78+
return mediaTypes;
7579
}
7680

7781
/*

src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java

+18
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
import lombok.Getter;
1919
import lombok.RequiredArgsConstructor;
2020

21+
import java.util.Collection;
2122
import java.util.HashMap;
2223
import java.util.Map;
2324
import java.util.Optional;
2425

2526
import org.springframework.core.ResolvableType;
2627
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
28+
import org.springframework.http.MediaType;
2729

2830
/**
2931
* HAL-FORMS specific configuration extension of {@link HalConfiguration}.
@@ -60,4 +62,20 @@ public HalFormsConfiguration registerPattern(Class<?> type, String pattern) {
6062
Optional<String> getTypePatternFor(ResolvableType type) {
6163
return Optional.ofNullable(patterns.get(type.resolve(Object.class)));
6264
}
65+
66+
/**
67+
* Register other {@link MediaType}s this one should response to.
68+
*
69+
* @param mediatype
70+
* @return HalFormsConfiguration
71+
*/
72+
public HalFormsConfiguration withAdditionalMediatype(MediaType mediatype) {
73+
74+
this.halConfiguration.getAdditionalMediaTypes().add(mediatype);
75+
return this;
76+
}
77+
78+
public Collection<? extends MediaType> getAdditionalMediaTypes() {
79+
return this.halConfiguration.getAdditionalMediaTypes();
80+
}
6381
}

src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsMediaTypeConfiguration.java

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import lombok.RequiredArgsConstructor;
1919

20+
import java.util.ArrayList;
2021
import java.util.List;
2122

2223
import org.springframework.beans.factory.ObjectProvider;
@@ -61,7 +62,10 @@ LinkDiscoverer halFormsLinkDiscoverer() {
6162
*/
6263
@Override
6364
public List<MediaType> getMediaTypes() {
64-
return HypermediaType.HAL_FORMS.getMediaTypes();
65+
66+
List<MediaType> mediaTypes = new ArrayList<>(HypermediaType.HAL_FORMS.getMediaTypes());
67+
this.halFormsConfiguration.ifAvailable(halFormsConfig -> mediaTypes.addAll(halFormsConfig.getAdditionalMediaTypes()));
68+
return mediaTypes;
6569
}
6670

6771
/*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright 2017 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.hateoas.mediatype.hal;
17+
18+
import static org.hamcrest.CoreMatchers.*;
19+
import static org.hamcrest.collection.IsCollectionWithSize.*;
20+
import static org.springframework.hateoas.support.JsonPathUtils.*;
21+
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Test;
24+
import org.junit.jupiter.api.extension.ExtendWith;
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.context.ApplicationContext;
27+
import org.springframework.context.annotation.Bean;
28+
import org.springframework.context.annotation.Configuration;
29+
import org.springframework.core.io.ClassPathResource;
30+
import org.springframework.hateoas.config.EnableHypermediaSupport;
31+
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
32+
import org.springframework.hateoas.config.WebClientConfigurer;
33+
import org.springframework.hateoas.support.MappingUtils;
34+
import org.springframework.hateoas.support.WebFluxEmployeeController;
35+
import org.springframework.http.HttpHeaders;
36+
import org.springframework.http.MediaType;
37+
import org.springframework.test.context.ContextConfiguration;
38+
import org.springframework.test.context.junit.jupiter.SpringExtension;
39+
import org.springframework.test.context.web.WebAppConfiguration;
40+
import org.springframework.test.web.reactive.server.WebTestClient;
41+
import org.springframework.web.reactive.config.EnableWebFlux;
42+
43+
/**
44+
* @author Greg Turnquist
45+
*/
46+
@ExtendWith(SpringExtension.class)
47+
@WebAppConfiguration
48+
@ContextConfiguration
49+
class HalHandleApplicationJsonWebFluxIntegrationTest {
50+
51+
@Autowired WebTestClient testClient;
52+
53+
@BeforeEach
54+
void setUp() {
55+
WebFluxEmployeeController.reset();
56+
}
57+
58+
/**
59+
* @see #728
60+
*/
61+
@Test
62+
void singleEmployee() {
63+
64+
this.testClient.get().uri("http://localhost/employees/0").accept(MediaType.APPLICATION_JSON).exchange()
65+
66+
.expectStatus().isOk() //
67+
.expectHeader().contentType(MediaType.APPLICATION_JSON) //
68+
.expectBody(String.class)//
69+
70+
.value(jsonPath("$.name", is("Frodo Baggins"))) //
71+
.value(jsonPath("$.role", is("ring bearer"))) //
72+
73+
.value(jsonPath("$._links.*", hasSize(2))) //
74+
.value(jsonPath("$._links['self'].href", is("http://localhost/employees/0"))) //
75+
.value(jsonPath("$._links['employees'].href", is("http://localhost/employees")));
76+
}
77+
78+
/**
79+
* @see #728
80+
*/
81+
@Test
82+
void collectionOfEmployees() {
83+
84+
this.testClient.get().uri("http://localhost/employees").accept(MediaType.APPLICATION_JSON).exchange().expectStatus()
85+
.isOk().expectHeader().contentType(MediaType.APPLICATION_JSON).expectBody(String.class)
86+
.value(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins")))
87+
.value(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
88+
.value(jsonPath("$._embedded.employees[0]._links['self'].href", is("http://localhost/employees/0")))
89+
.value(jsonPath("$._embedded.employees[1].name", is("Bilbo Baggins")))
90+
.value(jsonPath("$._embedded.employees[1].role", is("burglar")))
91+
.value(jsonPath("$._embedded.employees[1]._links['self'].href", is("http://localhost/employees/1")))
92+
93+
.value(jsonPath("$._links.*", hasSize(1)))
94+
.value(jsonPath("$._links['self'].href", is("http://localhost/employees")));
95+
}
96+
97+
/**
98+
* @see #728
99+
*/
100+
@Test
101+
void createNewEmployee() throws Exception {
102+
103+
String specBasedJson = MappingUtils.read(new ClassPathResource("new-employee.json", getClass()));
104+
105+
this.testClient.post().uri("http://localhost/employees").contentType(MediaType.APPLICATION_JSON)
106+
.bodyValue(specBasedJson) //
107+
.exchange() //
108+
.expectStatus().isCreated() //
109+
.expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2");
110+
}
111+
112+
@Configuration
113+
@EnableWebFlux
114+
@EnableHypermediaSupport(type = { HypermediaType.HAL })
115+
static class TestConfig {
116+
117+
@Bean
118+
WebFluxEmployeeController employeeController() {
119+
return new WebFluxEmployeeController();
120+
}
121+
122+
@Bean
123+
WebTestClient webTestClient(WebClientConfigurer webClientConfigurer, ApplicationContext ctx) {
124+
125+
return WebTestClient.bindToApplicationContext(ctx).build().mutate()
126+
.exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()).build();
127+
}
128+
129+
@Bean
130+
HalConfiguration halConfiguration() {
131+
return new HalConfiguration().withAdditionalMediatype(MediaType.APPLICATION_JSON);
132+
}
133+
}
134+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright 2017-2019 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.hateoas.mediatype.hal;
17+
18+
import static org.hamcrest.CoreMatchers.*;
19+
import static org.hamcrest.collection.IsCollectionWithSize.*;
20+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
21+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
22+
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
23+
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.extension.ExtendWith;
27+
import org.springframework.beans.factory.annotation.Autowired;
28+
import org.springframework.context.annotation.Bean;
29+
import org.springframework.context.annotation.Configuration;
30+
import org.springframework.core.io.ClassPathResource;
31+
import org.springframework.hateoas.config.EnableHypermediaSupport;
32+
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
33+
import org.springframework.hateoas.support.MappingUtils;
34+
import org.springframework.hateoas.support.WebMvcEmployeeController;
35+
import org.springframework.http.HttpHeaders;
36+
import org.springframework.http.MediaType;
37+
import org.springframework.test.context.ContextConfiguration;
38+
import org.springframework.test.context.junit.jupiter.SpringExtension;
39+
import org.springframework.test.context.web.WebAppConfiguration;
40+
import org.springframework.test.web.servlet.MockMvc;
41+
import org.springframework.web.context.WebApplicationContext;
42+
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
43+
44+
/**
45+
* @author Greg Turnquist
46+
*/
47+
@ExtendWith(SpringExtension.class)
48+
@WebAppConfiguration
49+
@ContextConfiguration
50+
class HalHandleApplicationJsonWebMvcIntegrationTest {
51+
52+
@Autowired WebApplicationContext context;
53+
54+
MockMvc mockMvc;
55+
56+
@BeforeEach
57+
void setUp() {
58+
59+
this.mockMvc = webAppContextSetup(this.context).build();
60+
WebMvcEmployeeController.reset();
61+
}
62+
63+
@Test
64+
void singleEmployee() throws Exception {
65+
66+
this.mockMvc.perform(get("/employees/0").accept(MediaType.APPLICATION_JSON)) //
67+
68+
.andExpect(status().isOk()) //
69+
.andExpect(jsonPath("$.name", is("Frodo Baggins"))) //
70+
.andExpect(jsonPath("$.role", is("ring bearer")))
71+
72+
.andExpect(jsonPath("$._links.*", hasSize(2)))
73+
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/0")))
74+
.andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")));
75+
}
76+
77+
@Test
78+
void collectionOfEmployees() throws Exception {
79+
80+
this.mockMvc.perform(get("/employees").accept(MediaType.APPLICATION_JSON)) //
81+
.andExpect(status().isOk()) //
82+
.andExpect(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins")))
83+
.andExpect(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
84+
.andExpect(jsonPath("$._embedded.employees[0]._links['self'].href", is("http://localhost/employees/0")))
85+
.andExpect(jsonPath("$._embedded.employees[1].name", is("Bilbo Baggins")))
86+
.andExpect(jsonPath("$._embedded.employees[1].role", is("burglar")))
87+
.andExpect(jsonPath("$._embedded.employees[1]._links['self'].href", is("http://localhost/employees/1")))
88+
89+
.andExpect(jsonPath("$._links.*", hasSize(1)))
90+
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees")));
91+
}
92+
93+
@Test
94+
void createNewEmployee() throws Exception {
95+
96+
String specBasedJson = MappingUtils.read(new ClassPathResource("new-employee.json", getClass()));
97+
98+
this.mockMvc.perform(post("/employees") //
99+
.content(specBasedJson) //
100+
.contentType(MediaType.APPLICATION_JSON_VALUE)) //
101+
.andExpect(status().isCreated())
102+
.andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2"));
103+
}
104+
105+
@Configuration
106+
@EnableWebMvc
107+
@EnableHypermediaSupport(type = HypermediaType.HAL)
108+
static class TestConfig {
109+
110+
@Bean
111+
WebMvcEmployeeController employeeController() {
112+
return new WebMvcEmployeeController();
113+
}
114+
115+
@Bean
116+
HalConfiguration halFormsConfiguration() {
117+
return new HalConfiguration().withAdditionalMediatype(MediaType.APPLICATION_JSON);
118+
}
119+
120+
}
121+
122+
@Configuration
123+
@EnableHypermediaSupport(type = HypermediaType.HAL)
124+
static class WithHalConfiguration {
125+
126+
static final HalConfiguration CONFIG = new HalConfiguration().withAdditionalMediatype(MediaType.APPLICATION_JSON);
127+
128+
@Bean
129+
public HalConfiguration halConfiguration() {
130+
return CONFIG;
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)