Initial commit

This commit is contained in:
Greg Turnquist
2017-05-25 11:09:03 -05:00
commit e8a9bc94e7
22 changed files with 1712 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2017 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.hateoas;
/**
* Analogous to {@link ResourceAssembler} but for resource collections.
*
* @author Greg Turnquist
*/
public interface ResourcesAssembler<T, D extends ResourceSupport> {
/**
* Converts all given entities into resources and wraps the collection as a resource as well.
*
* @see ResourceAssembler#toResource(Object)
* @param entities must not be {@literal null}.
* @return {@link Resources} containing {@link Resource} of {@code T}.
*/
Resources<D> toResources(Iterable<? extends T> entities);
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2017 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.hateoas;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import org.springframework.core.GenericTypeResolver;
import org.springframework.hateoas.core.EvoInflectorRelProvider;
import org.springframework.hateoas.mvc.ControllerLinkBuilder;
/**
* @author Greg Turnquist
*/
public class SimpleIdentifiableResourceAssembler<T extends Identifiable<?>> extends SimpleResourceAssembler<T> {
/**
* The Spring MVC class for the {@link Identifiable} from which links will be built.
*/
private final Class<?> controllerClass;
/**
* A {@link RelProvider} to look up names of links as options for resource paths.
*/
private final RelProvider relProvider;
/**
* A {@link Class} depicting the {@link Identifiable}'s type.
*/
private final Class<?> resourceType;
/**
* Default base path as empty.
*/
private String basePath = "";
/**
* Default a assembler based on Spring MVC controller, resource type, and {@link RelProvider}. With this combination
* of information, resources can be defined.
*
* @see #setBasePath(String) to adjust base path to something like "/api"/
*
* @param controllerClass - Spring MVC controller to base links off of
* @param relProvider
*/
public SimpleIdentifiableResourceAssembler(Class<?> controllerClass, RelProvider relProvider) {
this.controllerClass = controllerClass;
this.relProvider = relProvider;
// Find the "T" type contained in "T extends Identifiable<?>", e.g. SimpleIdentifiableResourceAssembler<User> -> User
this.resourceType = GenericTypeResolver.resolveTypeArgument(this.getClass(), SimpleIdentifiableResourceAssembler.class);
}
/**
* Alternate constructor that falls back to {@link EvoInflectorRelProvider}.
*
* @param controllerClass
*/
public SimpleIdentifiableResourceAssembler(Class<?> controllerClass) {
this(controllerClass, new EvoInflectorRelProvider());
}
/**
* Define links to add to every {@link Resource}.
*
* @param resource
*/
@Override
protected void addLinks(Resource<T> resource) {
resource.add(getCollectionLinkBuilder().slash(resource.getContent()).withSelfRel());
resource.add(getCollectionLinkBuilder().withRel(this.relProvider.getCollectionResourceRelFor(this.resourceType)));
}
/**
* Define links to add to {@link Resources} collection.
*
* @param resources
*/
@Override
protected void addLinks(Resources<Resource<T>> resources) {
resources.add(getCollectionLinkBuilder().withSelfRel());
}
/**
* Build up a URI for the collection using the Spring MVC controller followed by the resource type transformed
* by the {@link RelProvider}.
*
* Assumption is that an {@link org.springframework.hateoas.examples.EmployeeController} serving up {@link org.springframework.hateoas.examples.Employee}
* objects will be serving resources at {@code /employees} and {@code /employees/1}.
*
* If this is not the case, simply override this method in your concrete instance, or simply resort to
* overriding {@link #addLinks(Resource)} and {@link #addLinks(Resources)} where you have full control over exactly
* what links are put in the individual and collection resources.
*
* @return
*/
protected LinkBuilder getCollectionLinkBuilder() {
ControllerLinkBuilder linkBuilder = linkTo(this.controllerClass);
for (String pathComponent : (getPrefix() + this.relProvider.getCollectionResourceRelFor(this.resourceType)).split("/")) {
if (!pathComponent.isEmpty()) {
linkBuilder = linkBuilder.slash(pathComponent);
}
}
return linkBuilder;
}
private String getPrefix() {
return getBasePath().isEmpty() ? "" : getBasePath() + "/";
}
public String getBasePath() {
return this.basePath;
}
public void setBasePath(String basePath) {
this.basePath = basePath;
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2017 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.hateoas;
import java.util.ArrayList;
import java.util.List;
import org.springframework.util.Assert;
/**
* A {@link ResourceAssembler}/{@link ResourcesAssembler} that focuses purely on the domain type,
* returning back {@link Resource} and {@link Resources} for that type instead of
* {@link org.springframework.hateoas.ResourceSupport}.
*
* @author Greg Turnquist
*/
public class SimpleResourceAssembler<T> implements ResourceAssembler<T, Resource<T>>, ResourcesAssembler<T, Resource<T>> {
/**
* Converts the given entity into a {@link Resource}.
*
* @param entity
* @return
*/
@Override
public Resource<T> toResource(T entity) {
Resource<T> resource = new Resource<T>(entity);
addLinks(resource);
return resource;
}
/**
* Converts all given entities into resources and wraps the collection as a resource as well.
*
* @see #toResource(Object)
* @param entities must not be {@literal null}.
* @return {@link Resources} containing {@link Resource} of {@code T}.
*/
public Resources<Resource<T>> toResources(Iterable<? extends T> entities) {
Assert.notNull(entities, "Entities must not be null!");
List<Resource<T>> result = new ArrayList<Resource<T>>();
for (T entity : entities) {
result.add(toResource(entity));
}
Resources<Resource<T>> resources = new Resources<>(result);
addLinks(resources);
return resources;
}
/**
* Define links to add to every individual {@link Resource}.
*
* @param resource
*/
protected void addLinks(Resource<T> resource) {
// Default adds no links
}
/**
* Define links to add to the {@link Resources} collection.
*
* @param resources
*/
protected void addLinks(Resources<Resource<T>> resources) {
// Default adds no links.
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2017 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.hateoas.examples;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
/**
* Pre-load some data using a Spring Boot {@link CommandLineRunner}.
*
* @author Greg Turnquist
*/
@Component
public class DatabaseLoader {
/**
* Use Spring to inject a {@link EmployeeRepository} that can then load data. Since this will run
* only after the app is operational, the database will be up.
*
* @param repository
*/
@Bean
CommandLineRunner init(EmployeeRepository repository) {
return args -> {
repository.save(new Employee("Frodo", "Baggins", "ring bearer"));
repository.save(new Employee("Bilbo", "Baggins", "burglar"));
};
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2017 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.hateoas.examples;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.hateoas.Identifiable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Domain object representing a company employee. Project Lombok keeps actual code at a minimum.
*
* {@code @Data} - Generates getters, setters, toString, hash, and equals functions
* {@code @Entity} - JPA annotation to flag this class for DB persistence
* {@code @NoArgsConstructor} - Create a constructor with no args to support JPA
* {@code @AllArgsConstructor} - Create a constructor with all args to support testing
*
* {@code @JsonIgnoreProperties(ignoreUnknow=true)}
* When converting JSON to Java, ignore any unrecognized attributes. This is critical for REST because it
* encourages adding new fields in later versions that won't break. It also allows things like _links to be
* ignore as well, meaning HAL documents can be fetched and later posted to the server without adjustment.
*
*
* @author Greg Turnquist
*/
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Employee implements Identifiable<Long> {
@Id @GeneratedValue
private Long id;
private String firstName;
private String lastName;
private String role;
/**
* Useful constructor when id is not yet known.
*
* @param firstName
* @param lastName
* @param role
*/
Employee(String firstName, String lastName, String role) {
this.firstName = firstName;
this.lastName = lastName;
this.role = role;
}
/**
* This method will create another piece of data in the REST resource representation. These types
* of methods are key in supporting backward compatibility.
*
* By NOT removing old fields, and instead replacing them with methods like this, an API can evolve
* without breaking old clients.
*
* Because of {@code @JsonIgnoreProperties} settings above, this attribute will be ignore if sent back
* to the server, allowing API evolution.
*
* @return
*/
public String getFullName() {
return firstName + " " + lastName;
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2017 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.hateoas.examples;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* Spring Web {@link RestController} used to generate a REST API.
*
* Works by injecting an {@link EmployeeRepository} and an {@link EmployeeResourceAssembler} in the constructor, both
* of which are used to retrieve data from the database, and assemble a REST resource.
*
* @author Greg Turnquist
*/
@RestController
public class EmployeeController {
private final EmployeeRepository repository;
private final EmployeeResourceAssembler assembler;
public EmployeeController(EmployeeRepository repository,
EmployeeResourceAssembler assembler) {
this.repository = repository;
this.assembler = assembler;
}
/**
* Look up all employees, and transform them into a REST collection resource using
* {@link EmployeeResourceAssembler#toResources(Iterable)}. Then return them through
* Spring Web's {@link ResponseEntity} fluent API.
*
* NOTE: cURL will fetch things as HAL JSON directly, but browsers issue a different
* default accept header, which allows XML to get requested first, so "produces"
* forces it to HAL JSON for all clients.
*/
@GetMapping(value = "/employees", produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity<Resources<Resource<Employee>>> findAll() {
return ResponseEntity.ok(
assembler.toResources(repository.findAll()));
}
/**
* Look up a single {@link Employee} and transform into a REST resource using
* {@link EmployeeResourceAssembler#toResource(Object)}. Then return it through
* Spring Web's {@link ResponseEntity} fluent API.
*
* See {@link #findAll()} to explain "produces".
*
* @param id
*/
@GetMapping(value = "/employees/{id}", produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity<Resource<Employee>> findOne(@PathVariable String id) {
return ResponseEntity.ok(
assembler.toResource(repository.findOne(Long.valueOf(id))));
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2017 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.hateoas.examples;
import org.springframework.data.repository.CrudRepository;
/**
* @author Greg Turnquist
*/
public interface EmployeeRepository extends CrudRepository<Employee, Long> {
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2017 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.hateoas.examples;
import org.springframework.hateoas.SimpleIdentifiableResourceAssembler;
import org.springframework.stereotype.Component;
/**
* @author Greg Turnquist
*/
@Component
public class EmployeeResourceAssembler extends SimpleIdentifiableResourceAssembler<Employee> {
/**
* Link the {@link Employee} domain type to the {@link EmployeeController} using this
* {@link SimpleIdentifiableResourceAssembler} in order to generate both {@link org.springframework.hateoas.Resource}
* and {@link org.springframework.hateoas.Resources}.
*/
public EmployeeResourceAssembler() {
super(EmployeeController.class);
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2017 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.hateoas.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.hateoas.core.EvoInflectorRelProvider;
/**
* @author Greg Turnquist
*/
@SpringBootApplication
public class SpringHateoasBasicsApplication {
public static void main(String... args) {
SpringApplication.run(SpringHateoasBasicsApplication.class);
}
/**
* Format embedded collections by pluralizing the resource's type.
*
* @return
*/
@Bean
EvoInflectorRelProvider relProvider() {
return new EvoInflectorRelProvider();
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2017 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.hateoas;
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
import java.util.Arrays;
import lombok.Data;
import org.hamcrest.Matchers;
import org.junit.Test;
/**
* @author Greg Turnquist
*/
public class SimpleResourceAssemblerTest {
@Test
public void convertingToResourceShouldWork() {
TestResourceAssembler assembler = new TestResourceAssembler();
Resource<Employee> resource = assembler.toResource(new Employee("Frodo"));
assertThat(resource.getContent().getName(), is("Frodo"));
}
@Test
public void convertingToResourcesShouldWork() {
TestResourceAssembler assembler = new TestResourceAssembler();
Resources<Resource<Employee>> resources = assembler.toResources(Arrays.asList(new Employee("Frodo")));
assertThat(resources.getContent(), hasSize(1));
assertThat(resources.getContent(), Matchers.<Resource<Employee>>contains(new Resource(new Employee("Frodo"))));
assertThat(resources.getLinks(), is(Matchers.<Link>empty()));
assertThat(resources.getContent().iterator().next(), is(new Resource(new Employee("Frodo"))));
}
@Test
public void convertingToResourceWithCustomLinksShouldWork() {
ResourceAssemblerWithCustomLink assembler = new ResourceAssemblerWithCustomLink();
Resource<Employee> resource = assembler.toResource(new Employee("Frodo"));
assertThat(resource.getContent().getName(), is("Frodo"));
assertThat(resource.getLinks(), hasSize(1));
assertThat(resource.getLinks(), hasItem(new Link("/employees").withRel("employees")));
}
@Test
public void convertingToResourcesWithCustomLinksShouldWork() {
ResourceAssemblerWithCustomLink assembler = new ResourceAssemblerWithCustomLink();
Resources<Resource<Employee>> resources = assembler.toResources(Arrays.asList(new Employee("Frodo")));
assertThat(resources.getContent(), hasSize(1));
assertThat(resources.getContent(),
Matchers.<Resource<Employee>>contains(new Resource(new Employee("Frodo"), new Link("/employees").withRel("employees"))));
assertThat(resources.getLinks(), is(Matchers.<Link>empty()));
assertThat(resources.getContent().iterator().next(),
is(new Resource(new Employee("Frodo"), new Link("/employees").withRel("employees"))));
}
class TestResourceAssembler extends SimpleResourceAssembler<Employee> {}
class ResourceAssemblerWithCustomLink extends SimpleResourceAssembler<Employee> {
@Override
protected void addLinks(Resource<Employee> resource) {
resource.add(new Link("/employees").withRel("employees"));
}
}
@Data
class Employee {
private final String name;
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2017 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.hateoas.examples;
import static org.hamcrest.CoreMatchers.*;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.util.Arrays;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
/**
* @author Greg Turnquist
*/
@RunWith(SpringRunner.class)
@WebMvcTest(EmployeeController.class)
@Import({EmployeeResourceAssembler.class})
public class EmployeeControllerTests {
@Autowired
private MockMvc mvc;
@MockBean
private EmployeeRepository repository;
@Test
public void noop() throws Exception {
given(repository.findAll()).willReturn(
Arrays.asList(
new Employee(1L,"Frodo", "Baggins", "ring bearer"),
new Employee(2L,"Bilbo", "Baggins", "burglar")));
mvc.perform(get("/employees").accept(MediaTypes.HAL_JSON_VALUE))
.andDo(print())
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE + ";charset=UTF-8"))
.andExpect(jsonPath("$._embedded.employees[0].id", is(1)))
.andExpect(jsonPath("$._embedded.employees[0].firstName", is("Frodo")))
.andExpect(jsonPath("$._embedded.employees[0].lastName", is("Baggins")))
.andExpect(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
.andExpect(jsonPath("$._embedded.employees[0]._links.self.href", is("http://localhost/employees/1")))
.andExpect(jsonPath("$._embedded.employees[0]._links.employees.href", is("http://localhost/employees")))
.andExpect(jsonPath("$._embedded.employees[1].id", is(2)))
.andExpect(jsonPath("$._embedded.employees[1].firstName", is("Bilbo")))
.andExpect(jsonPath("$._embedded.employees[1].lastName", is("Baggins")))
.andExpect(jsonPath("$._embedded.employees[1].role", is("burglar")))
.andExpect(jsonPath("$._embedded.employees[1]._links.self.href", is("http://localhost/employees/2")))
.andExpect(jsonPath("$._embedded.employees[1]._links.employees.href", is("http://localhost/employees")))
.andExpect(jsonPath("$._links.self.href", is("http://localhost/employees")))
.andReturn();
}
}