├── .editorconfig ├── .gitignore ├── client ├── src │ ├── main │ │ ├── java │ │ │ └── uk │ │ │ │ └── co │ │ │ │ └── blackpepper │ │ │ │ └── bowman │ │ │ │ ├── PropertyValueFactory.java │ │ │ │ ├── ConditionalMethodHandler.java │ │ │ │ ├── ObjectMapperConfigurer.java │ │ │ │ ├── MethodLinkAttributes.java │ │ │ │ ├── MethodLinkUriResolver.java │ │ │ │ ├── NoSuchLinkException.java │ │ │ │ ├── MethodLinkAttributesResolver.java │ │ │ │ ├── SimplePropertyMethodHandler.java │ │ │ │ ├── ClientProxyFactory.java │ │ │ │ ├── ObjectMapperFactory.java │ │ │ │ ├── TypeResolver.java │ │ │ │ ├── ResourceIdMethodHandler.java │ │ │ │ ├── RestTemplateFactory.java │ │ │ │ ├── MethodHandlerChain.java │ │ │ │ ├── annotation │ │ │ │ ├── ResourceId.java │ │ │ │ ├── LinkedResource.java │ │ │ │ ├── RemoteResource.java │ │ │ │ └── ResourceTypeInfo.java │ │ │ │ ├── HalSupport.java │ │ │ │ ├── ClientProxyException.java │ │ │ │ ├── DefaultTypeResolver.java │ │ │ │ ├── RestTemplateConfigurer.java │ │ │ │ ├── DefaultPropertyValueFactory.java │ │ │ │ ├── DefaultObjectMapperFactory.java │ │ │ │ ├── DefaultRestTemplateFactory.java │ │ │ │ ├── JsonClientHttpRequestInterceptor.java │ │ │ │ ├── AbstractPropertyAwareMethodHandler.java │ │ │ │ ├── ClientFactory.java │ │ │ │ ├── ReflectionSupport.java │ │ │ │ ├── SelfLinkTypeResolver.java │ │ │ │ ├── JavassistClientProxyFactory.java │ │ │ │ ├── ResourceDeserializer.java │ │ │ │ ├── InlineAssociationDeserializer.java │ │ │ │ ├── RestOperations.java │ │ │ │ ├── JacksonClientModule.java │ │ │ │ ├── RestOperationsFactory.java │ │ │ │ └── Client.java │ │ └── asciidoc │ │ │ ├── index.adoc │ │ │ ├── 500-glossary.adoc │ │ │ ├── 100-faqs.adoc │ │ │ ├── 010-overview.adoc │ │ │ ├── 020-getting-started.adoc │ │ │ └── 030-api-usage.adoc │ └── test │ │ └── java │ │ └── uk │ │ └── co │ │ └── blackpepper │ │ └── bowman │ │ ├── NoSuchLinkExceptionTest.java │ │ ├── ReflectionSupportTest.java │ │ ├── HalSupportTest.java │ │ ├── ConfigurationTest.java │ │ ├── ResourceIdMethodHandlerTest.java │ │ ├── AbstractPropertyAwareMethodHandlerTest.java │ │ ├── MethodLinkUriResolverTest.java │ │ ├── JsonClientHttpRequestInterceptorTest.java │ │ ├── DefaultPropertyValueFactoryTest.java │ │ ├── MethodLinkAttributesResolverTest.java │ │ ├── SimplePropertyMethodHandlerTest.java │ │ ├── MethodHandlerChainTest.java │ │ ├── InlineAssociationDeserializerTest.java │ │ ├── JacksonClientModuleTest.java │ │ └── ResourceDeserializerTest.java └── pom.xml ├── test ├── server │ ├── src │ │ └── main │ │ │ └── java │ │ │ └── uk │ │ │ └── co │ │ │ └── blackpepper │ │ │ └── bowman │ │ │ └── test │ │ │ └── server │ │ │ ├── model │ │ │ ├── HierarchyDerivedEntity1.java │ │ │ ├── HierarchyDerivedEntity2.java │ │ │ ├── HierarchyBaseEntity.java │ │ │ ├── NullLinkedCollectionEntity.java │ │ │ ├── PageableEntity.java │ │ │ ├── HierarchyPropertyEntity.java │ │ │ ├── OptionalLinksEntity.java │ │ │ ├── SimpleEntity.java │ │ │ ├── BidiChildEntity.java │ │ │ ├── CustomRelEntity.java │ │ │ ├── BidiParentEntity.java │ │ │ ├── InlineBidiChildEntity.java │ │ │ └── InlineBidiParentEntity.java │ │ │ ├── repository │ │ │ ├── HierarchyBaseEntityRepository.java │ │ │ ├── OptionalLinksEntityRepository.java │ │ │ ├── HierarchyDerivedEntity1Repository.java │ │ │ ├── HierarchyDerivedEntity2Repository.java │ │ │ ├── HierarchyPropertyEntityRepository.java │ │ │ ├── NullLinkedCollectionEntityRepository.java │ │ │ ├── PageableEntityRepository.java │ │ │ ├── BidiChildEntityRepository.java │ │ │ ├── BidiParentEntityRepository.java │ │ │ ├── CustomRelEntityRepository.java │ │ │ ├── InlineBidiParentEntityRepository.java │ │ │ └── SimpleEntityRepository.java │ │ │ ├── controller │ │ │ └── OptionalLinksEntitiesAbsentLinksController.java │ │ │ └── Application.java │ └── pom.xml ├── it │ ├── src │ │ └── test │ │ │ ├── java │ │ │ └── uk │ │ │ │ └── co │ │ │ │ └── blackpepper │ │ │ │ └── bowman │ │ │ │ └── test │ │ │ │ └── it │ │ │ │ ├── model │ │ │ │ ├── SimpleEntitySearch.java │ │ │ │ ├── HierarchyDerivedEntity1.java │ │ │ │ ├── HierarchyDerivedEntity2.java │ │ │ │ ├── HierarchyBaseEntity.java │ │ │ │ ├── NullLinkedCollectionEntity.java │ │ │ │ ├── PageableEntity.java │ │ │ │ ├── PageableEntityResultPage.java │ │ │ │ ├── HierarchyPropertyEntity.java │ │ │ │ ├── CustomRelEntity.java │ │ │ │ ├── OptionalLinksEntity.java │ │ │ │ ├── OptionalLinksQueryEntity.java │ │ │ │ ├── SimpleEntity.java │ │ │ │ ├── BidiChildEntity.java │ │ │ │ ├── BidiParentEntity.java │ │ │ │ ├── InlineBidiChildEntity.java │ │ │ │ └── InlineBidiParentEntity.java │ │ │ │ ├── RetainLocalChangesIT.java │ │ │ │ ├── NullLinkedCollectionIT.java │ │ │ │ ├── CustomRelIT.java │ │ │ │ ├── SimpleEntitySearchIT.java │ │ │ │ ├── BidiIT.java │ │ │ │ ├── InlineBidiIT.java │ │ │ │ ├── OptionalLinkIT.java │ │ │ │ ├── PagingIT.java │ │ │ │ ├── HierarchyIT.java │ │ │ │ ├── AbstractIT.java │ │ │ │ └── SimpleEntityIT.java │ │ │ └── resources │ │ │ ├── logback-test-quiet.xml │ │ │ ├── logback-server.xml │ │ │ └── logback-test.xml │ └── pom.xml └── pom.xml ├── development.adoc ├── deploy └── pom.xml ├── .github └── workflows │ └── build.yml ├── config └── settings.ci.xml └── README.adoc /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.adoc] 2 | indent_style = space 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .settings 3 | .classpath 4 | .checkstyle 5 | 6 | *.iml 7 | .idea/ 8 | 9 | .DS_Store 10 | 11 | target 12 | 13 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/PropertyValueFactory.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.util.Collection; 4 | 5 | interface PropertyValueFactory { 6 | 7 | > T createCollection(Class collectionType); 8 | } 9 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ConditionalMethodHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.Method; 4 | 5 | import javassist.util.proxy.MethodHandler; 6 | 7 | interface ConditionalMethodHandler extends MethodHandler { 8 | 9 | boolean supports(Method method); 10 | } 11 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/HierarchyDerivedEntity1.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import jakarta.persistence.Entity; 4 | 5 | @SuppressWarnings("unused") 6 | @Entity 7 | public class HierarchyDerivedEntity1 extends HierarchyBaseEntity { 8 | 9 | private String entity1Field; 10 | } 11 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/HierarchyDerivedEntity2.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import jakarta.persistence.Entity; 4 | 5 | @SuppressWarnings("unused") 6 | @Entity 7 | public class HierarchyDerivedEntity2 extends HierarchyBaseEntity { 8 | 9 | private String entity2Field; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/main/asciidoc/index.adoc: -------------------------------------------------------------------------------- 1 | = Bowman Reference Documentation 2 | :toc: right 3 | :icons: font 4 | :sectanchors: 5 | 6 | A simple, model-mapping, link-traversing Java client library for consuming a JSON+HAL REST API. 7 | 8 | include::010-overview.adoc[] 9 | 10 | include::020-getting-started.adoc[] 11 | 12 | include::030-api-usage.adoc[] 13 | 14 | include::040-client-model.adoc[] 15 | 16 | include::100-faqs.adoc[] 17 | 18 | include::500-glossary.adoc[] 19 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/HierarchyBaseEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.GenerationType; 6 | import jakarta.persistence.Id; 7 | 8 | @Entity 9 | public abstract class HierarchyBaseEntity { 10 | 11 | @Id 12 | @GeneratedValue(strategy = GenerationType.IDENTITY) 13 | private Integer id; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ObjectMapperConfigurer.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | 5 | /** 6 | * Interface supporting the configuration of the Jackson {@link com.fasterxml.jackson.databind.ObjectMapper} which is 7 | * used internally by the {@link Client}. 8 | * 9 | * @author Karl Spies 10 | */ 11 | public interface ObjectMapperConfigurer { 12 | 13 | void configure(ObjectMapper objectMapper); 14 | } 15 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/MethodLinkAttributes.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | class MethodLinkAttributes { 4 | 5 | private String linkName; 6 | 7 | private boolean optional; 8 | 9 | MethodLinkAttributes(String linkName, boolean optional) { 10 | this.linkName = linkName; 11 | this.optional = optional; 12 | } 13 | 14 | public String getLinkName() { 15 | return linkName; 16 | } 17 | 18 | public boolean isOptional() { 19 | return optional; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/MethodLinkUriResolver.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.net.URI; 4 | 5 | import org.springframework.hateoas.EntityModel; 6 | import org.springframework.hateoas.Link; 7 | 8 | class MethodLinkUriResolver { 9 | 10 | URI resolveForMethod(EntityModel resource, String linkName, Object[] args) { 11 | Link link = resource.getLink(linkName).orElseThrow(() -> new NoSuchLinkException(linkName)); 12 | return URI.create(link.expand(args).getHref()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/SimpleEntitySearch.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.util.List; 4 | 5 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 6 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 7 | 8 | @RemoteResource("/simple-entities/search") 9 | public interface SimpleEntitySearch { 10 | 11 | @LinkedResource 12 | SimpleEntity findByName(String name); 13 | 14 | @LinkedResource 15 | List findByNameContaining(String query); 16 | } 17 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/HierarchyDerivedEntity1.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 4 | 5 | @RemoteResource("/hierarchy-derived-entity-ones") 6 | public class HierarchyDerivedEntity1 extends HierarchyBaseEntity { 7 | 8 | private String entity1Field; 9 | 10 | public String getEntity1Field() { 11 | return entity1Field; 12 | } 13 | 14 | public void setEntity1Field(String entity1Field) { 15 | this.entity1Field = entity1Field; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/HierarchyDerivedEntity2.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 4 | 5 | @RemoteResource("/hierarchy-derived-entity-twos") 6 | public class HierarchyDerivedEntity2 extends HierarchyBaseEntity { 7 | 8 | private String entity2Field; 9 | 10 | public String getEntity2Field() { 11 | return entity2Field; 12 | } 13 | 14 | public void setEntity2Field(String entity2Field) { 15 | this.entity2Field = entity2Field; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/NoSuchLinkExceptionTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.hamcrest.Matchers.is; 6 | import static org.junit.Assert.assertThat; 7 | 8 | public class NoSuchLinkExceptionTest { 9 | 10 | @Test 11 | public void constructorSetsProperties() { 12 | NoSuchLinkException exception = new NoSuchLinkException("linked"); 13 | 14 | assertThat(exception.getLinkName(), is("linked")); 15 | assertThat(exception.getMessage(), is("Link 'linked' could not be found!")); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/main/asciidoc/500-glossary.adoc: -------------------------------------------------------------------------------- 1 | == Glossary 2 | 3 | [glossary] 4 | Base resource:: 5 | The remote resource identified as the entrypoint for an entity's API, identified by the entity's `@RemoteResource` annotation. An entity's base resource may be single-valued or collection-valued. 6 | 7 | Entity:: 8 | An artifact within the client-side model. This may correspond to a full or embedded remote resource. Entity types may be concrete classes (Java Beans - when their representation contains data properties) or abstract classes or interfaces (useful when their representation contains read-only links). -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/HierarchyBaseEntityRepository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 5 | 6 | import uk.co.blackpepper.bowman.test.server.model.HierarchyBaseEntity; 7 | 8 | @RepositoryRestResource(path = "hierarchy-base-entities") 9 | public interface HierarchyBaseEntityRepository extends CrudRepository { 10 | // no additional methods 11 | } 12 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/OptionalLinksEntityRepository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 5 | 6 | import uk.co.blackpepper.bowman.test.server.model.OptionalLinksEntity; 7 | 8 | @RepositoryRestResource(path = "optional-links-entities") 9 | public interface OptionalLinksEntityRepository extends CrudRepository { 10 | // no additional methods 11 | } 12 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/HierarchyDerivedEntity1Repository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 5 | 6 | import uk.co.blackpepper.bowman.test.server.model.HierarchyDerivedEntity1; 7 | 8 | @RepositoryRestResource(path = "hierarchy-derived-entity-ones") 9 | public interface HierarchyDerivedEntity1Repository extends CrudRepository { 10 | // no additional methods 11 | } 12 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/HierarchyDerivedEntity2Repository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 5 | 6 | import uk.co.blackpepper.bowman.test.server.model.HierarchyDerivedEntity2; 7 | 8 | @RepositoryRestResource(path = "hierarchy-derived-entity-twos") 9 | public interface HierarchyDerivedEntity2Repository extends CrudRepository { 10 | // no additional methods 11 | } 12 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/HierarchyPropertyEntityRepository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 5 | 6 | import uk.co.blackpepper.bowman.test.server.model.HierarchyPropertyEntity; 7 | 8 | @RepositoryRestResource(path = "hierarchy-property-entities") 9 | public interface HierarchyPropertyEntityRepository extends CrudRepository { 10 | // no additional methods 11 | } 12 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/NullLinkedCollectionEntityRepository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 5 | 6 | import uk.co.blackpepper.bowman.test.server.model.NullLinkedCollectionEntity; 7 | 8 | @RepositoryRestResource(path = "null-linked-collections") 9 | public interface NullLinkedCollectionEntityRepository extends CrudRepository { 10 | // no additional methods 11 | } 12 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/NullLinkedCollectionEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import java.util.Set; 4 | 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.OneToMany; 10 | 11 | @Entity 12 | public class NullLinkedCollectionEntity { 13 | 14 | @Id 15 | @GeneratedValue(strategy = GenerationType.IDENTITY) 16 | private Integer id; 17 | 18 | @OneToMany 19 | private Set linked; 20 | } 21 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/PageableEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.GenerationType; 6 | import jakarta.persistence.Id; 7 | import jakarta.persistence.ManyToOne; 8 | 9 | @Entity 10 | @SuppressWarnings("unused") 11 | public class PageableEntity { 12 | 13 | @Id 14 | @GeneratedValue(strategy = GenerationType.IDENTITY) 15 | private Integer id; 16 | 17 | private String name; 18 | 19 | @ManyToOne 20 | private PageableEntity linked; 21 | } 22 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/ReflectionSupportTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.junit.Rule; 4 | import org.junit.Test; 5 | import org.junit.rules.ExpectedException; 6 | 7 | public class ReflectionSupportTest { 8 | 9 | private ExpectedException thrown = ExpectedException.none(); 10 | 11 | @Rule 12 | public ExpectedException getThrown() { 13 | return thrown; 14 | } 15 | 16 | @Test 17 | public void getIdWhenNoIdAccessor() { 18 | thrown.expect(IllegalArgumentException.class); 19 | thrown.expectMessage("No @ResourceId found for java.lang.Object"); 20 | 21 | ReflectionSupport.getId(new Object()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/NoSuchLinkException.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | /** 4 | * An exception thrown when no link could be found for a linked association. 5 | * 6 | * @author Ryan Pickett 7 | * 8 | */ 9 | public class NoSuchLinkException extends ClientProxyException { 10 | 11 | public static final long serialVersionUID = 4161584113275074573L; 12 | 13 | private final String linkName; 14 | 15 | NoSuchLinkException(String linkName) { 16 | super(String.format("Link '%s' could not be found!", linkName)); 17 | this.linkName = linkName; 18 | } 19 | 20 | public String getLinkName() { 21 | return linkName; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/MethodLinkAttributesResolver.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.Method; 4 | 5 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 6 | 7 | import static uk.co.blackpepper.bowman.HalSupport.toLinkName; 8 | 9 | class MethodLinkAttributesResolver { 10 | 11 | MethodLinkAttributes resolveForMethod(Method method) { 12 | LinkedResource annotation = method.getAnnotation(LinkedResource.class); 13 | 14 | String rel = annotation.rel(); 15 | 16 | if ("".equals(rel)) { 17 | rel = toLinkName(method.getName()); 18 | } 19 | 20 | return new MethodLinkAttributes(rel, annotation.optionalLink()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/PageableEntityRepository.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import org.springframework.data.repository.PagingAndSortingRepository; 5 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 6 | 7 | import uk.co.blackpepper.bowman.test.server.model.PageableEntity; 8 | 9 | @RepositoryRestResource(path = "pageable-entities") 10 | public interface PageableEntityRepository extends 11 | PagingAndSortingRepository, 12 | CrudRepository { 13 | // no additional methods 14 | } 15 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/HierarchyBaseEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.net.URI; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnore; 6 | 7 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 8 | import uk.co.blackpepper.bowman.annotation.ResourceId; 9 | import uk.co.blackpepper.bowman.annotation.ResourceTypeInfo; 10 | 11 | @RemoteResource("/hierarchy-base-entities") 12 | @ResourceTypeInfo(subtypes = {HierarchyDerivedEntity1.class, HierarchyDerivedEntity2.class}) 13 | public abstract class HierarchyBaseEntity { 14 | 15 | private URI id; 16 | 17 | @JsonIgnore 18 | @ResourceId 19 | public URI getId() { 20 | return id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/HierarchyPropertyEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import java.util.Set; 4 | 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.ManyToMany; 10 | import jakarta.persistence.ManyToOne; 11 | 12 | @Entity 13 | public class HierarchyPropertyEntity { 14 | 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.IDENTITY) 17 | private Integer id; 18 | 19 | @ManyToOne 20 | private HierarchyBaseEntity linkedEntity; 21 | 22 | @ManyToMany 23 | private Set linkedEntityCollection; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/HalSupportTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.hamcrest.Matchers.is; 6 | import static org.junit.Assert.assertThat; 7 | 8 | public class HalSupportTest { 9 | 10 | @Test 11 | public void toLinkNameWithIsMethodReturnsDecapitalizedMethodNameSubstring() { 12 | assertThat(HalSupport.toLinkName("isTheProperty"), is("theProperty")); 13 | } 14 | 15 | @Test 16 | public void toLinkNameWithGetMethodReturnsDecapitalizedMethodNameSubstring() { 17 | assertThat(HalSupport.toLinkName("getTheProperty"), is("theProperty")); 18 | } 19 | 20 | @Test 21 | public void toLinkNameWithOtherMethodReturnsMethodName() { 22 | assertThat(HalSupport.toLinkName("aMethod"), is("aMethod")); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/OptionalLinksEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.model; 2 | 3 | import java.util.Set; 4 | 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.ManyToMany; 10 | import jakarta.persistence.ManyToOne; 11 | 12 | @SuppressWarnings("unused") 13 | @Entity 14 | public class OptionalLinksEntity { 15 | 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.IDENTITY) 18 | private Integer id; 19 | 20 | private String name; 21 | 22 | @ManyToOne 23 | private SimpleEntity optionalLinkItem; 24 | 25 | @ManyToMany 26 | private Set optionalLinkCollection; 27 | } 28 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/ConfigurationTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.net.URI; 4 | 5 | import org.junit.Test; 6 | 7 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 8 | 9 | import static org.hamcrest.Matchers.is; 10 | import static org.junit.Assert.assertThat; 11 | 12 | public class ConfigurationTest { 13 | 14 | @RemoteResource("/y") 15 | private static class Entity { 16 | } 17 | 18 | @Test 19 | public void buildClientFactoryBuildsFactoryWithConfiguration() { 20 | ClientFactory factory = Configuration.builder() 21 | .setBaseUri(URI.create("http://x.com")).build().buildClientFactory(); 22 | 23 | Client client = factory.create(Entity.class); 24 | 25 | assertThat(client.getBaseUri(), is(URI.create("http://x.com/y"))); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/NullLinkedCollectionEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.net.URI; 4 | import java.util.Set; 5 | 6 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 7 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 8 | import uk.co.blackpepper.bowman.annotation.ResourceId; 9 | 10 | @RemoteResource("/null-linked-collections") 11 | public class NullLinkedCollectionEntity { 12 | 13 | private URI id; 14 | 15 | private Set linked; 16 | 17 | @ResourceId 18 | public URI getId() { 19 | return id; 20 | } 21 | 22 | @LinkedResource 23 | public Set getLinked() { 24 | return linked; 25 | } 26 | 27 | public void setLinked(Set linked) { 28 | this.linked = linked; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /development.adoc: -------------------------------------------------------------------------------- 1 | == Building 2 | 3 | To build and install into the local Maven repository: 4 | 5 | `mvn install` 6 | 7 | To run the integration tests: 8 | 9 | `mvn verify -PrunITs` 10 | 11 | You can run integration tests individually via your IDE by running the `uk.co.blackpepper.bowman.test.server.Application` Spring Boot application from the `test/server` module, then running tests using your IDE's JUnit runner. 12 | 13 | == IDE Setup 14 | 15 | A Checkstyle plugin for your IDE is recommended. 16 | 17 | == Code Style Configuration Files 18 | 19 | Using these code style files for your IDE will help your contribution conform with our Checkstyle rules: 20 | 21 | * https://github.com/BlackPepperSoftware/bp-build/tree/master/src/main/config/eclipse[Eclipse] 22 | * https://github.com/BlackPepperSoftware/bp-build/tree/master/src/main/config/idea/codestyles[IDEA] 23 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/SimplePropertyMethodHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.InvocationTargetException; 4 | import java.lang.reflect.Method; 5 | 6 | import org.springframework.hateoas.EntityModel; 7 | 8 | class SimplePropertyMethodHandler extends AbstractPropertyAwareMethodHandler { 9 | 10 | private final T content; 11 | 12 | SimplePropertyMethodHandler(EntityModel resource) { 13 | super(resource.getContent().getClass()); 14 | this.content = resource.getContent(); 15 | } 16 | 17 | @Override 18 | public boolean supports(Method method) { 19 | return isSetter(method) || isGetter(method); 20 | } 21 | 22 | @Override 23 | public Object invoke(Object self, Method method, Method proceed, Object[] args) 24 | throws InvocationTargetException, IllegalAccessException { 25 | 26 | return method.invoke(content, args); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ClientProxyFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import org.springframework.hateoas.EntityModel; 19 | 20 | interface ClientProxyFactory { 21 | 22 | T create(EntityModel resource, RestOperations restOperations); 23 | } 24 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/PageableEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.net.URI; 4 | 5 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 6 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 7 | import uk.co.blackpepper.bowman.annotation.ResourceId; 8 | 9 | @RemoteResource("/pageable-entities") 10 | public class PageableEntity { 11 | 12 | private URI id; 13 | 14 | private String name; 15 | 16 | private PageableEntity linked; 17 | 18 | @ResourceId 19 | public URI getId() { 20 | return id; 21 | } 22 | 23 | public String getName() { 24 | return name; 25 | } 26 | 27 | public void setName(String name) { 28 | this.name = name; 29 | } 30 | 31 | @LinkedResource 32 | public PageableEntity getLinked() { 33 | return linked; 34 | } 35 | 36 | public void setLinked(PageableEntity linked) { 37 | this.linked = linked; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ObjectMapperFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import com.fasterxml.jackson.databind.ObjectMapper; 19 | import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; 20 | 21 | interface ObjectMapperFactory { 22 | 23 | ObjectMapper create(HandlerInstantiator instantiator); 24 | } 25 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/TypeResolver.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.springframework.hateoas.Links; 4 | 5 | /** 6 | * Narrowing type resolution strategy. 7 | * 8 | * @author Ryan Pickett 9 | */ 10 | public interface TypeResolver { 11 | 12 | /** 13 | * Get the type to use for a resource. This will be the superclass of proxies generated for 14 | * the properties (or property collection items) returning the resource, and so must be a 15 | * subtype of the property's (or property collection item's) declared type. 16 | * 17 | * @param declaredType 18 | * declared type of the property or property collection item 19 | * @param resourceLinks 20 | * links of the resource 21 | * @param configuration 22 | * client factory configuration 23 | * @return 24 | * the type to use for the superclass of the generated proxy for a resource 25 | */ 26 | Class resolveType(Class declaredType, Links resourceLinks, Configuration configuration); 27 | } 28 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ResourceIdMethodHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.Method; 4 | import java.net.URI; 5 | import java.util.Optional; 6 | 7 | import org.springframework.hateoas.EntityModel; 8 | import org.springframework.hateoas.IanaLinkRelations; 9 | import org.springframework.hateoas.Link; 10 | 11 | import uk.co.blackpepper.bowman.annotation.ResourceId; 12 | 13 | class ResourceIdMethodHandler implements ConditionalMethodHandler { 14 | 15 | private final EntityModel resource; 16 | 17 | ResourceIdMethodHandler(EntityModel resource) { 18 | this.resource = resource; 19 | } 20 | 21 | @Override 22 | public boolean supports(Method method) { 23 | return method.isAnnotationPresent(ResourceId.class); 24 | } 25 | 26 | @Override 27 | public Object invoke(Object self, Method method, Method proceed, Object[] args) { 28 | Optional selfLink = resource.getLink(IanaLinkRelations.SELF); 29 | return selfLink.map(link -> URI.create(link.getHref())).orElse(null); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/it/src/test/resources/logback-test-quiet.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | %date %level [%thread] %logger{10} [%file:%line] %msg%n 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/it/src/test/resources/logback-server.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | server.log 21 | 22 | 23 | %date %level [%thread] %logger{10} [%file:%line] %msg%n 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/RestTemplateFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import org.springframework.http.client.ClientHttpRequestFactory; 19 | import org.springframework.web.client.RestTemplate; 20 | 21 | import com.fasterxml.jackson.databind.ObjectMapper; 22 | 23 | interface RestTemplateFactory { 24 | 25 | RestTemplate create(ClientHttpRequestFactory clientHttpRequestFactory, ObjectMapper objectMapper); 26 | } 27 | -------------------------------------------------------------------------------- /client/src/main/asciidoc/100-faqs.adoc: -------------------------------------------------------------------------------- 1 | == FAQs 2 | 3 | === How can I access an API that requires authentication? 4 | 5 | For example, you could define an interceptor to send the `Authorization` header with your OAuth access token on each request: 6 | 7 | [source,java] 8 | ---- 9 | ClientFactory clientFactory = Configuration.builder() 10 | .setRestTemplateConfigurer(new RestTemplateConfigurer() { 11 | 12 | public void configure(RestTemplate restTemplate) { 13 | 14 | restTemplate.getInterceptors().add(new ClientHttpRequestInterceptor() { 15 | 16 | public ClientHttpResponse intercept(HttpRequest request, byte[] body, 17 | ClientHttpRequestExecution execution) throws IOException { 18 | 19 | request.getHeaders().add("Authorization", "Bearer youraccesstoken"); 20 | return execution.execute(request, body); 21 | } 22 | }); 23 | } 24 | }) 25 | .buildClientFactory(); 26 | ---- 27 | 28 | You should be able to use Bowman or any other HTTP client library to first get the token from an OAuth https://tools.ietf.org/html/rfc6749#section-4.1.4[access token response] following authorisation. 29 | 30 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/MethodHandlerChain.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.Method; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | 7 | import javassist.util.proxy.MethodFilter; 8 | import javassist.util.proxy.MethodHandler; 9 | 10 | class MethodHandlerChain implements MethodHandler, MethodFilter { 11 | 12 | private List delegateHandlers; 13 | 14 | MethodHandlerChain(List delegateHandlers) { 15 | this.delegateHandlers = new ArrayList<>(delegateHandlers); 16 | } 17 | 18 | @Override 19 | public Object invoke(Object self, Method thisMethod, Method proceed, Object[] args) throws Throwable { 20 | for (ConditionalMethodHandler handler : delegateHandlers) { 21 | if (handler.supports(thisMethod)) { 22 | return handler.invoke(self, thisMethod, proceed, args); 23 | } 24 | } 25 | 26 | throw new IllegalStateException(String.format("invoke called for non-handled method %s", thisMethod)); 27 | } 28 | 29 | @Override 30 | public boolean isHandled(Method method) { 31 | return delegateHandlers.stream().anyMatch(h -> h.supports(method)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/RetainLocalChangesIT.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it; 2 | 3 | import java.net.URI; 4 | 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | 8 | import uk.co.blackpepper.bowman.Client; 9 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 10 | 11 | import static org.hamcrest.CoreMatchers.nullValue; 12 | import static org.hamcrest.Matchers.is; 13 | import static org.junit.Assert.assertThat; 14 | 15 | public class RetainLocalChangesIT extends AbstractIT { 16 | 17 | private Client client; 18 | 19 | @Before 20 | public void setup() { 21 | client = clientFactory.create(SimpleEntity.class); 22 | } 23 | 24 | @Test 25 | public void retainsLocallySetNullAssociations() { 26 | SimpleEntity related = new SimpleEntity(); 27 | related.setName("x"); 28 | 29 | client.post(related); 30 | 31 | SimpleEntity entity = new SimpleEntity(); 32 | entity.setRelated(related); 33 | 34 | URI location = client.post(entity); 35 | 36 | SimpleEntity retrieved = client.get(location); 37 | 38 | retrieved.setRelated(null); 39 | 40 | assertThat(retrieved.getRelated(), is(nullValue())); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/main/asciidoc/010-overview.adoc: -------------------------------------------------------------------------------- 1 | == Overview 2 | 3 | Bowman is a Java library for accessing a http://stateless.co/hal_specification.html[JSON+HAL] REST API, supporting the mapping of a client-side 4 | model to HTTP resources with automatic link traversal into associated resources. 5 | 6 | === Philosophy 7 | 8 | The original motivation for this library was to make it easier to write clients for https://projects.spring.io/spring-data-rest/[Spring Data 9 | REST]-exposed JPA repositories, supporting lazy-loading of associations in a similar style 10 | to JPA. 11 | 12 | Bowman mandates the use of a client-side model -- comprised of _entities_, though separate from the server-side model -- which maps to an API's JSON+HAL representation via annotations. It then enhances the model by creating _proxies_ using Javassist, so that invoking an accessor or query method can transparently retrieve a linked remote resource and add it to the client-side object graph. This can make client code much easier to write and understand. 13 | 14 | TIP: See the blog post https://hdpe.me/post/spring-data-rest-hal-client/[Simpler Spring Data REST Clients with Bowman], for the thinking that led to the creation of this library. 15 | -------------------------------------------------------------------------------- /test/it/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | %date %level [%thread] %logger{10} [%file:%line] %msg%n 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/PageableEntityResultPage.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.hateoas.mediatype.hal.Jackson2HalModule; 6 | 7 | import com.fasterxml.jackson.annotation.JsonProperty; 8 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 9 | 10 | import uk.co.blackpepper.bowman.InlineAssociationDeserializer; 11 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 12 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 13 | 14 | @RemoteResource("/pageable-entities") 15 | public abstract class PageableEntityResultPage { 16 | 17 | private List content; 18 | 19 | @JsonProperty("_embedded") 20 | @JsonDeserialize( 21 | using = Jackson2HalModule.HalResourcesDeserializer.class, 22 | contentUsing = InlineAssociationDeserializer.class, 23 | contentAs = PageableEntity.class) 24 | public List getContent() { 25 | return content; 26 | } 27 | 28 | public void setContent(List content) { 29 | this.content = content; 30 | } 31 | 32 | @LinkedResource 33 | public abstract PageableEntityResultPage getNext(); 34 | } 35 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/BidiChildEntityRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.repository; 17 | 18 | import org.springframework.data.repository.CrudRepository; 19 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 20 | 21 | import uk.co.blackpepper.bowman.test.server.model.BidiChildEntity; 22 | 23 | @RepositoryRestResource(path = "bidi-children") 24 | public interface BidiChildEntityRepository extends CrudRepository { 25 | // no additional methods 26 | } 27 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/BidiParentEntityRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.repository; 17 | 18 | import org.springframework.data.repository.CrudRepository; 19 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 20 | 21 | import uk.co.blackpepper.bowman.test.server.model.BidiParentEntity; 22 | 23 | @RepositoryRestResource(path = "bidi-parents") 24 | public interface BidiParentEntityRepository extends CrudRepository { 25 | // no additional methods 26 | } 27 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/CustomRelEntityRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.repository; 17 | 18 | import org.springframework.data.repository.CrudRepository; 19 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 20 | 21 | import uk.co.blackpepper.bowman.test.server.model.CustomRelEntity; 22 | 23 | @RepositoryRestResource(path = "custom-rel-entities") 24 | public interface CustomRelEntityRepository extends CrudRepository { 25 | // no additional methods 26 | } 27 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/annotation/ResourceId.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.annotation; 17 | 18 | import java.lang.annotation.ElementType; 19 | import java.lang.annotation.Retention; 20 | import java.lang.annotation.RetentionPolicy; 21 | import java.lang.annotation.Target; 22 | 23 | /** 24 | * Annotation to mark a property as the entity's {@link java.net.URI} ID. 25 | * 26 | * @author Ryan Pickett 27 | * 28 | */ 29 | @Retention(RetentionPolicy.RUNTIME) 30 | @Target(ElementType.METHOD) 31 | public @interface ResourceId { 32 | // marker annotation 33 | } 34 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/HalSupport.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.beans.Introspector; 19 | 20 | final class HalSupport { 21 | 22 | private HalSupport() { 23 | } 24 | 25 | public static String toLinkName(String methodName) { 26 | if (methodName.startsWith("is")) { 27 | methodName = methodName.substring(2); 28 | } 29 | else if (methodName.startsWith("get")) { 30 | methodName = methodName.substring(3); 31 | } 32 | else { 33 | return methodName; 34 | } 35 | 36 | return Introspector.decapitalize(methodName); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/InlineBidiParentEntityRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.repository; 17 | 18 | import org.springframework.data.repository.CrudRepository; 19 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 20 | 21 | import uk.co.blackpepper.bowman.test.server.model.InlineBidiParentEntity; 22 | 23 | @RepositoryRestResource(path = "inline-bidi-parents") 24 | public interface InlineBidiParentEntityRepository extends CrudRepository { 25 | // no additional methods 26 | } 27 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ClientProxyException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | /** 19 | * An exception thrown by a failure to create or navigate the proxies generated by a {@link Client}. 20 | * 21 | * @author Ryan Pickett 22 | * 23 | */ 24 | public class ClientProxyException extends RuntimeException { 25 | 26 | private static final long serialVersionUID = 7398487411554253606L; 27 | 28 | public ClientProxyException(String message) { 29 | super(message); 30 | } 31 | 32 | public ClientProxyException(String message, Throwable cause) { 33 | super(message, cause); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/SimpleEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.model; 17 | 18 | import jakarta.persistence.Entity; 19 | import jakarta.persistence.GeneratedValue; 20 | import jakarta.persistence.GenerationType; 21 | import jakarta.persistence.Id; 22 | import jakarta.persistence.ManyToOne; 23 | 24 | @SuppressWarnings("unused") 25 | @Entity 26 | public class SimpleEntity { 27 | 28 | @Id 29 | @GeneratedValue(strategy = GenerationType.IDENTITY) 30 | private Integer id; 31 | 32 | private String name; 33 | 34 | @ManyToOne 35 | private SimpleEntity related; 36 | } 37 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/BidiChildEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.model; 17 | 18 | import jakarta.persistence.Entity; 19 | import jakarta.persistence.GeneratedValue; 20 | import jakarta.persistence.GenerationType; 21 | import jakarta.persistence.Id; 22 | import jakarta.persistence.ManyToOne; 23 | 24 | @SuppressWarnings("unused") 25 | @Entity 26 | public class BidiChildEntity { 27 | 28 | @Id 29 | @GeneratedValue(strategy = GenerationType.IDENTITY) 30 | private Integer id; 31 | 32 | @ManyToOne 33 | private BidiParentEntity parent; 34 | 35 | private String name; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/DefaultTypeResolver.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.springframework.beans.BeanUtils; 4 | import org.springframework.core.annotation.AnnotationUtils; 5 | import org.springframework.hateoas.Links; 6 | 7 | import uk.co.blackpepper.bowman.annotation.ResourceTypeInfo; 8 | 9 | class DefaultTypeResolver implements TypeResolver { 10 | 11 | @Override 12 | public Class resolveType(Class declaredType, Links resourceLinks, Configuration configuration) { 13 | 14 | ResourceTypeInfo info = AnnotationUtils.findAnnotation(declaredType, ResourceTypeInfo.class); 15 | 16 | if (info == null) { 17 | return declaredType; 18 | } 19 | 20 | boolean customTypeResolverIsSpecified = info.typeResolver() != ResourceTypeInfo.NullTypeResolver.class; 21 | 22 | if (!(info.subtypes().length > 0 ^ customTypeResolverIsSpecified)) { 23 | throw new ClientProxyException("one of subtypes or typeResolver must be specified"); 24 | } 25 | 26 | TypeResolver delegateTypeResolver = customTypeResolverIsSpecified 27 | ? BeanUtils.instantiateClass(info.typeResolver()) 28 | : new SelfLinkTypeResolver(info.subtypes()); 29 | 30 | return delegateTypeResolver.resolveType(declaredType, resourceLinks, configuration); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/HierarchyPropertyEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.net.URI; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | 7 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 8 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 9 | import uk.co.blackpepper.bowman.annotation.ResourceId; 10 | 11 | @RemoteResource("/hierarchy-property-entities") 12 | public class HierarchyPropertyEntity { 13 | 14 | private URI id; 15 | 16 | private HierarchyBaseEntity linkedEntity; 17 | 18 | private List linkedEntityCollection = new ArrayList<>(); 19 | 20 | @ResourceId 21 | public URI getId() { 22 | return id; 23 | } 24 | 25 | @LinkedResource 26 | public HierarchyBaseEntity getLinkedEntity() { 27 | return linkedEntity; 28 | } 29 | 30 | public void setLinkedEntity(HierarchyBaseEntity linkedEntity) { 31 | this.linkedEntity = linkedEntity; 32 | } 33 | 34 | @LinkedResource 35 | public List getLinkedEntityCollection() { 36 | return linkedEntityCollection; 37 | } 38 | 39 | public void setLinkedEntityCollection(List linkedEntityCollection) { 40 | this.linkedEntityCollection = linkedEntityCollection; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/annotation/LinkedResource.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.annotation; 17 | 18 | import java.lang.annotation.ElementType; 19 | import java.lang.annotation.Retention; 20 | import java.lang.annotation.RetentionPolicy; 21 | import java.lang.annotation.Target; 22 | 23 | /** 24 | * Annotation to mark a property as a linked rather than inline association. 25 | * 26 | * @author Ryan Pickett 27 | * 28 | */ 29 | @Retention(RetentionPolicy.RUNTIME) 30 | @Target(ElementType.METHOD) 31 | public @interface LinkedResource { 32 | 33 | String rel() default ""; 34 | 35 | boolean optionalLink() default false; 36 | } 37 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/CustomRelEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.model; 17 | 18 | import org.springframework.data.rest.core.annotation.RestResource; 19 | 20 | import jakarta.persistence.Entity; 21 | import jakarta.persistence.GeneratedValue; 22 | import jakarta.persistence.GenerationType; 23 | import jakarta.persistence.Id; 24 | import jakarta.persistence.ManyToOne; 25 | 26 | @Entity 27 | public class CustomRelEntity { 28 | 29 | @Id 30 | @GeneratedValue(strategy = GenerationType.IDENTITY) 31 | private Integer id; 32 | 33 | @ManyToOne 34 | @RestResource(rel = "a:b") 35 | private SimpleEntity related; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/RestTemplateConfigurer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import org.springframework.web.client.RestTemplate; 19 | 20 | /** 21 | * Interface supporting the configuration of the Spring {@link org.springframework.web.client.RestTemplate} 22 | * used internally by the {@link Client}. 23 | * 24 | * @author Ryan Pickett 25 | * 26 | */ 27 | public interface RestTemplateConfigurer { 28 | 29 | /** 30 | * Apply some further configuration to the RestTemplate following its initialisation. 31 | * 32 | * @param restTemplate the RestTemplate to configure 33 | */ 34 | void configure(RestTemplate restTemplate); 35 | } 36 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/annotation/RemoteResource.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.annotation; 17 | 18 | import java.lang.annotation.ElementType; 19 | import java.lang.annotation.Retention; 20 | import java.lang.annotation.RetentionPolicy; 21 | import java.lang.annotation.Target; 22 | 23 | /** 24 | * Class-level annotation to define the path of an entity's collection resource. 25 | * 26 | * @author Ryan Pickett 27 | * 28 | */ 29 | @Retention(RetentionPolicy.RUNTIME) 30 | @Target(ElementType.TYPE) 31 | public @interface RemoteResource { 32 | 33 | /** 34 | * @return the collection resource path, relative to the client base URI. 35 | */ 36 | String value(); 37 | } 38 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/BidiParentEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.model; 17 | 18 | import java.util.Set; 19 | 20 | import jakarta.persistence.Entity; 21 | import jakarta.persistence.GeneratedValue; 22 | import jakarta.persistence.GenerationType; 23 | import jakarta.persistence.Id; 24 | import jakarta.persistence.OneToMany; 25 | 26 | @SuppressWarnings("unused") 27 | @Entity 28 | public class BidiParentEntity { 29 | 30 | @Id 31 | @GeneratedValue(strategy = GenerationType.IDENTITY) 32 | private Integer entityId; 33 | 34 | private String name; 35 | 36 | @OneToMany(mappedBy = "parent") 37 | private Set children; 38 | } 39 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/ResourceIdMethodHandlerTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.net.URI; 4 | 5 | import org.junit.Test; 6 | import org.springframework.hateoas.EntityModel; 7 | import org.springframework.hateoas.IanaLinkRelations; 8 | import org.springframework.hateoas.Link; 9 | import org.springframework.hateoas.Links; 10 | 11 | import static org.hamcrest.Matchers.is; 12 | import static org.hamcrest.Matchers.nullValue; 13 | import static org.junit.Assert.assertThat; 14 | 15 | public class ResourceIdMethodHandlerTest { 16 | 17 | @Test 18 | public void invokeWithResourceWithSelfLinkReturnsLinkUri() { 19 | EntityModel resource = EntityModel.of(new Object(), Links.of(Link.of("http://www.example.com/1", 20 | IanaLinkRelations.SELF))); 21 | 22 | Object result = new ResourceIdMethodHandler(resource).invoke(null, null, null, null); 23 | 24 | assertThat(result, is(URI.create("http://www.example.com/1"))); 25 | } 26 | 27 | @Test 28 | public void invokeWithResourceWithNoSelfLinkReturnsNull() { 29 | EntityModel resource = EntityModel.of(new Object(), Links.of(Link.of("http://www.example.com/1", 30 | "some-other-rel"))); 31 | 32 | Object result = new ResourceIdMethodHandler(resource).invoke(null, null, null, null); 33 | 34 | assertThat(result, is(nullValue())); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/DefaultPropertyValueFactory.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.LinkedHashSet; 6 | import java.util.List; 7 | import java.util.Set; 8 | import java.util.SortedSet; 9 | import java.util.TreeSet; 10 | 11 | import org.springframework.beans.BeanUtils; 12 | 13 | class DefaultPropertyValueFactory implements PropertyValueFactory { 14 | 15 | @Override 16 | public > T createCollection(Class collectionType) { 17 | Object collection = null; 18 | 19 | if (Collection.class.isAssignableFrom(collectionType) && !collectionType.isInterface()) { 20 | collection = BeanUtils.instantiateClass(collectionType); 21 | } 22 | else if (SortedSet.class.equals(collectionType)) { 23 | collection = new TreeSet<>(); 24 | } 25 | else if (Set.class.equals(collectionType)) { 26 | collection = new LinkedHashSet<>(); 27 | } 28 | else if (List.class.equals(collectionType) || Collection.class.equals(collectionType)) { 29 | collection = new ArrayList<>(); 30 | } 31 | else { 32 | throw new ClientProxyException(String.format("Unsupported Collection type: %s", collectionType.getName())); 33 | } 34 | 35 | @SuppressWarnings("unchecked") 36 | T result = (T) collection; 37 | 38 | return result; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/AbstractPropertyAwareMethodHandlerTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.beans.IntrospectionException; 4 | import java.lang.reflect.Method; 5 | 6 | import org.junit.Rule; 7 | import org.junit.Test; 8 | import org.junit.rules.ExpectedException; 9 | import org.springframework.hateoas.EntityModel; 10 | 11 | public class AbstractPropertyAwareMethodHandlerTest { 12 | 13 | private static class TestMethodHandler extends AbstractPropertyAwareMethodHandler { 14 | 15 | TestMethodHandler(EntityModel resource, BeanInfoProvider beanInfoProvider) { 16 | super(resource.getContent().getClass(), beanInfoProvider); 17 | } 18 | 19 | @Override 20 | public boolean supports(Method method) { 21 | return false; 22 | } 23 | 24 | @Override 25 | public Object invoke(Object o, Method method, Method method1, Object[] objects) { 26 | return null; 27 | } 28 | } 29 | 30 | private ExpectedException thrown = ExpectedException.none(); 31 | 32 | @Rule 33 | public ExpectedException getThrown() { 34 | return thrown; 35 | } 36 | 37 | @Test 38 | public void constructorOnIntrospectionExceptionThrowsException() { 39 | thrown.expect(ClientProxyException.class); 40 | 41 | new TestMethodHandler(EntityModel.of(new Object()), (clazz) -> { 42 | throw new IntrospectionException("x"); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/repository/SimpleEntityRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.repository; 17 | 18 | import java.util.List; 19 | 20 | import org.springframework.data.repository.CrudRepository; 21 | import org.springframework.data.repository.query.Param; 22 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 23 | 24 | import uk.co.blackpepper.bowman.test.server.model.SimpleEntity; 25 | 26 | @RepositoryRestResource(path = "simple-entities") 27 | public interface SimpleEntityRepository extends CrudRepository { 28 | 29 | SimpleEntity findByName(@Param("name") String name); 30 | 31 | List findByNameContaining(@Param("query") String query); 32 | } 33 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/InlineBidiChildEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.model; 17 | 18 | import com.fasterxml.jackson.annotation.JsonBackReference; 19 | 20 | import jakarta.persistence.Entity; 21 | import jakarta.persistence.GeneratedValue; 22 | import jakarta.persistence.GenerationType; 23 | import jakarta.persistence.Id; 24 | import jakarta.persistence.ManyToOne; 25 | 26 | @SuppressWarnings("unused") 27 | @Entity 28 | public class InlineBidiChildEntity { 29 | 30 | @Id 31 | @GeneratedValue(strategy = GenerationType.IDENTITY) 32 | private Integer id; 33 | 34 | @ManyToOne 35 | @JsonBackReference 36 | private InlineBidiParentEntity parent; 37 | 38 | private String name; 39 | 40 | @ManyToOne 41 | private SimpleEntity related; 42 | } 43 | -------------------------------------------------------------------------------- /deploy/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 21 | 22 | 4.0.0 23 | 24 | 25 | me.hdpe.bowman 26 | bowman-parent 27 | 0.11.1-SNAPSHOT 28 | 29 | 30 | bowman-deploy 31 | pom 32 | 33 | 34 | 35 | ${project.groupId} 36 | bowman-test-it 37 | ${project.version} 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/DefaultObjectMapperFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import org.springframework.hateoas.mediatype.hal.Jackson2HalModule; 19 | 20 | import com.fasterxml.jackson.databind.DeserializationFeature; 21 | import com.fasterxml.jackson.databind.ObjectMapper; 22 | import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; 23 | 24 | class DefaultObjectMapperFactory implements ObjectMapperFactory { 25 | 26 | @Override 27 | public ObjectMapper create(HandlerInstantiator instantiator) { 28 | ObjectMapper mapper = new ObjectMapper(); 29 | mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 30 | mapper.registerModule(new Jackson2HalModule()); 31 | mapper.registerModule(new JacksonClientModule()); 32 | mapper.setHandlerInstantiator(instantiator); 33 | return mapper; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/MethodLinkUriResolverTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.net.URI; 4 | 5 | import org.junit.Rule; 6 | import org.junit.Test; 7 | import org.junit.rules.ExpectedException; 8 | import org.springframework.hateoas.EntityModel; 9 | import org.springframework.hateoas.Link; 10 | import org.springframework.hateoas.Links; 11 | 12 | import static org.hamcrest.Matchers.hasProperty; 13 | import static org.hamcrest.Matchers.is; 14 | import static org.junit.Assert.assertThat; 15 | 16 | public class MethodLinkUriResolverTest { 17 | 18 | @Rule 19 | public ExpectedException thrown = ExpectedException.none(); 20 | 21 | @Test 22 | public void resolveForMethodWithNoMatchingLinkThrowsException() { 23 | EntityModel resource = EntityModel.of(new Object(), Links.of(Link.of("http://www.example.com", 24 | "other"))); 25 | 26 | thrown.expect(NoSuchLinkException.class); 27 | thrown.expect(hasProperty("linkName", is("link1"))); 28 | 29 | new MethodLinkUriResolver().resolveForMethod(resource, "link1", new Object[0]); 30 | } 31 | 32 | @Test 33 | public void resolveForMethodReturnsUriWithParamsExpanded() { 34 | EntityModel resource = EntityModel.of(new Object(), 35 | Links.of(Link.of("http://www.example.com/{?x,y}", 36 | "link1"))); 37 | 38 | URI uri = new MethodLinkUriResolver().resolveForMethod(resource, "link1", new Object[] {"1", 2}); 39 | 40 | assertThat(uri, is(URI.create("http://www.example.com/?x=1&y=2"))); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/CustomRelEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it.model; 17 | 18 | import java.net.URI; 19 | 20 | import com.fasterxml.jackson.annotation.JsonIgnore; 21 | 22 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 23 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 24 | import uk.co.blackpepper.bowman.annotation.ResourceId; 25 | 26 | @RemoteResource("custom-rel-entities") 27 | public class CustomRelEntity { 28 | 29 | private URI id; 30 | 31 | private SimpleEntity related; 32 | 33 | @ResourceId 34 | @JsonIgnore 35 | public URI getId() { 36 | return id; 37 | } 38 | 39 | @LinkedResource(rel = "a:b") 40 | public SimpleEntity getRelated() { 41 | return related; 42 | } 43 | 44 | public void setRelated(SimpleEntity related) { 45 | this.related = related; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/OptionalLinksEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.net.URI; 4 | import java.util.List; 5 | 6 | import com.fasterxml.jackson.annotation.JsonIgnore; 7 | 8 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 9 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 10 | import uk.co.blackpepper.bowman.annotation.ResourceId; 11 | 12 | @RemoteResource("/optional-links-entities") 13 | public class OptionalLinksEntity { 14 | 15 | private URI id; 16 | 17 | private String name; 18 | 19 | private SimpleEntity optionalLinkItem; 20 | 21 | private List optionalLinkCollection; 22 | 23 | @ResourceId 24 | @JsonIgnore 25 | public URI getId() { 26 | return id; 27 | } 28 | 29 | public String getName() { 30 | return name; 31 | } 32 | 33 | public void setName(String name) { 34 | this.name = name; 35 | } 36 | 37 | @LinkedResource(optionalLink = true) 38 | public SimpleEntity getOptionalLinkItem() { 39 | return optionalLinkItem; 40 | } 41 | 42 | public void setOptionalLinkItem(SimpleEntity optionalLinkItem) { 43 | this.optionalLinkItem = optionalLinkItem; 44 | } 45 | 46 | @LinkedResource(optionalLink = true) 47 | public List getOptionalLinkCollection() { 48 | return optionalLinkCollection; 49 | } 50 | 51 | public void setOptionalLinkCollection(List optionalLinkCollection) { 52 | this.optionalLinkCollection = optionalLinkCollection; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/OptionalLinksQueryEntity.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it.model; 2 | 3 | import java.net.URI; 4 | import java.util.List; 5 | 6 | import com.fasterxml.jackson.annotation.JsonIgnore; 7 | 8 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 9 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 10 | import uk.co.blackpepper.bowman.annotation.ResourceId; 11 | 12 | @RemoteResource("/optional-links-entities-query") 13 | public class OptionalLinksQueryEntity { 14 | 15 | private URI id; 16 | 17 | private String name; 18 | 19 | private SimpleEntity optionalLinkItem; 20 | 21 | private List optionalLinkCollection; 22 | 23 | @ResourceId 24 | @JsonIgnore 25 | public URI getId() { 26 | return id; 27 | } 28 | 29 | public String getName() { 30 | return name; 31 | } 32 | 33 | public void setName(String name) { 34 | this.name = name; 35 | } 36 | 37 | @LinkedResource(optionalLink = true) 38 | public SimpleEntity getOptionalLinkItem() { 39 | return optionalLinkItem; 40 | } 41 | 42 | public void setOptionalLinkItem(SimpleEntity optionalLinkItem) { 43 | this.optionalLinkItem = optionalLinkItem; 44 | } 45 | 46 | @LinkedResource(optionalLink = true) 47 | public List getOptionalLinkCollection() { 48 | return optionalLinkCollection; 49 | } 50 | 51 | public void setOptionalLinkCollection(List optionalLinkCollection) { 52 | this.optionalLinkCollection = optionalLinkCollection; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/controller/OptionalLinksEntitiesAbsentLinksController.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.server.controller; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.hateoas.EntityModel; 5 | import org.springframework.hateoas.Link; 6 | import org.springframework.hateoas.Links; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.server.ResponseStatusException; 13 | 14 | import uk.co.blackpepper.bowman.test.server.model.OptionalLinksEntity; 15 | import uk.co.blackpepper.bowman.test.server.repository.OptionalLinksEntityRepository; 16 | 17 | @RestController 18 | @RequestMapping("/optional-links-entities-query") 19 | public class OptionalLinksEntitiesAbsentLinksController { 20 | 21 | @Autowired 22 | private OptionalLinksEntityRepository repository; 23 | 24 | @GetMapping("/{id}") 25 | public EntityModel get(@PathVariable("id") Integer id) { 26 | OptionalLinksEntity entity = repository.findById(id) 27 | .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); 28 | 29 | return EntityModel.of(entity, Links.of(Link.of( 30 | String.format("http://localhost:8080/optional-links-entities/%s", id)))); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/NullLinkedCollectionIT.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it; 2 | 3 | import java.net.URI; 4 | 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | 8 | import com.google.common.collect.Sets; 9 | 10 | import uk.co.blackpepper.bowman.Client; 11 | import uk.co.blackpepper.bowman.test.it.model.NullLinkedCollectionEntity; 12 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 13 | 14 | import static org.hamcrest.Matchers.contains; 15 | import static org.hamcrest.Matchers.hasProperty; 16 | import static org.hamcrest.Matchers.is; 17 | import static org.junit.Assert.assertThat; 18 | 19 | public class NullLinkedCollectionIT extends AbstractIT { 20 | 21 | private Client client; 22 | 23 | private Client simpleEntityClient; 24 | 25 | @Before 26 | public void setUp() { 27 | client = clientFactory.create(NullLinkedCollectionEntity.class); 28 | simpleEntityClient = clientFactory.create(SimpleEntity.class); 29 | } 30 | 31 | @Test 32 | public void canGetInitiallyNullLinkedCollection() { 33 | SimpleEntity linked = new SimpleEntity(); 34 | URI linkedLocation = simpleEntityClient.post(linked); 35 | 36 | NullLinkedCollectionEntity entity = new NullLinkedCollectionEntity(); 37 | entity.setLinked(Sets.newHashSet(linked)); 38 | client.post(entity); 39 | 40 | NullLinkedCollectionEntity retrieved = client.get(entity.getId()); 41 | 42 | assertThat(retrieved.getLinked(), contains(hasProperty("id", is(linkedLocation)))); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/DefaultRestTemplateFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import org.springframework.http.client.ClientHttpRequestFactory; 19 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; 20 | import org.springframework.web.client.RestTemplate; 21 | 22 | import com.fasterxml.jackson.databind.ObjectMapper; 23 | 24 | class DefaultRestTemplateFactory implements RestTemplateFactory { 25 | 26 | @Override 27 | public RestTemplate create(ClientHttpRequestFactory clientHttpRequestFactory, ObjectMapper objectMapper) { 28 | RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory); 29 | 30 | restTemplate.getMessageConverters().add(0, new MappingJackson2HttpMessageConverter(objectMapper)); 31 | restTemplate.getInterceptors().add(new JsonClientHttpRequestInterceptor()); 32 | 33 | return restTemplate; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/annotation/ResourceTypeInfo.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.annotation; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import uk.co.blackpepper.bowman.TypeResolver; 9 | 10 | /** 11 | * Class-level annotation to define the narrowing polymorphic deserialization strategy for 12 | * properties (or property collection items) of this type. 13 | *

14 | * Only one of {@link #subtypes} or {@link #typeResolver} should be specified. 15 | * 16 | * @author Ryan Pickett 17 | */ 18 | @Retention(RetentionPolicy.RUNTIME) 19 | @Target(ElementType.TYPE) 20 | public @interface ResourceTypeInfo { 21 | 22 | interface NullTypeResolver extends TypeResolver { 23 | // no members 24 | } 25 | 26 | /** 27 | * The subtypes to consider in polymorphic deserialization of properties involving this type. On 28 | * deserialization, the final type of the property (or property collection item) will be determined 29 | * by the self link of the resource. 30 | * 31 | * @return the subtypes to consider in deserialization 32 | */ 33 | Class[] subtypes() default {}; 34 | 35 | /** 36 | * A custom narrowing type resolution strategy to use in polymorphic deserialization of properties 37 | * (or property collection items) of this type. 38 | * 39 | * @return the type resolver to use to determine the final type 40 | */ 41 | Class typeResolver() default NullTypeResolver.class; 42 | } 43 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/model/InlineBidiParentEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server.model; 17 | 18 | import java.util.Set; 19 | 20 | import com.fasterxml.jackson.annotation.JsonManagedReference; 21 | 22 | import jakarta.persistence.CascadeType; 23 | import jakarta.persistence.Entity; 24 | import jakarta.persistence.GeneratedValue; 25 | import jakarta.persistence.GenerationType; 26 | import jakarta.persistence.Id; 27 | import jakarta.persistence.OneToMany; 28 | import jakarta.persistence.OneToOne; 29 | 30 | @SuppressWarnings("unused") 31 | @Entity 32 | public class InlineBidiParentEntity { 33 | 34 | @Id 35 | @GeneratedValue(strategy = GenerationType.IDENTITY) 36 | private Integer id; 37 | 38 | private String name; 39 | 40 | @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) 41 | @JsonManagedReference 42 | private Set children; 43 | 44 | @OneToOne(cascade = CascadeType.ALL) 45 | private InlineBidiChildEntity child; 46 | } 47 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/SimpleEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it.model; 17 | 18 | import java.net.URI; 19 | 20 | import com.fasterxml.jackson.annotation.JsonIgnore; 21 | 22 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 23 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 24 | import uk.co.blackpepper.bowman.annotation.ResourceId; 25 | 26 | @RemoteResource("/simple-entities") 27 | public class SimpleEntity { 28 | 29 | private URI id; 30 | 31 | private String name; 32 | 33 | private SimpleEntity related; 34 | 35 | @ResourceId 36 | @JsonIgnore 37 | public URI getId() { 38 | return id; 39 | } 40 | 41 | public String getName() { 42 | return name; 43 | } 44 | 45 | public void setName(String name) { 46 | this.name = name; 47 | } 48 | 49 | @LinkedResource 50 | public SimpleEntity getRelated() { 51 | return related; 52 | } 53 | 54 | public void setRelated(SimpleEntity related) { 55 | this.related = related; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/BidiChildEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it.model; 17 | 18 | import java.net.URI; 19 | 20 | import com.fasterxml.jackson.annotation.JsonIgnore; 21 | 22 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 23 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 24 | import uk.co.blackpepper.bowman.annotation.ResourceId; 25 | 26 | @RemoteResource("/bidi-children") 27 | public class BidiChildEntity { 28 | 29 | private URI id; 30 | 31 | private BidiParentEntity parent; 32 | 33 | private String name; 34 | 35 | @ResourceId 36 | @JsonIgnore 37 | public URI getId() { 38 | return id; 39 | } 40 | 41 | @LinkedResource 42 | public BidiParentEntity getParent() { 43 | return parent; 44 | } 45 | 46 | public void setParent(BidiParentEntity parent) { 47 | this.parent = parent; 48 | } 49 | 50 | public String getName() { 51 | return name; 52 | } 53 | 54 | public void setName(String name) { 55 | this.name = name; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/BidiParentEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it.model; 17 | 18 | import java.net.URI; 19 | import java.util.LinkedHashSet; 20 | import java.util.Set; 21 | 22 | import com.fasterxml.jackson.annotation.JsonIgnore; 23 | 24 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 25 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 26 | import uk.co.blackpepper.bowman.annotation.ResourceId; 27 | 28 | @RemoteResource("/bidi-parents") 29 | public class BidiParentEntity { 30 | 31 | private URI entityId; 32 | 33 | private String name; 34 | 35 | private Set children = new LinkedHashSet<>(); 36 | 37 | @ResourceId 38 | @JsonIgnore 39 | public URI getEntityId() { 40 | return entityId; 41 | } 42 | 43 | public String getName() { 44 | return name; 45 | } 46 | 47 | public void setName(String name) { 48 | this.name = name; 49 | } 50 | 51 | @LinkedResource 52 | public Set getChildren() { 53 | return children; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: read 11 | packages: write 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up JDK 21 16 | uses: actions/setup-java@v3 17 | with: 18 | java-version: '21' 19 | distribution: 'temurin' 20 | 21 | - name: Build with Maven 22 | env: 23 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 24 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} 25 | run: | 26 | if [[ "$GITHUB_REF" =~ ^refs/tags/ ]]; then 27 | PHASES="install site" 28 | elif [[ "$GITHUB_REF" = "refs/heads/main" ]]; then 29 | PHASES="deploy" 30 | else 31 | PHASES="verify" 32 | fi 33 | mvn -B -s config/settings.ci.xml $PHASES -PrunITs 34 | 35 | - name: Report code coverage 36 | env: 37 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 38 | run: mvn -B test jacoco:report coveralls:report -pl :bowman-client 39 | 40 | - uses: actions/upload-pages-artifact@v1 41 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 42 | with: 43 | path: client/target/generated-docs 44 | 45 | deploy-pages: 46 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 47 | 48 | needs: build 49 | 50 | permissions: 51 | pages: write 52 | id-token: write 53 | 54 | environment: 55 | name: github-pages 56 | url: ${{ steps.deployment.outputs.page_url }} 57 | 58 | runs-on: ubuntu-latest 59 | steps: 60 | - name: Deploy to GitHub Pages 61 | id: deployment 62 | uses: actions/deploy-pages@v2 63 | -------------------------------------------------------------------------------- /test/server/src/main/java/uk/co/blackpepper/bowman/test/server/Application.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.server; 17 | 18 | import org.springframework.boot.SpringApplication; 19 | import org.springframework.boot.autoconfigure.SpringBootApplication; 20 | import org.springframework.context.annotation.Bean; 21 | 22 | import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; 23 | import com.fasterxml.jackson.databind.ObjectMapper; 24 | 25 | @SpringBootApplication 26 | public class Application { 27 | 28 | public static void main(String[] args) { 29 | new Application().run(args); 30 | } 31 | 32 | void run(String[] args) { 33 | SpringApplication.run(Application.class, args); 34 | } 35 | 36 | @Bean 37 | public ObjectMapper getObjectMapper() { 38 | ObjectMapper objectMapper = new ObjectMapper(); 39 | objectMapper.setVisibility(objectMapper.getSerializationConfig() 40 | .getDefaultVisibilityChecker() 41 | .withFieldVisibility(Visibility.ANY) 42 | .withGetterVisibility(Visibility.NONE) 43 | .withIsGetterVisibility(Visibility.NONE)); 44 | return objectMapper; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/JsonClientHttpRequestInterceptor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.io.IOException; 19 | 20 | import org.springframework.hateoas.MediaTypes; 21 | import org.springframework.http.HttpRequest; 22 | import org.springframework.http.client.ClientHttpRequestExecution; 23 | import org.springframework.http.client.ClientHttpRequestInterceptor; 24 | import org.springframework.http.client.ClientHttpResponse; 25 | import org.springframework.http.client.support.HttpRequestWrapper; 26 | 27 | import static java.util.Arrays.asList; 28 | 29 | class JsonClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { 30 | 31 | @Override 32 | public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) 33 | throws IOException { 34 | HttpRequestWrapper wrapped = new HttpRequestWrapper(request); 35 | wrapped.getHeaders().put("Content-Type", asList(MediaTypes.HAL_JSON_VALUE)); 36 | wrapped.getHeaders().put("Accept", asList(MediaTypes.HAL_JSON_VALUE)); 37 | return execution.execute(wrapped, body); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/AbstractPropertyAwareMethodHandler.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.beans.BeanInfo; 4 | import java.beans.IntrospectionException; 5 | import java.beans.Introspector; 6 | import java.beans.PropertyDescriptor; 7 | import java.lang.reflect.Method; 8 | import java.util.Arrays; 9 | 10 | import javassist.util.proxy.ProxyFactory; 11 | 12 | abstract class AbstractPropertyAwareMethodHandler implements ConditionalMethodHandler { 13 | 14 | interface BeanInfoProvider { 15 | BeanInfo getBeanInfo(Class clazz) throws IntrospectionException; 16 | } 17 | 18 | private final BeanInfo contentBeanInfo; 19 | 20 | AbstractPropertyAwareMethodHandler(Class clazz) { 21 | this(clazz, Introspector::getBeanInfo); 22 | } 23 | 24 | AbstractPropertyAwareMethodHandler(Class clazz, BeanInfoProvider beanInfoProvider) { 25 | try { 26 | contentBeanInfo = beanInfoProvider.getBeanInfo(getBeanType(clazz)); 27 | } 28 | catch (IntrospectionException exception) { 29 | throw new ClientProxyException(String.format("couldn't determine properties for %s", clazz.getName()), 30 | exception); 31 | } 32 | } 33 | 34 | boolean isSetter(Method method) { 35 | return Arrays.stream(getContentBeanInfo().getPropertyDescriptors()) 36 | .map(PropertyDescriptor::getWriteMethod) 37 | .anyMatch(method::equals); 38 | } 39 | 40 | boolean isGetter(Method method) { 41 | return Arrays.stream(getContentBeanInfo().getPropertyDescriptors()) 42 | .map(PropertyDescriptor::getReadMethod) 43 | .anyMatch(method::equals); 44 | } 45 | 46 | BeanInfo getContentBeanInfo() { 47 | return contentBeanInfo; 48 | } 49 | 50 | private static Class getBeanType(Class clazz) { 51 | if (!ProxyFactory.isProxyClass(clazz)) { 52 | return clazz; 53 | } 54 | 55 | return clazz.getSuperclass(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/InlineBidiChildEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it.model; 17 | 18 | import java.net.URI; 19 | 20 | import com.fasterxml.jackson.annotation.JsonIgnore; 21 | 22 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 23 | import uk.co.blackpepper.bowman.annotation.ResourceId; 24 | 25 | public class InlineBidiChildEntity { 26 | 27 | private URI id; 28 | 29 | private InlineBidiParentEntity parent; 30 | 31 | private String name; 32 | 33 | private SimpleEntity related; 34 | 35 | @ResourceId 36 | @JsonIgnore 37 | public URI getId() { 38 | return id; 39 | } 40 | 41 | @LinkedResource 42 | public InlineBidiParentEntity getParent() { 43 | return parent; 44 | } 45 | 46 | public void setParent(InlineBidiParentEntity parent) { 47 | this.parent = parent; 48 | } 49 | 50 | public String getName() { 51 | return name; 52 | } 53 | 54 | public void setName(String name) { 55 | this.name = name; 56 | } 57 | 58 | @LinkedResource 59 | public SimpleEntity getRelated() { 60 | return related; 61 | } 62 | 63 | public void setRelated(SimpleEntity related) { 64 | this.related = related; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/JsonClientHttpRequestInterceptorTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.io.IOException; 4 | 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | import org.mockito.ArgumentCaptor; 8 | import org.springframework.http.HttpHeaders; 9 | import org.springframework.http.HttpRequest; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.http.client.ClientHttpRequestExecution; 12 | 13 | import static org.hamcrest.MatcherAssert.assertThat; 14 | import static org.hamcrest.Matchers.contains; 15 | import static org.hamcrest.Matchers.is; 16 | import static org.mockito.AdditionalMatchers.aryEq; 17 | import static org.mockito.Mockito.mock; 18 | import static org.mockito.Mockito.verify; 19 | import static org.mockito.Mockito.when; 20 | 21 | public class JsonClientHttpRequestInterceptorTest { 22 | 23 | private JsonClientHttpRequestInterceptor interceptor; 24 | 25 | @Before 26 | public void setUp() { 27 | interceptor = new JsonClientHttpRequestInterceptor(); 28 | } 29 | 30 | @Test 31 | public void interceptSetsContentTypeAndAcceptHeaders() throws IOException { 32 | HttpRequest request = mock(HttpRequest.class); 33 | when(request.getHeaders()).thenReturn(new HttpHeaders()); 34 | 35 | ClientHttpRequestExecution execution = mock(ClientHttpRequestExecution.class); 36 | 37 | interceptor.intercept(request, new byte[] {1}, execution); 38 | 39 | ArgumentCaptor finalRequest = ArgumentCaptor.forClass(HttpRequest.class); 40 | verify(execution).execute(finalRequest.capture(), aryEq(new byte[] {1})); 41 | 42 | HttpHeaders finalHeaders = finalRequest.getValue().getHeaders(); 43 | assertThat(finalHeaders.getAccept(), contains(MediaType.valueOf("application/hal+json"))); 44 | assertThat(finalHeaders.getContentType(), is(MediaType.valueOf("application/hal+json"))); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/CustomRelIT.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it; 17 | 18 | import org.junit.Before; 19 | import org.junit.Test; 20 | 21 | import uk.co.blackpepper.bowman.Client; 22 | import uk.co.blackpepper.bowman.test.it.model.CustomRelEntity; 23 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 24 | 25 | import static org.hamcrest.Matchers.is; 26 | import static org.junit.Assert.assertThat; 27 | 28 | public class CustomRelIT extends AbstractIT { 29 | 30 | private Client client; 31 | 32 | private Client simpleEntityClient; 33 | 34 | @Before 35 | public void setup() { 36 | client = clientFactory.create(CustomRelEntity.class); 37 | simpleEntityClient = clientFactory.create(SimpleEntity.class); 38 | } 39 | 40 | @Test 41 | public void canGetCustomRelLinkedAssociation() { 42 | SimpleEntity related = new SimpleEntity(); 43 | related.setName("x"); 44 | simpleEntityClient.post(related); 45 | 46 | CustomRelEntity entity = new CustomRelEntity(); 47 | entity.setRelated(related); 48 | client.post(entity); 49 | 50 | entity = client.get(entity.getId()); 51 | 52 | assertThat(entity.getRelated().getName(), is("x")); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/SimpleEntitySearchIT.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it; 2 | 3 | import java.util.List; 4 | 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | 8 | import uk.co.blackpepper.bowman.Client; 9 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 10 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntitySearch; 11 | 12 | import static org.hamcrest.Matchers.is; 13 | import static org.junit.Assert.assertThat; 14 | 15 | public class SimpleEntitySearchIT extends AbstractIT { 16 | 17 | private Client entities; 18 | 19 | private Client search; 20 | 21 | @Before 22 | public void setup() { 23 | entities = clientFactory.create(SimpleEntity.class); 24 | search = clientFactory.create(SimpleEntitySearch.class); 25 | } 26 | 27 | @Test 28 | public void getByInterfaceTemplateLinkReturnsEntity() { 29 | SimpleEntity entity = new SimpleEntity(); 30 | entity.setName("x"); 31 | entities.post(entity); 32 | 33 | SimpleEntity found = search.get().findByName("x"); 34 | 35 | assertThat(found.getName(), is("x")); 36 | } 37 | 38 | @Test 39 | public void getByInterfaceTemplateLinkReturnsEntityWithProxiedProperties() { 40 | SimpleEntity related = new SimpleEntity(); 41 | related.setName("related"); 42 | entities.post(related); 43 | 44 | SimpleEntity entity = new SimpleEntity(); 45 | entity.setName("x"); 46 | entity.setRelated(related); 47 | entities.post(entity); 48 | 49 | SimpleEntity found = search.get().findByName("x").getRelated(); 50 | 51 | assertThat(found.getName(), is("related")); 52 | } 53 | 54 | @Test 55 | public void getByInterfaceCollectionValuedTemplateLinkReturnsEntities() { 56 | SimpleEntity entity = new SimpleEntity(); 57 | entity.setName("x"); 58 | entities.post(entity); 59 | 60 | List found = search.get().findByNameContaining("x"); 61 | 62 | assertThat(found.get(0).getName(), is("x")); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ClientFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | /** 19 | * Factory class for creating {@link Client}s. 20 | * 21 | *

ClientFactories are created via {@link Configuration#buildClientFactory()}. 22 | * 23 | * @author Ryan Pickett 24 | * 25 | */ 26 | public class ClientFactory { 27 | 28 | private final Configuration configuration; 29 | 30 | private final ClientProxyFactory proxyFactory; 31 | 32 | private final RestOperations restOperations; 33 | 34 | ClientFactory(Configuration configuration) { 35 | this(configuration, new JavassistClientProxyFactory()); 36 | } 37 | 38 | ClientFactory(Configuration configuration, ClientProxyFactory proxyFactory) { 39 | this.configuration = configuration; 40 | 41 | this.proxyFactory = proxyFactory; 42 | this.restOperations = new RestOperationsFactory(configuration, proxyFactory).create(); 43 | } 44 | 45 | /** 46 | * Create a Client for the given annotated entity type. 47 | * 48 | * @param the entity type of the required client 49 | * @param entityType the entity type of the required client 50 | * @return the created client 51 | */ 52 | public Client create(Class entityType) { 53 | return new Client<>(entityType, configuration, restOperations, proxyFactory); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 4.0.0 19 | 20 | 21 | me.hdpe.bowman 22 | bowman-parent 23 | 0.11.1-SNAPSHOT 24 | 25 | 26 | bowman-test-parent 27 | pom 28 | 29 | 30 | true 31 | 32 | 33 | 34 | server 35 | it 36 | 37 | 38 | 39 | 40 | 41 | 42 | com.bazaarvoice.maven.plugins 43 | process-exec-maven-plugin 44 | 0.7 45 | 46 | 47 | org.codehaus.mojo 48 | build-helper-maven-plugin 49 | 1.10 50 | 51 | 52 | org.springframework.boot 53 | spring-boot-maven-plugin 54 | ${spring-boot.version} 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/model/InlineBidiParentEntity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it.model; 17 | 18 | import java.net.URI; 19 | import java.util.LinkedHashSet; 20 | import java.util.Set; 21 | 22 | import com.fasterxml.jackson.annotation.JsonIgnore; 23 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 24 | 25 | import uk.co.blackpepper.bowman.InlineAssociationDeserializer; 26 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 27 | import uk.co.blackpepper.bowman.annotation.ResourceId; 28 | 29 | @RemoteResource("/inline-bidi-parents") 30 | public class InlineBidiParentEntity { 31 | 32 | private URI id; 33 | 34 | private String name; 35 | 36 | private Set children = new LinkedHashSet<>(); 37 | 38 | private InlineBidiChildEntity child; 39 | 40 | @ResourceId 41 | @JsonIgnore 42 | public URI getId() { 43 | return id; 44 | } 45 | 46 | public String getName() { 47 | return name; 48 | } 49 | 50 | public void setName(String name) { 51 | this.name = name; 52 | } 53 | 54 | @JsonDeserialize(using = InlineAssociationDeserializer.class) 55 | public InlineBidiChildEntity getChild() { 56 | return child; 57 | } 58 | 59 | public void setChild(InlineBidiChildEntity child) { 60 | this.child = child; 61 | } 62 | 63 | @JsonDeserialize(contentUsing = InlineAssociationDeserializer.class) 64 | public Set getChildren() { 65 | return children; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /config/settings.ci.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 21 | 26 | 27 | 28 | 29 | sonatype-nexus-snapshots 30 | ${env.OSSRH_USERNAME} 31 | ${env.OSSRH_PASSWORD} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ci 40 | 41 | 42 | sonatype-nexus-snapshots 43 | Sonatype Nexus Snapshot Repository 44 | https://s01.oss.sonatype.org/content/repositories/snapshots/ 45 | 46 | true 47 | 48 | 49 | 50 | 51 | 52 | sonatype-nexus-snapshots 53 | Sonatype Nexus Snapshot Repository 54 | https://s01.oss.sonatype.org/content/repositories/snapshots/ 55 | 56 | true 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ci 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ReflectionSupport.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.lang.reflect.Field; 19 | import java.lang.reflect.Method; 20 | import java.net.URI; 21 | 22 | import org.springframework.util.ReflectionUtils; 23 | 24 | import uk.co.blackpepper.bowman.annotation.ResourceId; 25 | 26 | final class ReflectionSupport { 27 | 28 | private static final Class ID_ACCESSOR_ANNOTATION = ResourceId.class; 29 | 30 | private ReflectionSupport() { 31 | } 32 | 33 | public static URI getId(Object object) { 34 | Method accessor = getIdAccessor(object.getClass()); 35 | return (URI) ReflectionUtils.invokeMethod(accessor, object); 36 | } 37 | 38 | public static void setId(Object value, URI uri) { 39 | Field idField = getIdField(value.getClass()); 40 | idField.setAccessible(true); 41 | ReflectionUtils.setField(idField, value, uri); 42 | } 43 | 44 | private static Method getIdAccessor(Class clazz) { 45 | for (Method method : ReflectionUtils.getAllDeclaredMethods(clazz)) { 46 | if (method.getAnnotation(ID_ACCESSOR_ANNOTATION) != null) { 47 | return method; 48 | } 49 | } 50 | 51 | throw new IllegalArgumentException(String.format("No @%s found for %s", 52 | ID_ACCESSOR_ANNOTATION.getSimpleName(), clazz.getName())); 53 | } 54 | 55 | private static Field getIdField(Class clazz) { 56 | Method idAccessor = getIdAccessor(clazz); 57 | return ReflectionUtils.findField(clazz, HalSupport.toLinkName(idAccessor.getName())); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/BidiIT.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it; 17 | 18 | import java.net.URI; 19 | 20 | import org.junit.Before; 21 | import org.junit.Test; 22 | 23 | import uk.co.blackpepper.bowman.Client; 24 | import uk.co.blackpepper.bowman.test.it.model.BidiChildEntity; 25 | import uk.co.blackpepper.bowman.test.it.model.BidiParentEntity; 26 | 27 | import static org.hamcrest.Matchers.is; 28 | import static org.junit.Assert.assertThat; 29 | 30 | public class BidiIT extends AbstractIT { 31 | 32 | private Client parentClient; 33 | 34 | private Client childClient; 35 | 36 | @Before 37 | public void setup() { 38 | parentClient = clientFactory.create(BidiParentEntity.class); 39 | childClient = clientFactory.create(BidiChildEntity.class); 40 | } 41 | 42 | @Test 43 | public void canGetParentAssociation() { 44 | BidiParentEntity parent = new BidiParentEntity(); 45 | parent.setName("x"); 46 | parentClient.post(parent); 47 | 48 | BidiChildEntity sent = new BidiChildEntity(); 49 | sent.setParent(parent); 50 | 51 | URI location = childClient.post(sent); 52 | 53 | BidiChildEntity retrieved = childClient.get(location); 54 | assertThat(retrieved.getParent().getName(), is("x")); 55 | } 56 | 57 | @Test 58 | public void canGetChildrenAssociation() { 59 | BidiParentEntity parent = new BidiParentEntity(); 60 | URI location = parentClient.post(parent); 61 | 62 | BidiChildEntity child = new BidiChildEntity(); 63 | child.setName("x"); 64 | child.setParent(parent); 65 | childClient.post(child); 66 | 67 | BidiParentEntity retrieved = parentClient.get(location); 68 | assertThat(retrieved.getChildren().iterator().next().getName(), is("x")); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/DefaultPropertyValueFactoryTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.HashSet; 6 | import java.util.LinkedHashSet; 7 | import java.util.List; 8 | import java.util.Queue; 9 | import java.util.Set; 10 | import java.util.SortedSet; 11 | import java.util.TreeSet; 12 | 13 | import org.junit.Before; 14 | import org.junit.Rule; 15 | import org.junit.Test; 16 | import org.junit.rules.ExpectedException; 17 | 18 | import static org.hamcrest.CoreMatchers.instanceOf; 19 | import static org.junit.Assert.assertThat; 20 | 21 | public class DefaultPropertyValueFactoryTest { 22 | 23 | private DefaultPropertyValueFactory factory; 24 | 25 | private ExpectedException thrown = ExpectedException.none(); 26 | 27 | @Rule 28 | public ExpectedException getThrown() { 29 | return thrown; 30 | } 31 | 32 | @Before 33 | public void setUp() { 34 | factory = new DefaultPropertyValueFactory(); 35 | } 36 | 37 | @Test 38 | public void createCollectionForCollectionReturnsArrayList() { 39 | assertThat(factory.createCollection(Collection.class), instanceOf(ArrayList.class)); 40 | } 41 | 42 | @Test 43 | public void createCollectionForListReturnsArrayList() { 44 | assertThat(factory.createCollection(List.class), instanceOf(ArrayList.class)); 45 | } 46 | 47 | @Test 48 | public void createCollectionForSortedSetReturnsTreeSet() { 49 | assertThat(factory.createCollection(SortedSet.class), instanceOf(TreeSet.class)); 50 | } 51 | 52 | @Test 53 | public void createCollectionForSetReturnsLinkedHashSet() { 54 | assertThat(factory.createCollection(Set.class), instanceOf(LinkedHashSet.class)); 55 | } 56 | 57 | @Test 58 | public void createCollectionForConcreteCollectionReturnsCollection() { 59 | assertThat(factory.createCollection(HashSet.class), instanceOf(HashSet.class)); 60 | } 61 | 62 | @Test 63 | public void createCollectionForUnknownCollectionThrowsException() { 64 | thrown.expect(ClientProxyException.class); 65 | thrown.expectMessage("Unsupported Collection type: java.util.Queue"); 66 | 67 | factory.createCollection(Queue.class); 68 | } 69 | 70 | @Test 71 | public void createCollectionForNonCollectionThrowsException() { 72 | thrown.expect(ClientProxyException.class); 73 | thrown.expectMessage("Unsupported Collection type: java.lang.Object"); 74 | 75 | factory.createCollection(Object.class); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/MethodLinkAttributesResolverTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.Method; 4 | 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | 8 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 9 | 10 | import static org.hamcrest.Matchers.is; 11 | import static org.junit.Assert.assertThat; 12 | 13 | public class MethodLinkAttributesResolverTest { 14 | 15 | private interface Content { 16 | 17 | @LinkedResource 18 | Object getNoRel(); 19 | 20 | @LinkedResource(rel = "custom") 21 | void withRel(); 22 | 23 | @LinkedResource(optionalLink = false) 24 | Object getNotOptional(); 25 | 26 | @LinkedResource(optionalLink = true) 27 | Object getOptional(); 28 | } 29 | 30 | private MethodLinkAttributesResolver resolver; 31 | 32 | @Before 33 | public void setUp() { 34 | resolver = new MethodLinkAttributesResolver(); 35 | } 36 | 37 | @Test 38 | public void resolveForMethodWithNoRelReturnsMethodNameAsLinkName() throws Exception { 39 | Method linked = Content.class.getMethod("getNoRel"); 40 | 41 | MethodLinkAttributes attribs = resolver.resolveForMethod(linked); 42 | 43 | assertThat(attribs.getLinkName(), is("noRel")); 44 | } 45 | 46 | @Test 47 | public void resolveForMethodWithRelReturnsRelAsLinkName() throws Exception { 48 | Method linked = Content.class.getMethod("withRel"); 49 | 50 | MethodLinkAttributes attribs = resolver.resolveForMethod(linked); 51 | 52 | assertThat(attribs.getLinkName(), is("custom")); 53 | } 54 | 55 | @Test 56 | public void resolveForMethodWithDefaultOptionalLinkReturnsOptionalIsFalse() throws Exception { 57 | Method linked = Content.class.getMethod("withRel"); 58 | 59 | MethodLinkAttributes attribs = resolver.resolveForMethod(linked); 60 | 61 | assertThat(attribs.isOptional(), is(false)); 62 | } 63 | 64 | @Test 65 | public void resolveForMethodWithOptionalLinkFalseReturnsOptionalIsFalse() throws Exception { 66 | Method linked = Content.class.getMethod("getNotOptional"); 67 | 68 | MethodLinkAttributes attribs = resolver.resolveForMethod(linked); 69 | 70 | assertThat(attribs.isOptional(), is(false)); 71 | } 72 | 73 | @Test 74 | public void resolveForMethodWithOptionalLinkTrueReturnsOptionalIsTrue() throws Exception { 75 | Method linked = Content.class.getMethod("getOptional"); 76 | 77 | MethodLinkAttributes attribs = resolver.resolveForMethod(linked); 78 | 79 | assertThat(attribs.isOptional(), is(true)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/SelfLinkTypeResolver.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.net.URI; 4 | import java.util.Optional; 5 | 6 | import org.springframework.core.annotation.AnnotationUtils; 7 | import org.springframework.hateoas.IanaLinkRelations; 8 | import org.springframework.hateoas.Link; 9 | import org.springframework.hateoas.Links; 10 | import org.springframework.web.util.UriComponentsBuilder; 11 | 12 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 13 | 14 | class SelfLinkTypeResolver implements TypeResolver { 15 | 16 | private Class[] subtypes; 17 | 18 | SelfLinkTypeResolver(Class[] subtypes) { 19 | this.subtypes = subtypes; 20 | } 21 | 22 | @Override 23 | public Class resolveType(Class declaredType, Links resourceLinks, Configuration configuration) { 24 | 25 | Optional self = resourceLinks.getLink(IanaLinkRelations.SELF); 26 | 27 | if (!self.isPresent()) { 28 | return declaredType; 29 | } 30 | 31 | for (Class candidateClass : subtypes) { 32 | RemoteResource candidateClassInfo = AnnotationUtils.findAnnotation(candidateClass, RemoteResource.class); 33 | 34 | if (candidateClassInfo == null) { 35 | throw new ClientProxyException(String.format("%s is not annotated with @%s", candidateClass.getName(), 36 | RemoteResource.class.getSimpleName())); 37 | } 38 | 39 | String resourcePath = candidateClassInfo.value(); 40 | 41 | String resourceBaseUriString = UriComponentsBuilder.fromUri(configuration.getBaseUri()) 42 | .path(resourcePath) 43 | .toUriString(); 44 | 45 | String selfLinkUriString = toAbsoluteUriString(self.get().getHref(), configuration.getBaseUri()); 46 | 47 | if (selfLinkUriString.startsWith(resourceBaseUriString + "/")) { 48 | if (!declaredType.isAssignableFrom(candidateClass)) { 49 | throw new ClientProxyException(String.format("%s is not a subtype of %s", candidateClass.getName(), 50 | declaredType.getName())); 51 | } 52 | 53 | @SuppressWarnings("unchecked") 54 | Class result = (Class) candidateClass; 55 | 56 | return result; 57 | } 58 | } 59 | 60 | return declaredType; 61 | } 62 | 63 | private static String toAbsoluteUriString(String uri, URI baseUri) { 64 | if (UriComponentsBuilder.fromUriString(uri).build().getHost() != null) { 65 | return uri; 66 | } 67 | 68 | return UriComponentsBuilder.fromUri(baseUri) 69 | .path(uri) 70 | .toUriString(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/server/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 4.0.0 19 | 20 | 21 | me.hdpe.bowman 22 | bowman-test-parent 23 | 0.11.1-SNAPSHOT 24 | 25 | 26 | bowman-test-server 27 | 28 | 29 | true 30 | 31 | 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-maven-plugin 37 | 38 | uk.co.blackpepper.bowman.test.server.Application 39 | 40 | 41 | 42 | 43 | repackage 44 | 45 | 46 | jar 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | org.springframework.boot 57 | spring-boot-starter-web 58 | 59 | 60 | org.springframework.boot 61 | spring-boot-starter-data-jpa 62 | 63 | 64 | org.springframework.boot 65 | spring-boot-starter-data-rest 66 | 67 | 68 | 69 | org.springframework.boot 70 | spring-boot-devtools 71 | true 72 | 73 | 74 | 75 | com.h2database 76 | h2 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/JavassistClientProxyFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.util.Arrays; 19 | 20 | import org.springframework.hateoas.EntityModel; 21 | 22 | import javassist.util.proxy.Proxy; 23 | import javassist.util.proxy.ProxyFactory; 24 | 25 | import static java.util.Arrays.asList; 26 | 27 | class JavassistClientProxyFactory implements ClientProxyFactory { 28 | 29 | @Override 30 | public T create(EntityModel resource, RestOperations restOperations) { 31 | @SuppressWarnings("unchecked") 32 | Class entityType = (Class) resource.getContent().getClass(); 33 | 34 | MethodHandlerChain handlerChain = new MethodHandlerChain(asList( 35 | new ResourceIdMethodHandler(resource), 36 | new LinkedResourceMethodHandler(resource, restOperations, this), 37 | new SimplePropertyMethodHandler<>(resource) 38 | )); 39 | 40 | return createProxyInstance(entityType, handlerChain); 41 | } 42 | 43 | private static T createProxyInstance(Class entityType, MethodHandlerChain handlerChain) { 44 | ProxyFactory factory = new ProxyFactory(); 45 | if (ProxyFactory.isProxyClass(entityType)) { 46 | factory.setInterfaces(getNonProxyInterfaces(entityType)); 47 | factory.setSuperclass(entityType.getSuperclass()); 48 | } 49 | else { 50 | factory.setSuperclass(entityType); 51 | } 52 | factory.setFilter(handlerChain); 53 | 54 | Class clazz = factory.createClass(); 55 | T proxy = instantiateClass(clazz); 56 | ((Proxy) proxy).setHandler(handlerChain); 57 | return proxy; 58 | } 59 | 60 | private static Class[] getNonProxyInterfaces(Class entityType) { 61 | return Arrays.stream(entityType.getInterfaces()) 62 | .filter(i -> !Proxy.class.isAssignableFrom(i)) 63 | .toArray(Class[]::new); 64 | } 65 | 66 | private static T instantiateClass(Class clazz) { 67 | try { 68 | @SuppressWarnings("unchecked") 69 | T proxy = (T) clazz.newInstance(); 70 | return proxy; 71 | } 72 | catch (Exception exception) { 73 | throw new ClientProxyException("couldn't create proxy instance of " + clazz, exception); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/SimplePropertyMethodHandlerTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | import org.springframework.hateoas.EntityModel; 6 | 7 | import static org.hamcrest.Matchers.is; 8 | import static org.junit.Assert.assertThat; 9 | import static org.springframework.util.ReflectionUtils.findMethod; 10 | 11 | public class SimplePropertyMethodHandlerTest { 12 | 13 | @SuppressWarnings("unused") 14 | private static class ResourceContent { 15 | 16 | public String getThing() { 17 | return null; 18 | } 19 | 20 | public void setThing(int i) { 21 | } 22 | 23 | public boolean isThing2() { 24 | return false; 25 | } 26 | 27 | public String getThing3(int param) { 28 | return null; 29 | } 30 | 31 | public void getThing4() { 32 | } 33 | 34 | public String notApplicable() { 35 | return null; 36 | } 37 | 38 | public void setThing(String value) { 39 | } 40 | 41 | public void setThing2(String value, int param) { 42 | } 43 | 44 | public String setThing3(String value) { 45 | return null; 46 | } 47 | } 48 | 49 | private SimplePropertyMethodHandler handler; 50 | 51 | @Before 52 | public void setUp() { 53 | handler = new SimplePropertyMethodHandler<>(EntityModel.of(new ResourceContent())); 54 | } 55 | 56 | @Test 57 | public void supportsWithGetterIsTrue() { 58 | assertThat(handler.supports(findMethod(ResourceContent.class, "getThing")), is(true)); 59 | } 60 | 61 | @Test 62 | public void supportsWithIsGetterIsTrue() { 63 | assertThat(handler.supports(findMethod(ResourceContent.class, "isThing2")), is(true)); 64 | } 65 | 66 | @Test 67 | public void supportsWithGetMethodWithParameterIsFalse() { 68 | assertThat(handler.supports(findMethod(ResourceContent.class, "getThing3", int.class)), is(false)); 69 | } 70 | 71 | @Test 72 | public void supportsWithGetMethodWithVoidReturnTypeIsFalse() { 73 | assertThat(handler.supports(findMethod(ResourceContent.class, "getThing4")), is(false)); 74 | } 75 | 76 | @Test 77 | public void supportsWithNonGetMethodIsFalse() { 78 | assertThat(handler.supports(findMethod(ResourceContent.class, "notApplicable")), is(false)); 79 | } 80 | 81 | @Test 82 | public void supportsWithSetterIsTrue() { 83 | assertThat(handler.supports(findMethod(ResourceContent.class, "setThing", String.class)), is(true)); 84 | } 85 | 86 | @Test 87 | public void supportsWithSetMethodWithParameterIsFalse() { 88 | assertThat(handler.supports(findMethod(ResourceContent.class, "setThing2", String.class, int.class)), 89 | is(false)); 90 | } 91 | 92 | @Test 93 | public void supportsWithSetMethodWithReturnTypeIsFalse() { 94 | assertThat(handler.supports(findMethod(ResourceContent.class, "setThing3", String.class)), is(false)); 95 | } 96 | 97 | @Test 98 | public void supportsWithNonSetMethodIsFalse() { 99 | assertThat(handler.supports(findMethod(ResourceContent.class, "notApplicable")), is(false)); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/ResourceDeserializer.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.io.IOException; 4 | import java.lang.reflect.Modifier; 5 | 6 | import org.springframework.hateoas.EntityModel; 7 | import org.springframework.hateoas.Links; 8 | import org.springframework.hateoas.RepresentationModel; 9 | 10 | import com.fasterxml.jackson.core.JsonParser; 11 | import com.fasterxml.jackson.databind.BeanProperty; 12 | import com.fasterxml.jackson.databind.DeserializationContext; 13 | import com.fasterxml.jackson.databind.JsonDeserializer; 14 | import com.fasterxml.jackson.databind.ObjectMapper; 15 | import com.fasterxml.jackson.databind.deser.ContextualDeserializer; 16 | import com.fasterxml.jackson.databind.deser.std.StdDeserializer; 17 | import com.fasterxml.jackson.databind.node.ObjectNode; 18 | 19 | import javassist.util.proxy.ProxyFactory; 20 | 21 | class ResourceDeserializer extends StdDeserializer> implements ContextualDeserializer { 22 | 23 | private static final long serialVersionUID = -7290132544264448620L; 24 | 25 | private TypeResolver typeResolver; 26 | 27 | private Configuration configuration; 28 | 29 | ResourceDeserializer(Class type, TypeResolver typeResolver, Configuration configuration) { 30 | super(type); 31 | this.typeResolver = typeResolver; 32 | this.configuration = configuration; 33 | } 34 | 35 | @Override 36 | public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { 37 | Class resourceContentType = ctxt.getContextualType().getBindings().getTypeParameters().get(0).getRawClass(); 38 | 39 | return new ResourceDeserializer(resourceContentType, typeResolver, configuration); 40 | } 41 | 42 | @Override 43 | public EntityModel deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { 44 | ObjectNode node = p.readValueAs(ObjectNode.class); 45 | 46 | ObjectMapper mapper = (ObjectMapper) p.getCodec(); 47 | 48 | RepresentationModel resource = mapper.convertValue(node, RepresentationModel.class); 49 | Links links = Links.of(resource.getLinks()); 50 | 51 | Object content = mapper.convertValue(node, getResourceDeserializationType(links)); 52 | return EntityModel.of(content, links); 53 | } 54 | 55 | TypeResolver getTypeResolver() { 56 | return typeResolver; 57 | } 58 | 59 | Configuration getConfiguration() { 60 | return configuration; 61 | } 62 | 63 | private Class getResourceDeserializationType(Links links) { 64 | Class resourceContentType = typeResolver.resolveType(handledType(), links, configuration); 65 | 66 | if (resourceContentType.isInterface()) { 67 | ProxyFactory factory = new ProxyFactory(); 68 | factory.setInterfaces(new Class[] {resourceContentType}); 69 | resourceContentType = factory.createClass(); 70 | } 71 | else if (Modifier.isAbstract(resourceContentType.getModifiers())) { 72 | ProxyFactory factory = new ProxyFactory(); 73 | factory.setSuperclass(resourceContentType); 74 | resourceContentType = factory.createClass(); 75 | } 76 | 77 | return resourceContentType; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/InlineBidiIT.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it; 17 | 18 | import java.net.URI; 19 | 20 | import org.junit.Before; 21 | import org.junit.Test; 22 | 23 | import uk.co.blackpepper.bowman.Client; 24 | import uk.co.blackpepper.bowman.test.it.model.InlineBidiChildEntity; 25 | import uk.co.blackpepper.bowman.test.it.model.InlineBidiParentEntity; 26 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 27 | 28 | import static org.hamcrest.Matchers.is; 29 | import static org.junit.Assert.assertThat; 30 | 31 | public class InlineBidiIT extends AbstractIT { 32 | 33 | private Client client; 34 | 35 | private Client simpleEntityClient; 36 | 37 | @Before 38 | public void setup() { 39 | client = clientFactory.create(InlineBidiParentEntity.class); 40 | simpleEntityClient = clientFactory.create(SimpleEntity.class); 41 | } 42 | 43 | @Test 44 | public void canGetChildAssociation() { 45 | SimpleEntity related = new SimpleEntity(); 46 | related.setName("related"); 47 | simpleEntityClient.post(related); 48 | 49 | InlineBidiParentEntity parent = new InlineBidiParentEntity(); 50 | 51 | InlineBidiChildEntity child = new InlineBidiChildEntity(); 52 | child.setName("x"); 53 | child.setRelated(related); 54 | 55 | parent.setChild(child); 56 | 57 | URI location = client.post(parent); 58 | 59 | InlineBidiParentEntity retrieved = client.get(location); 60 | 61 | InlineBidiChildEntity retrievedItem = retrieved.getChild(); 62 | assertThat(retrievedItem.getName(), is("x")); 63 | assertThat(retrievedItem.getRelated().getName(), is("related")); 64 | } 65 | 66 | @Test 67 | public void canGetChildrenAssociation() { 68 | SimpleEntity related = new SimpleEntity(); 69 | related.setName("related"); 70 | simpleEntityClient.post(related); 71 | 72 | InlineBidiParentEntity parent = new InlineBidiParentEntity(); 73 | 74 | InlineBidiChildEntity child = new InlineBidiChildEntity(); 75 | child.setName("x"); 76 | child.setRelated(related); 77 | 78 | parent.getChildren().add(child); 79 | 80 | URI location = client.post(parent); 81 | 82 | InlineBidiParentEntity retrieved = client.get(location); 83 | 84 | InlineBidiChildEntity retrievedItem = retrieved.getChildren().iterator().next(); 85 | assertThat(retrievedItem.getName(), is("x")); 86 | assertThat(retrievedItem.getRelated().getName(), is("related")); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/OptionalLinkIT.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it; 2 | 3 | import java.net.URI; 4 | import java.util.Collections; 5 | 6 | import org.junit.Before; 7 | import org.junit.Test; 8 | 9 | import uk.co.blackpepper.bowman.Client; 10 | import uk.co.blackpepper.bowman.test.it.model.OptionalLinksEntity; 11 | import uk.co.blackpepper.bowman.test.it.model.OptionalLinksQueryEntity; 12 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 13 | 14 | import static org.hamcrest.CoreMatchers.nullValue; 15 | import static org.hamcrest.Matchers.arrayContaining; 16 | import static org.hamcrest.Matchers.empty; 17 | import static org.hamcrest.Matchers.is; 18 | import static org.junit.Assert.assertThat; 19 | 20 | public class OptionalLinkIT extends AbstractIT { 21 | 22 | private Client client; 23 | 24 | private Client queryClient; 25 | 26 | private Client simpleEntityClient; 27 | 28 | @Before 29 | public void setup() { 30 | client = clientFactory.create(OptionalLinksEntity.class); 31 | queryClient = clientFactory.create(OptionalLinksQueryEntity.class); 32 | simpleEntityClient = clientFactory.create(SimpleEntity.class); 33 | } 34 | 35 | @Test 36 | public void canGetOptionalItem() { 37 | OptionalLinksEntity sent = new OptionalLinksEntity(); 38 | client.post(sent); 39 | 40 | OptionalLinksQueryEntity retrieved = queryClient.get(getQueryUri(sent)); 41 | 42 | assertThat(retrieved.getOptionalLinkItem(), is(nullValue())); 43 | } 44 | 45 | @Test 46 | public void canGetOptionalCollection() { 47 | OptionalLinksEntity sent = new OptionalLinksEntity(); 48 | client.post(sent); 49 | 50 | OptionalLinksQueryEntity retrieved = queryClient.get(getQueryUri(sent)); 51 | 52 | assertThat(retrieved.getOptionalLinkCollection(), is(empty())); 53 | } 54 | 55 | @Test 56 | public void optionalItemCanHaveValue() { 57 | SimpleEntity related = new SimpleEntity(); 58 | related.setName("x"); 59 | simpleEntityClient.post(related); 60 | 61 | OptionalLinksEntity sent = new OptionalLinksEntity(); 62 | sent.setOptionalLinkItem(related); 63 | URI location = client.post(sent); 64 | 65 | OptionalLinksEntity retrieved = client.get(location); 66 | 67 | assertThat(retrieved.getOptionalLinkItem().getName(), is("x")); 68 | } 69 | 70 | @Test 71 | public void optionalCollectionCanHaveValues() { 72 | SimpleEntity related = new SimpleEntity(); 73 | related.setName("x"); 74 | simpleEntityClient.post(related); 75 | 76 | OptionalLinksEntity sent = new OptionalLinksEntity(); 77 | sent.setOptionalLinkCollection(Collections.singletonList(related)); 78 | URI location = client.post(sent); 79 | 80 | OptionalLinksEntity retrieved = client.get(location); 81 | 82 | assertThat(retrieved.getOptionalLinkCollection().stream().map(SimpleEntity::getName).toArray(), 83 | is(arrayContaining("x"))); 84 | } 85 | 86 | private static URI getQueryUri(OptionalLinksEntity entity) { 87 | return URI.create(entity.getId().toString() 88 | .replace("optional-links-entities", "optional-links-entities-query")); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/PagingIT.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it; 17 | 18 | import java.net.URI; 19 | 20 | import org.junit.Before; 21 | import org.junit.Ignore; 22 | import org.junit.Test; 23 | import org.springframework.web.util.UriComponentsBuilder; 24 | 25 | import uk.co.blackpepper.bowman.Client; 26 | import uk.co.blackpepper.bowman.test.it.model.PageableEntity; 27 | import uk.co.blackpepper.bowman.test.it.model.PageableEntityResultPage; 28 | 29 | import static org.hamcrest.Matchers.is; 30 | import static org.junit.Assert.assertThat; 31 | 32 | public class PagingIT extends AbstractIT { 33 | 34 | private Client entityClient; 35 | 36 | private Client pageClient; 37 | 38 | @Before 39 | public void setup() { 40 | entityClient = clientFactory.create(PageableEntity.class); 41 | pageClient = clientFactory.create(PageableEntityResultPage.class); 42 | } 43 | 44 | @Test 45 | public void canPageEntities() { 46 | for (int i = 1; i <= 3; i++) { 47 | PageableEntity sent = new PageableEntity(); 48 | sent.setName(String.valueOf(i)); 49 | 50 | entityClient.post(sent); 51 | } 52 | 53 | URI firstPageUri = UriComponentsBuilder.fromUri(baseUri) 54 | .path("/pageable-entities") 55 | .query("page=0&size=1") 56 | .build().toUri(); 57 | 58 | PageableEntityResultPage page1 = pageClient.get(firstPageUri); 59 | 60 | assertThat(page1.getContent().get(0).getName(), is("1")); 61 | 62 | PageableEntityResultPage page2 = page1.getNext(); 63 | assertThat(page2.getContent().get(0).getName(), is("2")); 64 | 65 | PageableEntityResultPage page3 = page2.getNext(); 66 | assertThat(page3.getContent().get(0).getName(), is("3")); 67 | } 68 | 69 | @Test 70 | @Ignore("https://github.com/spring-projects/spring-hateoas/issues/725") 71 | public void canGetLinkedResourceFromPage() { 72 | PageableEntity related = new PageableEntity(); 73 | related.setName("related"); 74 | entityClient.post(related); 75 | 76 | PageableEntity entity = new PageableEntity(); 77 | entity.setLinked(related); 78 | entityClient.post(entity); 79 | 80 | URI firstPageUri = UriComponentsBuilder.fromUri(baseUri) 81 | .path("/pageable-entities") 82 | .query("page=0&size=1") 83 | .build().toUri(); 84 | 85 | PageableEntityResultPage page = pageClient.get(firstPageUri); 86 | 87 | // entity will be on the second page 88 | assertThat(page.getNext().getContent().get(0).getLinked().getName(), is("related")); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /client/src/main/asciidoc/020-getting-started.adoc: -------------------------------------------------------------------------------- 1 | == Getting Started 2 | 3 | === Add to Your Project 4 | 5 | Add the Maven dependency: 6 | 7 | [source,xml] 8 | [subs="+attributes"] 9 | 10 | me.hdpe.bowman 11 | bowman-client 12 | {project-version} 13 | 14 | 15 | === Usage Example 16 | 17 | Given the following annotated model objects: 18 | 19 | [source,java] 20 | ---- 21 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 22 | import uk.co.blackpepper.bowman.annotation.ResourceId; 23 | 24 | @RemoteResource("/people") 25 | public class Person { 26 | 27 | private URI id; 28 | private String name; 29 | 30 | public Person() {} 31 | public Person(String name) { this.name = name; } 32 | 33 | @ResourceId public URI getId() { return id; } 34 | public String getName() { return name; } 35 | } 36 | ---- 37 | 38 | and 39 | 40 | [source,java] 41 | ---- 42 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 43 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 44 | import uk.co.blackpepper.bowman.annotation.ResourceId; 45 | 46 | @RemoteResource("/greetings") 47 | public class Greeting { 48 | 49 | private URI id; 50 | private Person recipient; 51 | private String message; 52 | 53 | public Greeting() {} 54 | public Greeting(String message, Person recipient) 55 | { this.message = message; this.recipient = recipient; } 56 | 57 | @ResourceId public URI getId() { return id; } 58 | @LinkedResource public Person getRecipient() { return recipient; } 59 | public String getMessage() { return message; } 60 | } 61 | ---- 62 | 63 | Client instances can be constructed and used as demonstrated below. 64 | 65 | TIP: The HTTP requests/responses corresponding to each instruction are shown in a comment 66 | beneath. 67 | 68 | [source,java] 69 | ---- 70 | import uk.co.blackpepper.bowman.Client; 71 | import uk.co.blackpepper.bowman.ClientFactory; 72 | import uk.co.blackpepper.bowman.Configuration; 73 | 74 | ... 75 | 76 | ClientFactory factory = Configuration.builder().setBaseUri("http://...").build() 77 | .buildClientFactory(); 78 | 79 | Client people = factory.create(Person.class); 80 | Client greetings = factory.create(Greeting.class); 81 | 82 | URI id = people.post(new Person("Bob")); 83 | // POST /people {"name": "Bob"} 84 | // -> Location: http://.../people/1 85 | 86 | Person recipient = people.get(id); 87 | // GET /people/1 88 | // -> {"name": "Bob", "_links": {"self": {"href": "http://.../people/1"}}} 89 | 90 | assertThat(recipient.getName(), is("Bob")); 91 | 92 | id = greetings.post(new Greeting("hello", recipient)); 93 | // POST /greetings {"message": "hello", "recipient": "http://.../people/1"}} 94 | // -> Location: http://.../greetings/1 95 | 96 | Greeting greeting = greetings.get(id); 97 | // GET /greetings/1 98 | // -> {"message": "hello", "_links": {"self": {"href": "http://.../greetings/1"}, 99 | // "recipient": {"href": "http://.../people/1"}}} 100 | 101 | assertThat(greeting.getMessage(), is("hello")); 102 | 103 | recipient = greeting.getRecipient(); 104 | // GET /people/1 105 | // -> {"name": "Bob", "_links": {"self": {"href": {"http://.../people/1"}}} 106 | 107 | assertThat(recipient.getName(), is("Bob")); 108 | ---- 109 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/InlineAssociationDeserializer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.io.IOException; 19 | 20 | import org.springframework.hateoas.EntityModel; 21 | 22 | import com.fasterxml.jackson.core.JsonParser; 23 | import com.fasterxml.jackson.core.JsonProcessingException; 24 | import com.fasterxml.jackson.databind.BeanProperty; 25 | import com.fasterxml.jackson.databind.DeserializationContext; 26 | import com.fasterxml.jackson.databind.JavaType; 27 | import com.fasterxml.jackson.databind.JsonDeserializer; 28 | import com.fasterxml.jackson.databind.JsonMappingException; 29 | import com.fasterxml.jackson.databind.deser.ContextualDeserializer; 30 | import com.fasterxml.jackson.databind.deser.std.StdDeserializer; 31 | 32 | /** 33 | * A Jackson deserializer to properly handle inline associations in an annotated entity type. A proxy 34 | * will be created for the annotated property, allowing the resolution of further linked associations. 35 | * 36 | *

Assign this deserializer to a property with 37 | * @JsonDeserialize(contentUsing = InlineAssociationDeserializer.class). 38 | * 39 | * @param the type or a supertype of the type that this deserializer is intended for - not needed by 40 | * client code 41 | * 42 | * @author Ryan Pickett 43 | * 44 | */ 45 | public class InlineAssociationDeserializer extends StdDeserializer implements ContextualDeserializer { 46 | 47 | private static final long serialVersionUID = -8694505834979017488L; 48 | 49 | private Class type; 50 | 51 | private RestOperations restOperations; 52 | 53 | private ClientProxyFactory proxyFactory; 54 | 55 | InlineAssociationDeserializer(Class type, RestOperations restOperations, 56 | ClientProxyFactory proxyFactory) { 57 | super(type); 58 | 59 | this.type = type; 60 | this.restOperations = restOperations; 61 | this.proxyFactory = proxyFactory; 62 | } 63 | 64 | @Override 65 | public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { 66 | JavaType resourceType = ctxt.getTypeFactory().constructParametricType(EntityModel.class, type); 67 | 68 | EntityModel resource = p.getCodec().readValue(p, resourceType); 69 | 70 | return proxyFactory.create(resource, restOperations); 71 | } 72 | 73 | @Override 74 | public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) 75 | throws JsonMappingException { 76 | return new InlineAssociationDeserializer<>(ctxt.getContextualType().getRawClass(), restOperations, 77 | proxyFactory); 78 | } 79 | 80 | RestOperations getRestOperations() { 81 | return restOperations; 82 | } 83 | 84 | ClientProxyFactory getProxyFactory() { 85 | return proxyFactory; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /client/src/main/asciidoc/030-api-usage.adoc: -------------------------------------------------------------------------------- 1 | == API Usage 2 | 3 | Using Bowman requires: 4 | 5 | . An annotated client model 6 | . A configured `ClientFactory` 7 | . A `Client` created from that factory 8 | 9 | A `Client` corresponds to a specific entity type; you will create one `Client` for each entity type that requires _direct_ (without following HAL links) retrieval or manipulation. 10 | 11 | === Factory Instantiation 12 | 13 | `ClientFactory` instances are created through `Configuration.builder().getClientFactory()`. 14 | 15 | ``Client``s are then created from the factory using `ClientFactory.create(clazz)`. 16 | 17 | TIP: Both `ClientFactory` and `Client` instances maintain no mutable state and may safely be shared across threads. 18 | 19 | === Factory Configuration 20 | 21 | ==== Base URI 22 | 23 | Set the base URI for the API with `setBaseUri`. Entities' base resources, specified by `@RemoteResource`, will be resolved relative to this. 24 | 25 | [source,java] 26 | ClientFactory factory = Configuration.builder() 27 | .setBaseUri("http://www.example.com/my-api-root") 28 | .build(); 29 | 30 | This is often all the ClientFactory configuration you need. 31 | 32 | ==== Framework Components 33 | 34 | Bowman uses Spring's `RestTemplate` and Jackson's `ObjectMapper` under the hood. It may often be necessary to customise these, to support custom serialisation, add authentication headers to the request etc. 35 | 36 | [source,java] 37 | ---- 38 | ClientFactory factory = Configuration.builder() 39 | .setBaseUri(...) 40 | .setRestTemplateConfigurer(...) <1> 41 | .setObjectMapperConfigurer(...) <2> 42 | .setClientHttpRequestFactory(...) <3> 43 | .build(); 44 | ---- 45 | <1> Provide custom configuration of the `RestTemplate` via an implementation of `uk.co.blackpepper.bowman.RestTemplateConfigurer` 46 | <2> Provide custom configuration of the `ObjectMapper` via an implementation of `uk.co.blackpepper.bowman.ObjectMapperConfigurer` 47 | <3> Provide an implementation of `org.springframework.http.client.ClientHttpRequestFactory`. The default `HttpComponentsClientHttpRequestFactory` is often sufficient, but you may wish to provide a buffering request factory to allow logging of the response body in an interceptor prior to deserialisation. 48 | 49 | WARNING: Provided implementations of `ClientHttpRequestFactory` *must* throw an `org.springframework.web.client.HttpClientErrorException` when an HTTP 404 is returned accessing the remote resource. 50 | 51 | === Client Instantiation 52 | 53 | Then from your `ClientFactory` you can create a `Client` of the desired type. The _base resource_ of the ``Client``'s API is then determined by its `@RemoteResource` annotation. 54 | 55 | [source,java] 56 | Client customers = factory.create(Customer.class); 57 | 58 | === Client API Methods 59 | 60 | Clients support: 61 | 62 | * `get()` - GET the single entity from the base resource 63 | * `get(URI id)` - GET the entity with the given ID 64 | * `getAll()` - GET a collection of entities from the base resource 65 | * `getAll(URI location)` - GET a collection of entities from the given resource 66 | * `post(T object)` - POST the entity to the base resource 67 | * `put(T object)` - PUT the entity to its resource 68 | * `patch(URI id, P patch)` - PATCH the entity with the given ID with a set of changes 69 | * `delete(URI id)` - DELETE the entity with the given ID 70 | 71 | WARNING: PUT/PATCH are supported with caveats: there is currently a whole category of Spring Data REST limitations interacting via PUT/PATCH with JPA repositories due to attempts to replace persistent collections and state merge occurring outside of a transaction. 72 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/MethodHandlerChainTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import java.lang.reflect.Method; 4 | 5 | import org.junit.Rule; 6 | import org.junit.Test; 7 | import org.junit.rules.ExpectedException; 8 | 9 | import static java.util.Arrays.asList; 10 | import static java.util.Collections.emptyList; 11 | import static java.util.Collections.singletonList; 12 | 13 | import static org.hamcrest.Matchers.arrayContaining; 14 | import static org.hamcrest.Matchers.is; 15 | import static org.junit.Assert.assertThat; 16 | import static org.mockito.ArgumentMatchers.eq; 17 | import static org.mockito.Mockito.mock; 18 | import static org.mockito.Mockito.verify; 19 | import static org.mockito.Mockito.when; 20 | import static org.mockito.hamcrest.MockitoHamcrest.argThat; 21 | 22 | public class MethodHandlerChainTest { 23 | 24 | private interface ResourceContent { 25 | 26 | Object method1(); 27 | 28 | Object method2(); 29 | } 30 | 31 | private ExpectedException thrown = ExpectedException.none(); 32 | 33 | @Rule 34 | public ExpectedException getThrown() { 35 | return thrown; 36 | } 37 | 38 | // CHECKSTYLE:OFF 39 | 40 | @Test 41 | public void invokeInvokesFirstSupportedDelegate() throws Throwable { 42 | 43 | // CHECKSTYLE:ON 44 | 45 | Method method = ResourceContent.class.getMethod("method1"); 46 | 47 | ConditionalMethodHandler expectedHandler = newHandlerSupportedFor(method); 48 | 49 | MethodHandlerChain chain = new MethodHandlerChain(asList( 50 | newHandlerUnsupportedFor(method), 51 | expectedHandler, 52 | newHandlerSupportedFor(method) 53 | )); 54 | 55 | Method proceedMethod = ResourceContent.class.getMethod("method2"); 56 | Object self = new Object(); 57 | Object arg = new Object(); 58 | 59 | chain.invoke(self, method, proceedMethod, new Object[] {arg}); 60 | 61 | verify(expectedHandler).invoke(eq(self), eq(method), eq(proceedMethod), argThat(arrayContaining(arg))); 62 | } 63 | 64 | // CHECKSTYLE:OFF 65 | 66 | @Test 67 | public void invokeWithNoSupportedDelegateThrowsException() throws Throwable { 68 | 69 | // CHECKSTYLE:ON 70 | 71 | Method method = ResourceContent.class.getMethod("method1"); 72 | 73 | MethodHandlerChain chain = new MethodHandlerChain(emptyList()); 74 | 75 | thrown.expect(IllegalStateException.class); 76 | 77 | chain.invoke(new Object(), method, null, new Object[0]); 78 | } 79 | 80 | @Test 81 | public void isHandledWithNoDelegateSupportsIsFalse() throws Exception { 82 | Method method = ResourceContent.class.getMethod("method1"); 83 | 84 | MethodHandlerChain chain = new MethodHandlerChain(singletonList(newHandlerUnsupportedFor(method))); 85 | 86 | assertThat(chain.isHandled(method), is(false)); 87 | } 88 | 89 | @Test 90 | public void isHandledWithDelegateSupportsIsTrue() throws Exception { 91 | Method method = ResourceContent.class.getMethod("method1"); 92 | 93 | MethodHandlerChain chain = new MethodHandlerChain(singletonList(newHandlerSupportedFor(method))); 94 | 95 | assertThat(chain.isHandled(method), is(true)); 96 | } 97 | 98 | private ConditionalMethodHandler newHandlerSupportedFor(Method method) { 99 | ConditionalMethodHandler supported1 = mock(ConditionalMethodHandler.class); 100 | when(supported1.supports(method)).thenReturn(true); 101 | return supported1; 102 | } 103 | 104 | private ConditionalMethodHandler newHandlerUnsupportedFor(Method method) { 105 | ConditionalMethodHandler notSupported = mock(ConditionalMethodHandler.class); 106 | when(notSupported.supports(method)).thenReturn(false); 107 | return notSupported; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/InlineAssociationDeserializerTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.util.ArrayList; 19 | import java.util.List; 20 | 21 | import org.junit.Before; 22 | import org.junit.Test; 23 | import org.springframework.hateoas.EntityModel; 24 | 25 | import com.fasterxml.jackson.databind.DeserializationFeature; 26 | import com.fasterxml.jackson.databind.ObjectMapper; 27 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 28 | import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; 29 | 30 | import static org.hamcrest.Matchers.is; 31 | import static org.junit.Assert.assertThat; 32 | import static org.mockito.ArgumentMatchers.any; 33 | import static org.mockito.ArgumentMatchers.eq; 34 | import static org.mockito.Mockito.doReturn; 35 | import static org.mockito.Mockito.mock; 36 | 37 | public class InlineAssociationDeserializerTest { 38 | 39 | private static class SerializeParent { 40 | 41 | private List> children = new ArrayList<>(); 42 | 43 | @SuppressWarnings("unused") 44 | public List> getChildren() { 45 | return children; 46 | } 47 | } 48 | 49 | private static class DeserializeParent { 50 | 51 | private List children = new ArrayList<>(); 52 | 53 | @JsonDeserialize(contentUsing = InlineAssociationDeserializer.class) 54 | public List getChildren() { 55 | return children; 56 | } 57 | 58 | @SuppressWarnings("unused") 59 | public void setChildren(List children) { 60 | this.children = children; 61 | } 62 | } 63 | 64 | private static class Child { 65 | 66 | private String name; 67 | 68 | @SuppressWarnings("unused") 69 | Child() { 70 | } 71 | 72 | Child(String name) { 73 | this.name = name; 74 | } 75 | 76 | public String getName() { 77 | return name; 78 | } 79 | } 80 | 81 | private ObjectMapper mapper; 82 | 83 | private HandlerInstantiator instantiator; 84 | 85 | @Before 86 | public void setup() { 87 | RestOperations restOperations = mock(RestOperations.class); 88 | ClientProxyFactory proxyFactory = new JavassistClientProxyFactory(); 89 | 90 | instantiator = mock(HandlerInstantiator.class); 91 | 92 | doReturn(new InlineAssociationDeserializer<>(Object.class, restOperations, proxyFactory)) 93 | .when(instantiator).deserializerInstance(any(), any(), eq(InlineAssociationDeserializer.class)); 94 | 95 | mapper = new ObjectMapper(); 96 | mapper.setHandlerInstantiator(instantiator); 97 | mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 98 | } 99 | 100 | @Test 101 | public void deserializeReturnsObject() throws Exception { 102 | SerializeParent out = new SerializeParent(); 103 | out.children.add(EntityModel.of(new Child("x"))); 104 | String json = mapper.writeValueAsString(out); 105 | 106 | DeserializeParent parent = mapper.readValue(json, DeserializeParent.class); 107 | 108 | assertThat(parent.getChildren().get(0).getName(), is("x")); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Bowman 2 | 3 | image:https://github.com/hdpe/bowman/actions/workflows/build.yml/badge.svg?branch=main[title=Build Status,link=https://github.com/hdpe/bowman/actions?query=branch%3Amain] 4 | image:https://coveralls.io/repos/github/hdpe/bowman/badge.svg?branch=main[title=Coverage Status,link=https://coveralls.io/github/hdpe/bowman?branch=main] 5 | image:https://img.shields.io/maven-central/v/me.hdpe.bowman/bowman-client.svg[title=Maven Central,link=https://search.maven.org/#search%7Cga%7C1%7Ca%3Abowman-client] 6 | 7 | Bowman is a Java library for accessing a http://stateless.co/hal_specification.html[JSON+HAL] REST API. 8 | 9 | == Documentation 10 | 11 | * https://hdpe.github.io/bowman/latest/reference/[Reference Documentation] 12 | * https://hdpe.github.io/bowman/latest/apidocs/[Javadoc] 13 | 14 | == Features 15 | 16 | * *Simplified API consumption* via automatic, *lazy link traversal* on an *annotated client-side model* 17 | * Tailor made for *https://projects.spring.io/spring-data-rest/[Spring Data REST]* 18 | * *Analogous interface* to JPA 19 | * *RESTful CRUD* and *templated link query* support 20 | * *Polymorphic* deserialisation 21 | 22 | Standing on the shoulders of http://projects.spring.io/spring-hateoas/[Spring HATEOAS] and https://github.com/FasterXML/jackson[Jackson]. 23 | 24 | == Usage Example 25 | 26 | Given the following annotated model objects: 27 | 28 | [source,java] 29 | ---- 30 | @RemoteResource("/people") 31 | public class Person { 32 | 33 | private URI id; 34 | private String name; 35 | 36 | public Person() {} 37 | public Person(String name) { this.name = name; } 38 | 39 | @ResourceId public URI getId() { return id; } 40 | public String getName() { return name; } 41 | } 42 | ---- 43 | 44 | and 45 | 46 | [source,java] 47 | ---- 48 | @RemoteResource("/greetings") 49 | public class Greeting { 50 | 51 | private URI id; 52 | private Person recipient; 53 | private String message; 54 | 55 | public Greeting() {} 56 | public Greeting(String message, Person recipient) 57 | { this.message = message; this.recipient = recipient; } 58 | 59 | @ResourceId public URI getId() { return id; } 60 | @LinkedResource public Person getRecipient() { return recipient; } 61 | public String getMessage() { return message; } 62 | } 63 | ---- 64 | 65 | Client instances can be constructed and used as demonstrated below. 66 | 67 | The HTTP requests/responses corresponding to each instruction are shown in a comment 68 | beneath. 69 | 70 | [source,java] 71 | ---- 72 | ClientFactory factory = Configuration.builder().setBaseUri("http://...").build() 73 | .buildClientFactory(); 74 | 75 | Client people = factory.create(Person.class); 76 | Client greetings = factory.create(Greeting.class); 77 | 78 | URI id = people.post(new Person("Bob")); 79 | // POST /people {"name": "Bob"} 80 | // -> Location: http://.../people/1 81 | 82 | Person recipient = people.get(id); 83 | // GET /people/1 84 | // -> {"name": "Bob", "_links": {"self": {"href": "http://.../people/1"}}} 85 | 86 | assertThat(recipient.getName(), is("Bob")); 87 | 88 | id = greetings.post(new Greeting("hello", recipient)); 89 | // POST /greetings {"message": "hello", "recipient": "http://.../people/1"}} 90 | // -> Location: http://.../greetings/1 91 | 92 | Greeting greeting = greetings.get(id); 93 | // GET /greetings/1 94 | // -> {"message": "hello", "_links": {"self": {"href": "http://.../greetings/1"}, 95 | // "recipient": {"href": "http://.../people/1"}}} 96 | 97 | assertThat(greeting.getMessage(), is("hello")); 98 | 99 | recipient = greeting.getRecipient(); 100 | // GET /people/1 101 | // -> {"name": "Bob", "_links": {"self": {"href": {"http://.../people/1"}}} 102 | 103 | assertThat(recipient.getName(), is("Bob")); 104 | ---- 105 | 106 | == Contributing 107 | 108 | * link:./development.adoc[Development Guide] 109 | 110 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/RestOperations.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.net.URI; 19 | import java.util.Collections; 20 | 21 | import org.springframework.hateoas.CollectionModel; 22 | import org.springframework.hateoas.EntityModel; 23 | import org.springframework.http.HttpStatus; 24 | import org.springframework.web.client.HttpClientErrorException; 25 | import org.springframework.web.client.RestTemplate; 26 | 27 | import com.fasterxml.jackson.databind.JavaType; 28 | import com.fasterxml.jackson.databind.ObjectMapper; 29 | import com.fasterxml.jackson.databind.node.ObjectNode; 30 | 31 | class RestOperations { 32 | 33 | private final RestTemplate restTemplate; 34 | 35 | private final ObjectMapper objectMapper; 36 | 37 | RestOperations(RestTemplate restTemplate, ObjectMapper objectMapper) { 38 | this.restTemplate = restTemplate; 39 | this.objectMapper = objectMapper; 40 | } 41 | 42 | public EntityModel getResource(URI uri, Class entityType) { 43 | ObjectNode node; 44 | 45 | try { 46 | node = restTemplate.getForObject(uri, ObjectNode.class); 47 | } 48 | catch (HttpClientErrorException exception) { 49 | if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { 50 | return null; 51 | } 52 | 53 | throw exception; 54 | } 55 | 56 | JavaType targetType = objectMapper.getTypeFactory().constructParametricType(EntityModel.class, entityType); 57 | 58 | return objectMapper.convertValue(node, targetType); 59 | } 60 | 61 | public CollectionModel> getResources(URI uri, Class entityType) { 62 | ObjectNode node; 63 | 64 | try { 65 | node = restTemplate.getForObject(uri, ObjectNode.class); 66 | } 67 | catch (HttpClientErrorException exception) { 68 | if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { 69 | return CollectionModel.wrap(Collections.emptyList()); 70 | } 71 | 72 | throw exception; 73 | } 74 | 75 | JavaType innerType = objectMapper.getTypeFactory().constructParametricType(EntityModel.class, entityType); 76 | JavaType targetType = objectMapper.getTypeFactory().constructParametricType(CollectionModel.class, innerType); 77 | 78 | return objectMapper.convertValue(node, targetType); 79 | } 80 | 81 | public URI postForId(URI uri, Object object) { 82 | return restTemplate.postForLocation(uri, object); 83 | } 84 | 85 | public void put(URI uri, Object object) { 86 | restTemplate.put(uri, object); 87 | } 88 | 89 | public void delete(URI uri) { 90 | restTemplate.delete(uri); 91 | } 92 | 93 | public EntityModel patchForResource(URI uri, Object patch, Class entityType) { 94 | ObjectNode node; 95 | 96 | node = restTemplate.patchForObject(uri, patch, ObjectNode.class); 97 | if (node == null) { 98 | return null; 99 | } 100 | 101 | JavaType targetType = objectMapper.getTypeFactory().constructParametricType(EntityModel.class, entityType); 102 | 103 | return objectMapper.convertValue(node, targetType); 104 | } 105 | 106 | RestTemplate getRestTemplate() { 107 | return restTemplate; 108 | } 109 | 110 | ObjectMapper getObjectMapper() { 111 | return objectMapper; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/JacksonClientModuleTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.net.URI; 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | 22 | import org.junit.Before; 23 | import org.junit.Test; 24 | 25 | import com.fasterxml.jackson.annotation.JsonIgnore; 26 | import com.fasterxml.jackson.databind.ObjectMapper; 27 | 28 | import javassist.util.proxy.MethodHandler; 29 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 30 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 31 | import uk.co.blackpepper.bowman.annotation.ResourceId; 32 | 33 | import static org.hamcrest.CoreMatchers.containsString; 34 | import static org.hamcrest.CoreMatchers.not; 35 | import static org.junit.Assert.assertThat; 36 | 37 | public class JacksonClientModuleTest { 38 | 39 | @RemoteResource("/entities") 40 | public static class Entity { 41 | 42 | private URI id; 43 | 44 | private String simple; 45 | 46 | private Entity linked; 47 | 48 | private List linkedCollection = new ArrayList<>(); 49 | 50 | Entity() { 51 | } 52 | 53 | Entity(String simple) { 54 | this.simple = simple; 55 | } 56 | 57 | Entity(URI id) { 58 | this.id = id; 59 | } 60 | 61 | Entity(Entity linked) { 62 | this.linked = linked; 63 | } 64 | 65 | @ResourceId 66 | @JsonIgnore 67 | public URI getId() { 68 | return id; 69 | } 70 | 71 | public String getSimple() { 72 | return simple; 73 | } 74 | 75 | @LinkedResource 76 | public Entity getLinked() { 77 | return linked; 78 | } 79 | 80 | @LinkedResource 81 | public List getLinkedCollection() { 82 | return linkedCollection; 83 | } 84 | } 85 | 86 | private ObjectMapper mapper; 87 | 88 | @Before 89 | public void setup() { 90 | mapper = new ObjectMapper(); 91 | 92 | mapper.registerModule(new JacksonClientModule()); 93 | } 94 | 95 | @Test 96 | public void aLinkedResourceIsSerializedAsAUri() throws Exception { 97 | String json = mapper.writeValueAsString(new Entity(new Entity(URI.create("http://www.example.com/1")))); 98 | 99 | assertThat(json, containsString("\"linked\":\"http://www.example.com/1\"")); 100 | } 101 | 102 | @Test 103 | public void linkedResourcesAreSerializedAsAUriArray() throws Exception { 104 | Entity entity = new Entity(); 105 | entity.getLinkedCollection().add(new Entity(URI.create("http://www.example.com/1"))); 106 | 107 | String json = mapper.writeValueAsString(entity); 108 | 109 | assertThat(json, containsString("\"linkedCollection\":[\"http://www.example.com/1\"]")); 110 | } 111 | 112 | @Test 113 | public void unannotatedPropertiesAreSerializedAsNormal() throws Exception { 114 | String json = mapper.writeValueAsString(new Entity("x")); 115 | 116 | assertThat(json, containsString("\"simple\":\"x\"")); 117 | } 118 | 119 | @Test 120 | public void handlerOnJavassistProxyIsNotSerialized() throws Exception { 121 | Entity proxy = new Entity("x") { 122 | public MethodHandler getHandler() { 123 | return null; 124 | } 125 | }; 126 | String json = mapper.writeValueAsString(proxy); 127 | 128 | assertThat(json, not(containsString("\"handler\""))); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/JacksonClientModule.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.io.IOException; 19 | import java.util.List; 20 | 21 | import org.springframework.hateoas.EntityModel; 22 | 23 | import com.fasterxml.jackson.annotation.JsonIgnoreType; 24 | import com.fasterxml.jackson.core.JsonGenerationException; 25 | import com.fasterxml.jackson.core.JsonGenerator; 26 | import com.fasterxml.jackson.databind.BeanDescription; 27 | import com.fasterxml.jackson.databind.SerializationConfig; 28 | import com.fasterxml.jackson.databind.SerializerProvider; 29 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 30 | import com.fasterxml.jackson.databind.module.SimpleModule; 31 | import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; 32 | import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; 33 | import com.fasterxml.jackson.databind.ser.std.StdSerializer; 34 | 35 | import javassist.util.proxy.MethodHandler; 36 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 37 | 38 | /** 39 | * A module for handling serialization of Bowman annotated classes. 40 | * 41 | *

Registering this module with an {@link com.fasterxml.jackson.databind.ObjectMapper} 42 | * will cause properties annotated with {@link LinkedResource} to be serialized as 43 | * URI strings (single-valued associations) or arrays of URI strings (collection-valued 44 | * associations), and properties of type {@link javassist.util.proxy.MethodHandler} to 45 | * not be serialized. 46 | * 47 | * @author Ryan Pickett 48 | * 49 | */ 50 | public class JacksonClientModule extends SimpleModule { 51 | 52 | private static final long serialVersionUID = 5622234359343391536L; 53 | 54 | @JsonDeserialize(using = ResourceDeserializer.class) 55 | abstract static class ResourceMixin { 56 | private ResourceMixin() { 57 | } 58 | } 59 | 60 | @JsonIgnoreType 61 | abstract static class MethodHandlerMixin { 62 | private MethodHandlerMixin() { 63 | } 64 | } 65 | 66 | private static class LinkedResourceUriSerializer extends StdSerializer { 67 | 68 | private static final long serialVersionUID = -5901774722661025524L; 69 | 70 | protected LinkedResourceUriSerializer() { 71 | super(Object.class); 72 | } 73 | 74 | @Override 75 | public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) 76 | throws IOException, JsonGenerationException { 77 | if (value instanceof Iterable) { 78 | jgen.writeStartArray(); 79 | for (Object child : (Iterable) value) { 80 | jgen.writeString(getEntityUri(child)); 81 | } 82 | jgen.writeEndArray(); 83 | } 84 | else { 85 | jgen.writeString(getEntityUri(value)); 86 | } 87 | } 88 | 89 | private static String getEntityUri(Object value) { 90 | return ReflectionSupport.getId(value).toString(); 91 | } 92 | } 93 | 94 | public JacksonClientModule() { 95 | setSerializerModifier(new BeanSerializerModifier() { 96 | 97 | @Override 98 | public List changeProperties(SerializationConfig config, BeanDescription beanDesc, 99 | List beanProperties) { 100 | 101 | for (BeanPropertyWriter writer : beanProperties) { 102 | if (writer.getAnnotation(LinkedResource.class) != null) { 103 | writer.assignSerializer(new LinkedResourceUriSerializer()); 104 | } 105 | } 106 | 107 | return beanProperties; 108 | } 109 | }); 110 | 111 | setMixInAnnotation(EntityModel.class, ResourceMixin.class); 112 | setMixInAnnotation(MethodHandler.class, MethodHandlerMixin.class); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/HierarchyIT.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman.test.it; 2 | 3 | import java.net.URI; 4 | import java.util.Arrays; 5 | 6 | import org.hamcrest.Matcher; 7 | import org.hamcrest.Matchers; 8 | import org.junit.Before; 9 | import org.junit.Test; 10 | 11 | import uk.co.blackpepper.bowman.Client; 12 | import uk.co.blackpepper.bowman.test.it.model.HierarchyBaseEntity; 13 | import uk.co.blackpepper.bowman.test.it.model.HierarchyDerivedEntity1; 14 | import uk.co.blackpepper.bowman.test.it.model.HierarchyDerivedEntity2; 15 | import uk.co.blackpepper.bowman.test.it.model.HierarchyPropertyEntity; 16 | 17 | import static java.util.Arrays.asList; 18 | 19 | import static org.hamcrest.Matchers.containsInAnyOrder; 20 | import static org.hamcrest.Matchers.hasProperty; 21 | import static org.hamcrest.Matchers.instanceOf; 22 | import static org.hamcrest.Matchers.is; 23 | import static org.junit.Assert.assertThat; 24 | 25 | public class HierarchyIT extends AbstractIT { 26 | 27 | private Client baseEntityClient; 28 | 29 | private Client derivedEntity1Client; 30 | 31 | private Client derivedEntity2Client; 32 | 33 | private Client propertyEntityClient; 34 | 35 | @Before 36 | public void setUp() { 37 | baseEntityClient = clientFactory.create(HierarchyBaseEntity.class); 38 | derivedEntity1Client = clientFactory.create(HierarchyDerivedEntity1.class); 39 | derivedEntity2Client = clientFactory.create(HierarchyDerivedEntity2.class); 40 | propertyEntityClient = clientFactory.create(HierarchyPropertyEntity.class); 41 | } 42 | 43 | @Test 44 | public void testGetAllWithSubtypes() { 45 | HierarchyDerivedEntity1 entity1 = new HierarchyDerivedEntity1(); 46 | entity1.setEntity1Field("x"); 47 | derivedEntity1Client.post(entity1); 48 | 49 | HierarchyDerivedEntity2 entity2 = new HierarchyDerivedEntity2(); 50 | entity2.setEntity2Field("y"); 51 | derivedEntity2Client.post(entity2); 52 | 53 | Iterable retrieved = baseEntityClient.getAll(); 54 | 55 | assertThat(retrieved, containsInAnyOrder(Arrays.>asList( 56 | Matchers.allOf( 57 | instanceOf(HierarchyDerivedEntity1.class), 58 | hasProperty("entity1Field", is("x")) 59 | ), 60 | Matchers.allOf( 61 | instanceOf(HierarchyDerivedEntity2.class), 62 | hasProperty("entity2Field", is("y")) 63 | ) 64 | ))); 65 | } 66 | 67 | @Test 68 | public void testLinkedEntityWithSubtypes() { 69 | HierarchyDerivedEntity1 entity1 = new HierarchyDerivedEntity1(); 70 | entity1.setEntity1Field("x"); 71 | derivedEntity1Client.post(entity1); 72 | 73 | HierarchyPropertyEntity propertyEntity = new HierarchyPropertyEntity(); 74 | propertyEntity.setLinkedEntity(entity1); 75 | URI propertyEntityUri = propertyEntityClient.post(propertyEntity); 76 | 77 | HierarchyPropertyEntity retrieved = propertyEntityClient.get(propertyEntityUri); 78 | 79 | assertThat(retrieved.getLinkedEntity(), Matchers.allOf( 80 | instanceOf(HierarchyDerivedEntity1.class), 81 | hasProperty("entity1Field", is("x")) 82 | )); 83 | } 84 | 85 | @Test 86 | public void testLinkedEntityCollectionWithSubtypes() { 87 | HierarchyDerivedEntity1 entity1 = new HierarchyDerivedEntity1(); 88 | entity1.setEntity1Field("x"); 89 | derivedEntity1Client.post(entity1); 90 | 91 | HierarchyDerivedEntity2 entity2 = new HierarchyDerivedEntity2(); 92 | entity2.setEntity2Field("y"); 93 | derivedEntity2Client.post(entity2); 94 | 95 | HierarchyPropertyEntity propertyEntity = new HierarchyPropertyEntity(); 96 | propertyEntity.setLinkedEntityCollection(asList(entity1, entity2)); 97 | URI propertyEntityUri = propertyEntityClient.post(propertyEntity); 98 | 99 | HierarchyPropertyEntity retrieved = propertyEntityClient.get(propertyEntityUri); 100 | 101 | assertThat(retrieved.getLinkedEntityCollection(), 102 | containsInAnyOrder(Arrays.>asList( 103 | Matchers.allOf( 104 | instanceOf(HierarchyDerivedEntity1.class), 105 | hasProperty("entity1Field", is("x")) 106 | ), 107 | Matchers.allOf( 108 | instanceOf(HierarchyDerivedEntity2.class), 109 | hasProperty("entity2Field", is("y")) 110 | ) 111 | ))); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 4.0.0 19 | 20 | 21 | me.hdpe.bowman 22 | bowman-parent 23 | 0.11.1-SNAPSHOT 24 | 25 | 26 | bowman-client 27 | 28 | 29 | 30 | 31 | org.eluder.coveralls 32 | coveralls-maven-plugin 33 | 34 | ${env.COVERALLS_TOKEN} 35 | 36 | 37 | 38 | 39 | javax.xml.bind 40 | jaxb-api 41 | 2.3.1 42 | 43 | 44 | 45 | 46 | 47 | org.asciidoctor 48 | asciidoctor-maven-plugin 49 | 50 | html5 51 | highlight.js 52 | 53 | ${project.version} 54 | ${maven.build.timestamp} 55 | ${project.organization.name} 56 | ${project.version} 57 | 58 | index.adoc 59 | 60 | 61 | 62 | output-latest-reference 63 | site 64 | 65 | process-asciidoc 66 | 67 | 68 | target/generated-docs/latest/reference 69 | 70 | 71 | 72 | 73 | 74 | 75 | maven-javadoc-plugin 76 | 77 | 78 | output-latest-apidocs 79 | site 80 | 81 | javadoc 82 | 83 | 84 | target/generated-docs/latest 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | org.springframework 95 | spring-web 96 | 97 | 98 | org.springframework.hateoas 99 | spring-hateoas 100 | 101 | 102 | 103 | com.fasterxml.jackson.core 104 | jackson-core 105 | 106 | 107 | com.fasterxml.jackson.core 108 | jackson-databind 109 | 110 | 111 | 112 | org.javassist 113 | javassist 114 | 115 | 116 | 117 | org.slf4j 118 | slf4j-api 119 | 120 | 121 | 122 | org.apache.httpcomponents.client5 123 | httpclient5 124 | 125 | 126 | 127 | org.springframework 128 | spring-test 129 | test 130 | 131 | 132 | junit 133 | junit 134 | test 135 | 136 | 137 | org.hamcrest 138 | hamcrest-library 139 | test 140 | 141 | 142 | org.mockito 143 | mockito-core 144 | test 145 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/AbstractIT.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it; 17 | 18 | import java.io.IOException; 19 | import java.net.URI; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | 23 | import org.apache.commons.io.IOUtils; 24 | import org.junit.After; 25 | import org.slf4j.Logger; 26 | import org.slf4j.LoggerFactory; 27 | import org.springframework.http.HttpMethod; 28 | import org.springframework.http.HttpRequest; 29 | import org.springframework.http.client.BufferingClientHttpRequestFactory; 30 | import org.springframework.http.client.ClientHttpRequestExecution; 31 | import org.springframework.http.client.ClientHttpRequestInterceptor; 32 | import org.springframework.http.client.ClientHttpResponse; 33 | import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 34 | import org.springframework.web.client.RestClientException; 35 | import org.springframework.web.client.RestTemplate; 36 | 37 | import com.google.common.collect.Lists; 38 | 39 | import uk.co.blackpepper.bowman.ClientFactory; 40 | import uk.co.blackpepper.bowman.Configuration; 41 | import uk.co.blackpepper.bowman.RestTemplateConfigurer; 42 | 43 | import static java.util.Arrays.asList; 44 | 45 | public class AbstractIT { 46 | 47 | private static class LoggingClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { 48 | 49 | private static final Logger LOG = LoggerFactory.getLogger(LoggingClientHttpRequestInterceptor.class); 50 | 51 | @Override 52 | public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) 53 | throws IOException { 54 | 55 | if (LOG.isTraceEnabled()) { 56 | LOG.trace(request.getMethod().name() + " " + request.getURI() + " : " 57 | + new String(body, "UTF-8")); 58 | } 59 | 60 | ClientHttpResponse response = execution.execute(request, body); 61 | 62 | if (LOG.isTraceEnabled()) { 63 | LOG.trace("response " + response.getStatusCode().value() + " : " 64 | + IOUtils.toString(response.getBody())); 65 | } 66 | 67 | return response; 68 | } 69 | } 70 | 71 | private static class CreatedEntityRecordingClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { 72 | 73 | private List createdEntities = new ArrayList<>(); 74 | 75 | @Override 76 | public ClientHttpResponse intercept(HttpRequest request, byte[] body, 77 | ClientHttpRequestExecution execution) throws IOException { 78 | 79 | ClientHttpResponse response = execution.execute(request, body); 80 | 81 | if (request.getMethod() == HttpMethod.POST) { 82 | createdEntities.add(response.getHeaders().getLocation()); 83 | } 84 | 85 | return response; 86 | } 87 | 88 | List getCreatedEntities() { 89 | return createdEntities; 90 | } 91 | } 92 | 93 | // CHECKSTYLE:OFF 94 | 95 | protected URI baseUri; 96 | 97 | protected ClientFactory clientFactory; 98 | 99 | // CHECKSTYLE:ON 100 | 101 | private CreatedEntityRecordingClientHttpRequestInterceptor createdEntityRecordingInterceptor = 102 | new CreatedEntityRecordingClientHttpRequestInterceptor(); 103 | 104 | protected AbstractIT() { 105 | baseUri = URI.create(System.getProperty("baseUrl", "http://localhost:8080")); 106 | 107 | clientFactory = Configuration.builder() 108 | .setBaseUri(baseUri) 109 | .setClientHttpRequestFactory(new BufferingClientHttpRequestFactory( 110 | new HttpComponentsClientHttpRequestFactory())) 111 | .setRestTemplateConfigurer(new RestTemplateConfigurer() { 112 | 113 | @Override 114 | public void configure(RestTemplate restTemplate) { 115 | restTemplate.getInterceptors().addAll(asList( 116 | new LoggingClientHttpRequestInterceptor(), 117 | createdEntityRecordingInterceptor 118 | )); 119 | } 120 | }) 121 | .build() 122 | .buildClientFactory(); 123 | } 124 | 125 | @After 126 | public void tearDown() { 127 | RestTemplate cleanUpRestTemplate = new RestTemplate(); 128 | 129 | for (URI createdEntity : Lists.reverse(createdEntityRecordingInterceptor.getCreatedEntities())) { 130 | try { 131 | cleanUpRestTemplate.delete(createdEntity); 132 | } 133 | catch (RestClientException exception) { 134 | // perhaps already deleted; continue 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /client/src/test/java/uk/co/blackpepper/bowman/ResourceDeserializerTest.java: -------------------------------------------------------------------------------- 1 | package uk.co.blackpepper.bowman; 2 | 3 | import org.hamcrest.Matchers; 4 | import org.junit.Before; 5 | import org.junit.Test; 6 | import org.springframework.hateoas.EntityModel; 7 | import org.springframework.hateoas.IanaLinkRelations; 8 | import org.springframework.hateoas.Link; 9 | import org.springframework.hateoas.Links; 10 | import org.springframework.hateoas.mediatype.hal.Jackson2HalModule; 11 | 12 | import com.fasterxml.jackson.core.type.TypeReference; 13 | import com.fasterxml.jackson.databind.DeserializationFeature; 14 | import com.fasterxml.jackson.databind.ObjectMapper; 15 | import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; 16 | import com.fasterxml.jackson.databind.module.SimpleModule; 17 | 18 | import uk.co.blackpepper.bowman.JacksonClientModule.ResourceMixin; 19 | 20 | import static org.hamcrest.Matchers.is; 21 | import static org.junit.Assert.assertThat; 22 | import static org.mockito.ArgumentMatchers.any; 23 | import static org.mockito.ArgumentMatchers.eq; 24 | import static org.mockito.Mockito.doReturn; 25 | import static org.mockito.Mockito.mock; 26 | import static org.mockito.Mockito.verify; 27 | 28 | public class ResourceDeserializerTest { 29 | 30 | private interface DeclaredType { 31 | // no members 32 | } 33 | 34 | private static class ResolvedType implements DeclaredType { 35 | 36 | private String field; 37 | 38 | public String getField() { 39 | return field; 40 | } 41 | } 42 | 43 | private abstract static class ResolvedAbstractClassType implements DeclaredType { 44 | 45 | ResolvedAbstractClassType() { 46 | // explicit constructor required for private class 47 | } 48 | 49 | abstract String findField(); 50 | } 51 | 52 | private interface ResolvedInterfaceType extends DeclaredType { 53 | 54 | String findField(); 55 | } 56 | 57 | @SuppressWarnings("serial") 58 | private static class TestModule extends SimpleModule { 59 | TestModule() { 60 | setMixInAnnotation(EntityModel.class, ResourceMixin.class); 61 | } 62 | } 63 | 64 | private ObjectMapper mapper; 65 | 66 | private HandlerInstantiator instantiator; 67 | 68 | private TypeResolver typeResolver; 69 | 70 | private Configuration configuration; 71 | 72 | @Before 73 | public void setup() { 74 | typeResolver = mock(TypeResolver.class); 75 | configuration = Configuration.build(); 76 | 77 | instantiator = mock(HandlerInstantiator.class); 78 | 79 | doReturn(new ResourceDeserializer(Object.class, typeResolver, configuration)) 80 | .when(instantiator).deserializerInstance(any(), any(), eq(ResourceDeserializer.class)); 81 | 82 | mapper = new ObjectMapper(); 83 | mapper.setHandlerInstantiator(instantiator); 84 | mapper.registerModule(new Jackson2HalModule()); 85 | mapper.registerModule(new TestModule()); 86 | mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 87 | 88 | doReturn(Object.class).when(typeResolver).resolveType(any(), any(), any()); 89 | } 90 | 91 | @Test 92 | public void deserializeResolvesType() throws Exception { 93 | mapper.readValue("{\"_links\":{\"self\":{\"href\":\"http://x.com/1\"}}}", 94 | new TypeReference>() { /* generic type reference */ }); 95 | 96 | verify(typeResolver).resolveType(DeclaredType.class, 97 | Links.of(Link.of("http://x.com/1", IanaLinkRelations.SELF)), configuration); 98 | } 99 | 100 | @Test 101 | public void deserializeReturnsObjectOfResolvedType() throws Exception { 102 | doReturn(ResolvedType.class).when(typeResolver).resolveType(any(), any(), any()); 103 | 104 | EntityModel resource = mapper.readValue("{\"field\":\"x\"}", 105 | new TypeReference>() { /* generic type reference */ }); 106 | 107 | assertThat("class", resource.getContent().getClass(), Matchers.>equalTo(ResolvedType.class)); 108 | assertThat("field", ((ResolvedType) resource.getContent()).getField(), is("x")); 109 | } 110 | 111 | @Test 112 | public void deserializeReturnsObjectOfResolvedInterfaceType() throws Exception { 113 | doReturn(ResolvedInterfaceType.class).when(typeResolver).resolveType(any(), any(), any()); 114 | 115 | EntityModel resource = mapper.readValue("{}", 116 | new TypeReference>() { /* generic type reference */ }); 117 | 118 | assertThat(ResolvedInterfaceType.class.isAssignableFrom(resource.getContent().getClass()), is(true)); 119 | } 120 | 121 | @Test 122 | public void deserializeReturnsObjectOfResolvedAbstractClassType() throws Exception { 123 | doReturn(ResolvedAbstractClassType.class).when(typeResolver).resolveType(any(), any(), any()); 124 | 125 | EntityModel resource = mapper.readValue("{}", 126 | new TypeReference>() { /* generic type reference */ }); 127 | 128 | assertThat(ResolvedAbstractClassType.class.isAssignableFrom(resource.getContent().getClass()), is(true)); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/RestOperationsFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.util.HashMap; 19 | import java.util.Map; 20 | 21 | import org.springframework.beans.BeanUtils; 22 | import org.springframework.web.client.RestTemplate; 23 | 24 | import com.fasterxml.jackson.databind.DeserializationConfig; 25 | import com.fasterxml.jackson.databind.JsonDeserializer; 26 | import com.fasterxml.jackson.databind.JsonSerializer; 27 | import com.fasterxml.jackson.databind.KeyDeserializer; 28 | import com.fasterxml.jackson.databind.ObjectMapper; 29 | import com.fasterxml.jackson.databind.SerializationConfig; 30 | import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; 31 | import com.fasterxml.jackson.databind.cfg.MapperConfig; 32 | import com.fasterxml.jackson.databind.introspect.Annotated; 33 | import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; 34 | import com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder; 35 | 36 | class RestOperationsFactory { 37 | 38 | private static class RestOperationsInstantiation extends HandlerInstantiator { 39 | 40 | private final RestOperations restOperations; 41 | 42 | private final Map, Object> handlerMap = new HashMap<>(); 43 | 44 | RestOperationsInstantiation(Configuration configuration, ClientProxyFactory proxyFactory, 45 | ObjectMapperFactory objectMapperFactory, RestTemplateFactory restTemplateFactory) { 46 | 47 | ObjectMapper objectMapper = objectMapperFactory.create(this); 48 | RestTemplate restTemplate = restTemplateFactory.create(configuration.getClientHttpRequestFactory(), 49 | objectMapper); 50 | 51 | if (configuration.getRestTemplateConfigurer() != null) { 52 | configuration.getRestTemplateConfigurer().configure(restTemplate); 53 | } 54 | 55 | if (configuration.getObjectMapperConfigurer() != null) { 56 | configuration.getObjectMapperConfigurer().configure(objectMapper); 57 | } 58 | 59 | restOperations = new RestOperations(restTemplate, objectMapper); 60 | 61 | handlerMap.put(ResourceDeserializer.class, 62 | new ResourceDeserializer(Object.class, new DefaultTypeResolver(), configuration)); 63 | 64 | handlerMap.put(InlineAssociationDeserializer.class, 65 | new InlineAssociationDeserializer<>(Object.class, restOperations, proxyFactory)); 66 | } 67 | 68 | public RestOperations getRestOperations() { 69 | return restOperations; 70 | } 71 | 72 | @Override 73 | public JsonDeserializer deserializerInstance(DeserializationConfig config, Annotated annotated, 74 | Class deserClass) { 75 | return (JsonDeserializer) findHandlerInstance(deserClass); 76 | } 77 | 78 | @Override 79 | public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated, 80 | Class keyDeserClass) { 81 | return (KeyDeserializer) findHandlerInstance(keyDeserClass); 82 | } 83 | 84 | @Override 85 | public JsonSerializer serializerInstance(SerializationConfig config, Annotated annotated, 86 | Class serClass) { 87 | return (JsonSerializer) findHandlerInstance(serClass); 88 | } 89 | 90 | @Override 91 | public TypeResolverBuilder typeResolverBuilderInstance(MapperConfig config, Annotated annotated, 92 | Class builderClass) { 93 | return (TypeResolverBuilder) findHandlerInstance(builderClass); 94 | } 95 | 96 | @Override 97 | public TypeIdResolver typeIdResolverInstance(MapperConfig config, Annotated annotated, 98 | Class resolverClass) { 99 | return (TypeIdResolver) findHandlerInstance(resolverClass); 100 | } 101 | 102 | private Object findHandlerInstance(Class clazz) { 103 | Object handler = handlerMap.get(clazz); 104 | return handler != null ? handler : BeanUtils.instantiateClass(clazz); 105 | } 106 | } 107 | 108 | private final Configuration configuration; 109 | 110 | private final ClientProxyFactory proxyFactory; 111 | 112 | private final ObjectMapperFactory objectMapperFactory; 113 | 114 | private final RestTemplateFactory restTemplateFactory; 115 | 116 | RestOperationsFactory(Configuration configuration, ClientProxyFactory proxyFactory) { 117 | this(configuration, proxyFactory, new DefaultObjectMapperFactory(), new DefaultRestTemplateFactory()); 118 | } 119 | 120 | RestOperationsFactory(Configuration configuration, ClientProxyFactory proxyFactory, 121 | ObjectMapperFactory objectMapperFactory, RestTemplateFactory restTemplateFactory) { 122 | this.configuration = configuration; 123 | this.proxyFactory = proxyFactory; 124 | this.objectMapperFactory = objectMapperFactory; 125 | this.restTemplateFactory = restTemplateFactory; 126 | } 127 | 128 | public RestOperations create() { 129 | return new RestOperationsInstantiation(configuration, proxyFactory, objectMapperFactory, restTemplateFactory) 130 | .getRestOperations(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /test/it/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 4.0.0 19 | 20 | 21 | me.hdpe.bowman 22 | bowman-test-parent 23 | 0.11.1-SNAPSHOT 24 | 25 | 26 | bowman-test-it 27 | 28 | 29 | true 30 | 31 | 32 | 33 | 34 | runITs 35 | 36 | 37 | 38 | maven-dependency-plugin 39 | 40 | 41 | copy-server-artifact 42 | 43 | copy 44 | 45 | pre-integration-test 46 | 47 | 48 | 49 | ${project.groupId} 50 | bowman-test-server 51 | ${project.version} 52 | jar 53 | ${project.build.directory} 54 | app.jar 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | org.codehaus.mojo 64 | build-helper-maven-plugin 65 | 66 | 67 | reserve-network-port 68 | 69 | reserve-network-port 70 | 71 | pre-integration-test 72 | 73 | 74 | tomcat.http.port 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | com.bazaarvoice.maven.plugins 83 | process-exec-maven-plugin 84 | 85 | 86 | start-server 87 | pre-integration-test 88 | 89 | start 90 | 91 | 92 | bowman-test-server 93 | false 94 | http://localhost:${tomcat.http.port} 95 | 96 | java 97 | -Dlogging.config=${project.basedir}/src/test/resources/logback-server.xml 98 | -jar 99 | ${project.build.directory}/app.jar 100 | --server.port=${tomcat.http.port} 101 | 102 | 103 | 104 | 105 | 106 | stop-server 107 | post-integration-test 108 | 109 | stop-all 110 | 111 | 112 | 113 | 114 | 115 | 116 | org.apache.maven.plugins 117 | maven-failsafe-plugin 118 | 119 | 120 | 121 | integration-test 122 | verify 123 | 124 | 125 | 126 | http://localhost:${tomcat.http.port} 127 | logback-test-quiet.xml 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | org.springframework.boot 141 | spring-boot-starter-logging 142 | 143 | 144 | 145 | ${project.groupId} 146 | bowman-client 147 | 148 | 149 | 150 | commons-io 151 | commons-io 152 | 153 | 154 | 155 | com.google.guava 156 | guava 157 | 158 | 159 | 160 | junit 161 | junit 162 | 163 | 164 | org.hamcrest 165 | hamcrest-library 166 | 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /test/it/src/test/java/uk/co/blackpepper/bowman/test/it/SimpleEntityIT.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman.test.it; 17 | 18 | import java.net.URI; 19 | 20 | import org.junit.Before; 21 | import org.junit.Test; 22 | 23 | import com.google.common.collect.Lists; 24 | 25 | import uk.co.blackpepper.bowman.Client; 26 | import uk.co.blackpepper.bowman.annotation.LinkedResource; 27 | import uk.co.blackpepper.bowman.test.it.model.SimpleEntity; 28 | 29 | import static org.hamcrest.CoreMatchers.equalTo; 30 | import static org.hamcrest.CoreMatchers.hasItem; 31 | import static org.hamcrest.CoreMatchers.nullValue; 32 | import static org.hamcrest.Matchers.hasProperty; 33 | import static org.hamcrest.Matchers.is; 34 | import static org.junit.Assert.assertThat; 35 | 36 | public class SimpleEntityIT extends AbstractIT { 37 | 38 | private static class SimpleEntityPatch { 39 | 40 | private SimpleEntity related; 41 | 42 | SimpleEntityPatch(SimpleEntity related) { 43 | this.related = related; 44 | } 45 | 46 | @LinkedResource 47 | public SimpleEntity getRelated() { 48 | return related; 49 | } 50 | } 51 | 52 | private Client client; 53 | 54 | @Before 55 | public void setup() { 56 | client = clientFactory.create(SimpleEntity.class); 57 | } 58 | 59 | @Test 60 | public void canGetEntityName() { 61 | SimpleEntity sent = new SimpleEntity(); 62 | sent.setName("x"); 63 | 64 | URI location = client.post(sent); 65 | 66 | SimpleEntity retrieved = client.get(location); 67 | assertThat(retrieved.getName(), is("x")); 68 | } 69 | 70 | @Test 71 | public void canGetEntityAssociation() { 72 | SimpleEntity related = new SimpleEntity(); 73 | related.setName("x"); 74 | client.post(related); 75 | 76 | SimpleEntity sent = new SimpleEntity(); 77 | sent.setRelated(related); 78 | 79 | URI location = client.post(sent); 80 | 81 | SimpleEntity retrieved = client.get(location); 82 | assertThat(retrieved.getRelated().getName(), is("x")); 83 | } 84 | 85 | @Test 86 | public void canGetNullEntityAssociation() { 87 | URI location = client.post(new SimpleEntity()); 88 | 89 | SimpleEntity retrieved = client.get(location); 90 | assertThat(retrieved.getRelated(), is(nullValue())); 91 | } 92 | 93 | @Test 94 | public void canGetAllEntities() { 95 | SimpleEntity sent = new SimpleEntity(); 96 | 97 | URI location = client.post(sent); 98 | 99 | assertThat(Lists.newArrayList(client.getAll()), 100 | hasItem(hasProperty("id", equalTo(location)))); 101 | } 102 | 103 | @Test 104 | public void canPostEntityWithAssociatedProxy() { 105 | SimpleEntity related = new SimpleEntity(); 106 | URI relatedResource = client.post(related); 107 | 108 | SimpleEntity sent = new SimpleEntity(); 109 | sent.setRelated(client.get(relatedResource)); 110 | 111 | client.post(sent); 112 | } 113 | 114 | @Test 115 | public void canPutEntity() { 116 | SimpleEntity sent = new SimpleEntity(); 117 | sent.setName("current"); 118 | 119 | URI posted = client.post(sent); 120 | 121 | assertThat(sent.getId(), is(posted)); 122 | assertThat(sent.getName(), is("current")); 123 | 124 | sent.setName("updated"); 125 | client.put(sent); 126 | 127 | SimpleEntity updated = client.get(sent.getId()); 128 | 129 | assertThat(updated.getId(), is(posted)); 130 | assertThat(updated.getName(), is("updated")); 131 | } 132 | 133 | @Test 134 | public void canGetAndPutEntity() { 135 | SimpleEntity sent = new SimpleEntity(); 136 | sent.setName("current"); 137 | 138 | URI posted = client.post(sent); 139 | 140 | assertThat(sent.getId(), is(posted)); 141 | assertThat(sent.getName(), is("current")); 142 | 143 | sent = client.get(posted); 144 | 145 | sent.setName("updated"); 146 | client.put(sent); 147 | 148 | SimpleEntity updated = client.get(sent.getId()); 149 | 150 | assertThat(updated.getId(), is(posted)); 151 | assertThat(updated.getName(), is("updated")); 152 | } 153 | 154 | @Test 155 | public void canPatchEntity() { 156 | SimpleEntity related = new SimpleEntity(); 157 | related.setName("y"); 158 | client.post(related); 159 | 160 | SimpleEntity entity = new SimpleEntity(); 161 | entity.setName("x"); 162 | URI entityUri = client.post(entity); 163 | 164 | client.patch(entityUri, new SimpleEntityPatch(related)); 165 | 166 | SimpleEntity updated = client.get(entityUri); 167 | 168 | assertThat(updated.getName(), is("x")); 169 | assertThat(updated.getRelated().getName(), is("y")); 170 | } 171 | 172 | @Test 173 | public void canPatchRetrievedLinkedResourceOfEntity() { 174 | SimpleEntity related = new SimpleEntity(); 175 | related.setName("related"); 176 | client.post(related); 177 | 178 | SimpleEntity related2 = new SimpleEntity(); 179 | related2.setName("related2"); 180 | client.post(related2); 181 | 182 | SimpleEntity sent = new SimpleEntity(); 183 | sent.setRelated(related); 184 | client.post(sent); 185 | 186 | SimpleEntity retrieved = client.get(sent.getId()); 187 | 188 | retrieved.setRelated(related2); 189 | client.patch(retrieved.getId(), new SimpleEntityPatch(retrieved.getRelated())); 190 | 191 | assertThat(client.get(retrieved.getId()).getRelated().getName(), is("related2")); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /client/src/main/java/uk/co/blackpepper/bowman/Client.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Black Pepper Software 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 | * http://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 uk.co.blackpepper.bowman; 17 | 18 | import java.net.URI; 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | 22 | import org.springframework.hateoas.CollectionModel; 23 | import org.springframework.hateoas.EntityModel; 24 | import org.springframework.web.util.UriComponentsBuilder; 25 | 26 | import uk.co.blackpepper.bowman.annotation.RemoteResource; 27 | 28 | import static uk.co.blackpepper.bowman.ReflectionSupport.getId; 29 | import static uk.co.blackpepper.bowman.ReflectionSupport.setId; 30 | 31 | /** 32 | * Class for retrieving, persisting and deleting annotated entity instances via remote 33 | * hal+json resources. 34 | * 35 | *

Entities can contain simple (directly mappable to JSON) properties, and inline or 36 | * linked associations to further objects. 37 | * 38 | *

Clients are created via {@link ClientFactory#create}. 39 | * 40 | * @param the entity type for this client 41 | * 42 | * @author Ryan Pickett 43 | * 44 | */ 45 | public class Client { 46 | 47 | private final Class entityType; 48 | 49 | private final URI baseUri; 50 | 51 | private final ClientProxyFactory proxyFactory; 52 | 53 | private final RestOperations restOperations; 54 | 55 | Client(Class entityType, Configuration configuration, RestOperations restOperations, 56 | ClientProxyFactory proxyFactory) { 57 | this.entityType = entityType; 58 | this.baseUri = getEntityBaseUri(entityType, configuration); 59 | this.proxyFactory = proxyFactory; 60 | this.restOperations = restOperations; 61 | } 62 | 63 | /** 64 | * GET a single entity from the entity's base resource (determined by the class's 65 | * {@link uk.co.blackpepper.bowman.annotation.RemoteResource} annotation). 66 | * 67 | * @return the entity, or null if not found 68 | */ 69 | public T get() { 70 | return get(baseUri); 71 | } 72 | 73 | /** 74 | * GET a single entity located at the given URI. 75 | * 76 | * @param uri the URI from which to retrieve the entity 77 | * @return the entity, or null if not found 78 | */ 79 | public T get(URI uri) { 80 | EntityModel resource = restOperations.getResource(uri, entityType); 81 | 82 | if (resource == null) { 83 | return null; 84 | } 85 | 86 | return proxyFactory.create(resource, restOperations); 87 | } 88 | 89 | /** 90 | * GET a collection of entities from the entity's base resource (determined by the class's 91 | * {@link uk.co.blackpepper.bowman.annotation.RemoteResource} annotation). 92 | * 93 | * @return the entities retrieved 94 | */ 95 | public Iterable getAll() { 96 | return getAll(baseUri); 97 | } 98 | 99 | /** 100 | * GET a collection of entities from the given URI. 101 | * 102 | * @param uri the URI from which to retrieve the entities 103 | * @return the entities retrieved 104 | */ 105 | public Iterable getAll(URI uri) { 106 | List result = new ArrayList<>(); 107 | 108 | CollectionModel> resources = restOperations.getResources(uri, entityType); 109 | 110 | for (EntityModel resource : resources) { 111 | result.add(proxyFactory.create(resource, restOperations)); 112 | } 113 | 114 | return result; 115 | } 116 | 117 | /** 118 | * POST the given entity to the entity's base resource (determined by the class's 119 | * {@link uk.co.blackpepper.bowman.annotation.RemoteResource} annotation). 120 | * 121 | * The entity will be updated with the URI ID the remote service has assigned it. 122 | * 123 | * @param object the entity to submit 124 | * @return the URI ID of the newly created remote entity 125 | */ 126 | public URI post(T object) { 127 | URI resourceUri = restOperations.postForId(baseUri, object); 128 | 129 | setId(object, resourceUri); 130 | 131 | return resourceUri; 132 | } 133 | 134 | /** 135 | * PUT the given entity to the entity's base resource (determined by the class's 136 | * {@link uk.co.blackpepper.bowman.annotation.RemoteResource} annotation). 137 | * 138 | * @param object the entity to submit 139 | */ 140 | public void put(T object) { 141 | restOperations.put(getId(object), object); 142 | } 143 | 144 | /** 145 | * DELETE the entity at the given URI. 146 | * 147 | * @param uri a URI of the entity to delete 148 | */ 149 | public void delete(URI uri) { 150 | restOperations.delete(uri); 151 | } 152 | 153 | /** 154 | * PATCH (partial update) of the entity at the given URI. 155 | * 156 | * @param uri a URI of the entity to update 157 | * @param patch any type that can be serialized to a set of changes, for example a Map 158 | * @return The patched entity, or null if no response content was returned 159 | */ 160 | public T patch(URI uri, Object patch) { 161 | EntityModel resource = restOperations.patchForResource(uri, patch, entityType); 162 | 163 | return proxyFactory.create(resource, restOperations); 164 | } 165 | 166 | URI getBaseUri() { 167 | return baseUri; 168 | } 169 | 170 | private static URI getEntityBaseUri(Class entityType, Configuration configuration) { 171 | String path = entityType.getAnnotation(RemoteResource.class).value(); 172 | 173 | return UriComponentsBuilder.fromUri(configuration.getBaseUri()).path(path).build().toUri(); 174 | } 175 | } 176 | --------------------------------------------------------------------------------