Broadleaf Microservices

Adding New Domain and REST API Endpoints

Broadleaf provides lots of out-of-box domains, along with REST API endpoints to perform CRUD operations on them. The framework also includes robust support to make it easy for new domain and corresponding management operations to be added.

This tutorial provides a step-by-step demonstration of the process with a domain called MyDomain. We will follow the Layered Architecture pattern, which is described further here.

Almost all the content will apply to both Trackable and non-Trackable domain, but since most new domains are likely to be Trackable, we will use it for our example.

Defining Domain

Let’s start by defining the Java classes that will represent the new domain.

Business (Projection) Domain

The "business domain" (AKA "projection domain" or "DTO") is the database-provider-agnostic, API-friendly representation of the entity.

MyDomain business domain
import com.broadleafcommerce.data.tracking.core.ContextStateAware;
import com.broadleafcommerce.data.tracking.core.filtering.business.domain.ContextState;

import java.io.Serializable;

import lombok.Data;

@Data (1)
public class MyDomain implements Serializable, ContextStateAware { (2)

    private static final long serialVersionUID = 1L;

    private String id;

    private String name;

    private String description;

    private ContextState contextState; (3)
}
  1. Lombok @Data annotation to automatically generate getters, setters, toString, and hashCode and equals implementations

  2. Since our persisted domain will be Trackable, we will make our business domain ContextStateAware. ContextState exposes a subset of Tracking information relevant for API callers, and is particularly useful for domain that will be managed in the Broadleaf admin application, which leverages its contents.

  3. Defines a ContextState field to implement the ContextStateAware interface

Persisted Domain

The "persisted domain" (AKA "repository domain") is the representation of the entity used for a particular database provider (ex: JPA vs Mongo). This representation is also what defines the specific Trackable behavior which should be used for data discrimination. For simplicity, we’ll have our domain just be discriminated on tenant alone.

JpaMyDomain persisted domain
import static com.broadleafcommerce.common.jpa.JpaConstants.CONTEXT_ID_LENGTH;

import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.Type;
import org.modelmapper.Conditions;
import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;

import com.broadleafcommerce.data.tracking.core.TenantTrackable;
import com.broadleafcommerce.data.tracking.core.mapping.BusinessTypeAware;
import com.broadleafcommerce.data.tracking.core.mapping.ModelMapperMappable;
import com.broadleafcommerce.data.tracking.jpa.filtering.TrackingListener;
import com.broadleafcommerce.data.tracking.jpa.filtering.domain.TenantJpaTracking;

import java.io.Serializable;
import java.util.Optional;

import javax.persistence.Column;
import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Entity (1)
@Table(name = "MY_DOMAIN", (2)
        indexes = @Index(columnList = "TRK_TENANT_ID")) (3)
@Data (4)
@EqualsAndHashCode(exclude = "contextId") (5)
@EntityListeners(TrackingListener.class) (6)
public class JpaMyDomain implements
        TenantTrackable<TenantJpaTracking>,
        ModelMapperMappable,
        BusinessTypeAware,
        Serializable { (7) (8) (9)

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(generator = "blcid")
    @GenericGenerator(name = "blcid", strategy = "blcid")
    @Type(type = "com.broadleafcommerce.data.tracking.jpa.hibernate.ULidType")
    @Column(name = "ID", nullable = false, length = CONTEXT_ID_LENGTH)
    private String contextId; (10)

    @Column(name = "NAME")
    private String name;

    @Column(name = "DESCRIPTION")
    private String description;

    @Embedded
    private TenantJpaTracking tracking; (11)

    @Override
    public Optional<String> getDisplay() { (12)
        return Optional.ofNullable(getName());
    }

    @Override
    public ModelMapper fromMe() { (13)
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);

        modelMapper.createTypeMap(JpaMyDomain.class, MyDomain.class)
            .addMapping(JpaMyDomain::getContextId, MyDomain::setId);

        return modelMapper;
    }

    @Override
    public ModelMapper toMe() { (14)
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);

        modelMapper.createTypeMap(MyDomain.class, JpaMyDomain.class)
                .addMappings(mapping -> mapping.when(Conditions.isNotNull())
                        .map(MyDomain::getId, JpaMyDomain::setContextId));

        return modelMapper;
    }

    @Override
    public Class<?> getBusinessDomainType() { (15)
        return MyDomain.class;
    }
}
  1. Specify the class is a JPA entity

  2. Define the name of the table that the entity will be persisted in

  3. Since our entity is TenantTrackable, the TRK_TENANT_ID column from TenantJpaTracking will be used in query filtration, and thus having an index on it is recommended. For other Trackable types, the relevant columns to index may differ.

  4. Lombok @Data annotation to automatically generate getters, setters, toString, and hashCode and equals implementations

  5. Since it’s not recommended to include primary keys in entity equals implementations, we exclude the field here

  6. Register a listener that will automatically handle maintenance of fields from Tracking that need conversion during persistence or loading

  7. Since we want tenant-only discrimination for the purpose of this example, have the entity implement TenantTrackable. This will ensure the framework automatically applies the necessary behavior when dealing with it.

  8. When an entity implements ModelMapperMappable, the framework will use the ModelMapper configured in the toMe()/fromMe() methods to map between the business and persisted domain representations. This is the preferred approach for mapping between domain types.

  9. If an entity implements BusinessTypeAware, the framework will use the provided business domain type as the result to return when mapping from the persisted domain.

  10. All Trackable entities must define a contextId field. There are several annotations on here to indicate logic around ID generation. In this case, the contextId field is also the primary key for the entity.

    Note
    If your entity is sandboxable, catalog discriminated, or both, then a separate primary key field should be defined and contextId should not be the primary key.
  11. The Tracking field for the entity, which all Trackable entities must have. In this case, since the entity is TenantTrackable, we use TenantJpaTracking as the specific Tracking type.

  12. A simple method that returns a user-friendly display value for the entity. This example just returns the value of the name field.

  13. The fromMe() implementation for ModelMapperMappable. Defines a ModelMapper containing configuration and TypeMaps for mapping from the persisted domain to the business domain. It’s recommended to configure a 'strict' matching strategy to eliminate any ambiguity in how ModelMapper generates automatic mappings, and then manually define mappings for fields that don’t have exact name matches. In this case, the name/description fields are the same type/name in both domains, so ModelMapper will map them, but we will manually define the mapping for the id/contextId fields since they differ.

  14. The toMe() implementation for ModelMapperMappable. Defines a ModelMapper containing configuration and TypeMaps for mapping from the business domain to the persisted domain. The same concepts for 'strict' matching and manual mapping definitions apply here.

  15. Implements BusinessTypeAware by returning the business domain type.

Domain Management Components

Once we’ve defined the business and persisted domain classes, we need to establish components that can manage them.

Persistence Layer

After defining the persisted domain class, we need to make sure the framework is scanning for it and picking it up as an entity. Furthermore, we need to define some repository components and ensure they’re registered with the Spring context.

Defining the Repository

Note
If unfamiliar with the Spring Data repository patterns, you can learn more in Spring Data’s official documentation.

Let’s start by defining the repository interface. To remain flexible for possible future changes in database providers, we’ll define two repository interfaces (see Spring’s documentation about this).

Generic provider-agnostic repository
import org.springframework.data.repository.NoRepositoryBean;

import com.broadleafcommerce.data.tracking.core.Trackable;
import com.broadleafcommerce.data.tracking.core.TrackableRepository;
import com.broadleafcommerce.data.tracking.core.TrackableRsqlFilterExecutor;

@NoRepositoryBean (1)
public interface MyDomainRepository<D extends Trackable> (2)
        extends TrackableRepository<D>, TrackableRsqlFilterExecutor<D> {} (3) (4)
  1. Mark this with @NoRepositoryBean to explicitly indicate this interface is not meant to be instantiated as a repository, since we expect the provider-specific interfaces to be the ones registered instead

  2. Use the provider-agnostic Trackable interface as the type parameter constraint

  3. Since our entity is Trackable, extend Broadleaf’s TrackableRepository interface, which will automatically provide lots of useful methods with built-in support for the appropriate data-discrimination behavior

    Note
    For a non-Trackable entity, the typical approach would be to extend Spring’s PagingAndSortingRepository interface instead.
  4. This is optional, but if you believe your entity should be filterable with RSQL, extending TrackableRsqlFilterExecutor will ensure the necessary support is present.

    Note
    For a non-Trackable entity, the typical approach would be to use Broadleaf’s MappableRsqlFilterExecutor instead.
JPA provider-specific repository
import org.springframework.stereotype.Repository;

import com.broadleafcommerce.data.tracking.core.filtering.Narrow;
import com.broadleafcommerce.data.tracking.jpa.filtering.narrow.JpaNarrowExecutor;

@Repository (1)
@Narrow(JpaNarrowExecutor.class) (2)
public interface JpaMyDomainRepository<D extends JpaMyDomain> (3)
    extends MyDomainRepository<D> {} (4)
  1. Mark this with @Repository so it will be picked up in repository scans and instantiated as a bean

  2. The @Narrow annotation ensures all queries will have the appropriate data-discrimination criteria applied to them

  3. Use the provider-specific domain type which was previously defined: JpaMyDomain

  4. Simply extend the previously defined provider-agnostic repository

Note
This multi-interface approach can be useful for flexibility, but it is not required. If you do not anticipate the need to support multiple database providers, you can simply define a single provider-specific repository interface.

Configuration

Next, let’s define an auto-configuration class that will be responsible for entity scanning and repository registration.

This tutorial assumes you’re adding this configuration to a microservice that Broadleaf already provides. If you’re establishing a persistence-layer configuration for a fully custom microservice, you may need to add additional configuration (for example, data routing configuration).

For this example, let’s assume we are adding new domain to a customized extension of Broadleaf’s Catalog microservice.

JPA provider-specific configuration
import static com.broadleafcommerce.catalog.provider.RouteConstants.Persistence.CATALOG_ROUTE_PACKAGE;
import static com.broadleafcommerce.catalog.provider.jpa.JpaRouteConstants.Persistence.CATALOG_ENTITY_MANAGER_FACTORY;
import static com.broadleafcommerce.catalog.provider.jpa.JpaRouteConstants.Persistence.CATALOG_TRANSACTION_MANAGER;

import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

import com.broadleafcommerce.common.jpa.data.entity.JpaEntityScan;
import com.broadleafcommerce.data.tracking.jpa.filtering.narrow.factory.JpaTrackableRepositoryFactoryBean;

@Configuration
@AutoConfigureBefore(CatalogJpaAutoConfiguration.class) (1)
@EnableJpaRepositories(
        basePackages = "com.mycompany.provider.jpa.tutorial.repository", (2)
        repositoryFactoryBeanClass = JpaTrackableRepositoryFactoryBean.class, (3)
        entityManagerFactoryRef = CATALOG_ENTITY_MANAGER_FACTORY, (4)
        transactionManagerRef = CATALOG_TRANSACTION_MANAGER) (5)
@JpaEntityScan(
        basePackages = "com.mycompany.provider.jpa.tutorial.domain", (6)
        routePackage = CATALOG_ROUTE_PACKAGE) (7)
public class MyJpaAutoConfiguration {}
  1. Indicate that this configuration will precede Broadleaf’s out-of-box JPA configuration for the catalog service

  2. Specify the package in which the provider-specific repository interfaces have been defined. For this example, it would be the package in which JpaMyDomainRepository is defined.

  3. Since our repository is a TrackableRepository, use the JpaTrackableRepositoryFactoryBean to ensure it is correctly instantiated

    Note
    For non-Trackable repositories, JpaMappableRepositoryFactoryBean should be used instead.
  4. Leverage the same entity manager factory that Broadleaf’s catalog configuration uses by default (a similar constant will be defined in each Broadleaf-provided microservice)

  5. Leverage the same transaction manager that Broadleaf’s catalog configuration uses by default (a similar constant will be defined in each Broadleaf-provided microservice)

  6. Specify the package in which the provider-specific entities have been defined. For this example, it would be the package in which JpaMyDomain is defined.

  7. Specify the catalog route package to prevent entities from being registered in other data routes (a similar constant will be defined in each Broadleaf-provided microservice). This is only relevant if data routing is enabled.

Tip
Don’t forget to register your JPA configuration in spring.factories under org.springframework.boot.autoconfigure.EnableAutoConfiguration (and org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa if using that test configuration slice).
Tip
If you plan to support multiple database providers, it may be useful to gate this configuration on a property that describes which provider is active, such as broadleaf.database.provider.
Tip
If using Liquibase to track schema changes, you may find it useful to leverage tests provided in com.broadleafcommerce.common.jpa.schema.SchemaCompatibiltyUtility to generate the appropriate change-sets following the introduction of this domain.

Service Layer

Continuing with the Layered Architecture pattern, we will now define the "service layer" components. Here too, Broadleaf provides robust out-of-box implementations that we can leverage for our new domain.

Service Components

We’ll first define the interface of a CRUD service component, along with its implementation.

Note
Though separating into an interface and an implementation can enable greater flexibility in changing implementations, you can just as easily skip the interface and only define the implementation class if you so choose.
MyDomain CRUD service interface
import com.broadleafcommerce.data.tracking.core.service.RsqlCrudEntityService;

public interface MyDomainService<P extends MyDomain> (1)
        extends RsqlCrudEntityService<P> {} (2)
  1. Since consumers of the service will interact with it using the business domain type as input and output, the type parameter should be bound to that type. For our current example, this is MyDomain.

  2. To meet requirements of common use-cases, Broadleaf provides several out-of-box service interfaces and corresponding implementations. In this example, since the domain is Trackable and we’ve defined RSQL support in the repository, we will extend RsqlCrudEntityService.

    Note
    RsqlCrudEntityService and RsqlMappableCrudEntityService are for Trackable and non-Trackable entities supporting RSQL filtration (respectively). Similarly, CrudEntityService and MappableCrudEntityService are for Trackable and non-Trackable entities not supporting RSQL filtration.
MyDomain CRUD service implementation
import com.broadleafcommerce.data.tracking.core.Trackable;
import com.broadleafcommerce.data.tracking.core.service.BaseRsqlCrudEntityService;
import com.broadleafcommerce.data.tracking.core.service.RsqlCrudEntityHelper;

public class DefaultMyDomainService<P extends MyDomain> (1)
        extends BaseRsqlCrudEntityService<P> (2)
        implements MyDomainService<P> {

    public DefaultMyDomainService(MyDomainRepository<Trackable> repository, (3)
            RsqlCrudEntityHelper helper) { (4)
        super(repository, helper);
    }
}
  1. The implementation also defines the same bounded type parameter as the interface, which is the business domain.

  2. To implement the RsqlCrudEntityService interface, we can simply extend Broadleaf’s default implementation of BaseRsqlCrudEntityService.

    Note
    Broadleaf provides default implementations for all of its out-of-box service interfaces: BaseRsqlMappableCrudEntityService, BaseCrudEntityService, and BaseMappableCrudEntityService implement RsqlMappableCrudEntityService, CrudEntityService, and MappableCrudEntityService respectively.
  3. Ensure to specify the domain-specific repository as a constructor parameter to ensure the correct type is injected

  4. Broadleaf’s default service implementations rely on delegate "helper" components. In this case, we’re using BaseRsqlCrudEntityService, which requires the RsqlCrudEntityHelper.

    Note
    There are other helpers, such as CrudEntityHelper, RsqlMappableCrudEntityHelper, and MappableCrudEntityHelper.

Service Configuration

We’ll now define an auto-configuration class that will be responsible for registering our service-level components.

For this example, let’s assume we are adding components to a customized extension of Broadleaf’s Catalog microservice.

Service configuration
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.broadleafcommerce.data.tracking.core.Trackable;
import com.broadleafcommerce.data.tracking.core.service.RsqlCrudEntityHelper;

@Configuration
@AutoConfigureBefore(CatalogServiceAutoConfiguration.class) (1)
public class MyDomainServiceAutoConfiguration {

    @Bean
    public MyDomainService<MyDomain> myDomainService(
            MyDomainRepository<Trackable> myDomainRepository,
            RsqlCrudEntityHelper rsqlCrudEntityHelper) {
        return new DefaultMyDomainService<>(myDomainRepository, rsqlCrudEntityHelper);
    }
}
  1. Indicate that this configuration will precede Broadleaf’s out-of-box service configuration for the catalog service

Tip
Don’t forget to register this configuration in spring.factories under org.springframework.boot.autoconfigure.EnableAutoConfiguration.

Validation Components

In almost all cases, it is important for CRUD operations on an entity to include validation to prevent the possibility of an invalid/misconfigured state being persisted.

Broadleaf’s out-of-box service implementations include a powerful mechanism through which validation can be added for any entity if desired. The service implementations inject a component called EntityValidatorManager, which itself injects all available EntityValidator beans. Each EntityValidator implementation encapsulates all validation logic necessary for a particular type, and explicitly declares what it can support. The EntityValidatorManager will delegate to the first EntityValidator which supports the input.

Thus, to add validation for an entity, we can simply define a new EntityValidator implementation.

MyDomain validator implementation
import static org.springframework.validation.ValidationUtils.rejectIfEmptyOrWhitespace;
import org.apache.commons.lang3.StringUtils;
import org.springframework.validation.Errors;
import com.broadleafcommerce.data.tracking.core.context.ContextInfo;
import com.broadleafcommerce.data.tracking.core.mapping.validation.EntityValidator;

public class MyDomainValidator implements EntityValidator {

    @Override
    public boolean supports(Class<?> serviceClass, ContextInfo context) {
        return MyDomain.class.isAssignableFrom(serviceClass); (1)
    }

    @Override
    public void validate(Object businessInstance, Errors errors, ContextInfo context) { (2) (3)
        validateName(errors);
        validateDescription((MyDomain) businessInstance, errors); (4)
    }

    protected void validateName(Errors errors) {
        rejectIfEmptyOrWhitespace(errors, (5)
                "name",
                "validation.errors.mydomain.name.missing",
                "Name is required");
    }

    protected void validateDescription(MyDomain myDomain, Errors errors) {
        if (StringUtils.isBlank(myDomain.getDescription())) { (6)
            errors.rejectValue("description",
                    "validation.errors.mydomain.description.missing",
                    "Description is required");
        }
    }
}
  1. This method is used by EntityValidatorManager to determine the EntityValidator to use, so in it we simply check if the type matches the business domain we intend to validate with this validator.

  2. While EntityValidator exposes granular methods to allow different validation for create and replace operations, in many cases the validation may be the same. Thus, by default, those methods delegate to this common validate() method. We can implement this single method, and it will apply on all operations.

  3. The Errors object is a Spring concept that we can use to register any validation errors. It will be bound to the given business instance at the time this method is invoked.

  4. We can confidently cast the business instance to our type here, since we know EntityValidatorManager will only invoke this validator if the supports() condition passes

  5. Since we’re using Spring’s Errors concept, we can also leverage Spring’s utility methods for common validations

  6. This is just an example of how you might do a required validation if not using Spring’s utility methods

Tip
While you could technically register this validator bean in the service configuration, it is advisable to place validator bean definitions into a separate validation configuration class. You can then add that class to com.broadleafcommerce.data.tracking.test.autoconfigure.AutoConfigureEntityValidator in spring.factories, which will enable you to use the @AutoConfigureEntityValidator test configuration slice for testing your validators.

Once this validator is present, CRUD operations invoked from your service component will throw a com.broadleafcommerce.common.error.validation.ValidationException when validation errors are detected on the input. If the operation was invoked as part of a REST API call, the com.broadleafcommerce.common.error.validation.web.ApiValidationWebExceptionAdvisor will translate this exception into a meaningful API error response.

Note
The Broadleaf admin frontend application has been designed to understand validation API error responses and will render them appropriately to the user.

Adding REST API Endpoints

Now that we’ve established the domain and the internal components necessary to properly manage it, we can introduce REST API operations for external callers.

Spring REST Controller

We will write a standard Spring REST controller class and add request mappings to it.

For an entity that will be managed in the Broadleaf admin application, there are a handful of operations that are generally needed:

  1. A paginated "read-all" operation to retrieve data for the browse view, or for lookups involving this entity

  2. A "read-single" operation to retrieve a single entity, useful for populating the edit view

  3. A "replace" operation to replace an existing entity instance with new data, typically invoked on submission of the edit form

  4. A "create" operation to insert a new entity, invoked on submission of the create form

  5. A "delete" operation to delete/archive an existing entity

MyDomain controller class
import static com.broadleafcommerce.catalog.provider.RouteConstants.Persistence.CATALOG_ROUTE_KEY;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.broadleafcommerce.common.extension.data.DataRouteByKey;
import com.broadleafcommerce.data.tracking.core.context.ContextInfo;
import com.broadleafcommerce.data.tracking.core.context.ContextOperation;
import com.broadleafcommerce.data.tracking.core.filtering.fetch.FilterHelper;
import com.broadleafcommerce.data.tracking.core.filtering.fetch.FilterParser;
import com.broadleafcommerce.data.tracking.core.policy.Policy;
import com.broadleafcommerce.data.tracking.core.type.OperationType;

import cz.jirutka.rsql.parser.ast.Node;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping(MyDomainEndpoint.BASE_URI)
@RequiredArgsConstructor (1)
@DataRouteByKey(CATALOG_ROUTE_KEY) (2)
public class MyDomainEndpoint {

    public static final String BASE_URI = "/my-domains"; (3)

    @Getter(AccessLevel.PROTECTED)
    private final MyDomainService<MyDomain> myDomainService; (4)

    @Getter(AccessLevel.PROTECTED)
    private final FilterParser<Node> filterParser;

    @Policy(permissionRoots = "MY_DOMAIN") (5)
    @GetMapping
    public Page<MyDomain> readAllMyDomains(
            @ContextOperation(OperationType.READ) final ContextInfo contextInfo, (6)
            @RequestParam(value = "q", required = false) String nameQuery, (7)
            Node filters, (8)
            @PageableDefault(size = 50) Pageable page) { (9)
        filters = FilterHelper.filterByNameAndFilters(filters, nameQuery, filterParser); (10)
        return myDomainService.readAll(filters, page, contextInfo);
    }

    @Policy(permissionRoots = "MY_DOMAIN")
    @GetMapping("/{id}")
    public MyDomain readSingleMyDomain(
            @ContextOperation(OperationType.READ) final ContextInfo contextInfo,
            @PathVariable("id") String id) {
        return myDomainService.readByContextId(id, contextInfo);
    }

    @Policy(permissionRoots = "MY_DOMAIN")
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public MyDomain createMyDomain(
            @ContextOperation(OperationType.CREATE) final ContextInfo contextInfo,
            @RequestBody MyDomain myDomain) {
        return myDomainService.create(myDomain, contextInfo);
    }

    @Policy(permissionRoots = "MY_DOMAIN")
    @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public MyDomain replaceMyDomain(
            @ContextOperation(OperationType.UPDATE) final ContextInfo contextInfo,
            @PathVariable("id") String id,
            @RequestBody MyDomain myDomain) {
        myDomain.setId(id);
        return myDomainService.replace(id, myDomain, contextInfo);
    }

    @Policy(permissionRoots = "MY_DOMAIN")
    @DeleteMapping("/{id}")
    public void deleteMyDomain(
            @ContextOperation(OperationType.DELETE) final ContextInfo contextInfo,
            @PathVariable("id") String id) {
        myDomainService.delete(id, contextInfo);
    }
}
  1. This is a Lombok annotation that automatically generates a constructor accepting any defined final fields as arguments

  2. If data routing is enabled, it’s important to include a @DataRouteByKey or @DataRouteByExample annotation describing the route on which you want processing to occur. This example assumes we’re in the Catalog microservice, so we use its route key.

  3. This is the base URI path we’ll use for CRUD operations on our new entity. Following convention, the path is plural, indicating management of a 'collection'.

  4. We’ll inject our newly defined service component, since the actual processing will be delegated to it.

  5. All operations in this controller are annotated with this @Policy annotation indicating the permission root that will be required to use it. The permission root (in this case MY_DOMAIN) is prefixed with the operation type specified in @ContextOperation(in this case READ) to form the final permission that is required for access (READ_MY_DOMAIN). If the access token supplied in the request does not have the requisite authority, then access is denied.

    Note
    You must introduce the appropriate scope/permission-root and permissions in the Authentication microservice, and grant those permissions to the appropriate users/roles.
  6. Since our domain is Trackable, all query/persistence operations will require the presence of a ContextInfo (and its ContextRequest). API callers are expected to provide the necessary context information in the request, and it will automatically be resolved into this ContextInfo object by com.broadleafcommerce.data.tracking.core.context.ContextInfoHandlerMethodArgumentResolver.

  7. In "read-all" endpoints, it’s often useful to allow filtering results by searching on a particular field. By convention, the request parameter for this is q. In this case, we’ll allow searching on the name field of MyDomain.

  8. Since our domain is RSQL filterable, it’s useful to expose this functionality to API callers in our "read-all" endpoint. API callers are expected to pass RSQL filters through the optional cq parameter, and com.broadleafcommerce.data.tracking.core.filtering.fetch.rsql.web.RsqlFilterHandlerMethodArgumentResolver is responsible for parsing those filters into a Node object. During query preparation, these filters will be converted and added to query criteria.

  9. To optimize the user experience and improve performance, we support pagination in our "read-all" endpoint. API callers provide various pagination parameters, and these are resolved by com.broadleafcommerce.data.tracking.core.web.NarrowPageableHandlerMethodArgumentResolver into the appropriate Pageable object.

    Note
    Depending on whether the entity is Trackable, certain pagination parameters may not be supported. Typically, Trackable entities only support offset, forward, and size. See com.broadleafcommerce.data.tracking.core.web.NarrowPageableHandlerMethodArgumentResolver for what may be appropriate.
  10. Since our domain supports RSQL, we can take advantage of some performance optimizations by simply merging our name search filter into the RSQL Node. Broadleaf provides a utility class that can do this for us, so we will use it here.

Tip
Be sure to write tests that make requests to each operation and confirm the behavior works!
Tip
It’s good practice to document your API so consumers know what operations are available. Broadleaf recommends using the OpenAPI specification standard.

Web Layer Configuration

Next, we need to create an auto-configuration class for the web-layer that will register our controller.

Web layer configuration
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnWebApplication (1)
@ComponentScan(basePackages = "com.mycompany.web.endpoint") (2)
public class MyWebAutoConfiguration {}
  1. Only register this configuration for Spring web applications

  2. Specify the package in which the controllers have been defined. For this example, it would be the package containing MyDomainEndpoint.

Tip
Don’t forget to register your web configuration in spring.factories under org.springframework.boot.autoconfigure.EnableAutoConfiguration (and org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc if using that test configuration slice).

Handling Request Authorization

Before we can hit the endpoint, we need to do two more things:

  1. Set up the new permissions

  2. Get an access token

Setting Up Permissions

Let’s set up the permissions a user or client (like another microservice) would need to hit these endpoints. First, we’ll add them to the Auth Service:

INSERT INTO blc_user_permission(id, "name", last_updated)
VALUES ('-2000', 'READ_MY_DOMAIN', '2021-01-01 12:00:00.000'),
       ('-2001', 'ALL_MY_DOMAIN', '2021-01-01 12:00:00.000');

-- -1    Partial access user
-- -2    Full access user

INSERT INTO blc_role_permission_xref (role_id, permission_id)
VALUES ('-1', '-2000'),
       ('-2', '-2001');

INSERT INTO blc_security_scope (id, "name", "open")
VALUES ('-2000', 'MY_DOMAIN', 'N');

INSERT INTO blc_permission_scope(id, permission, is_permission_root, scope_id)
VALUES ('-2000', 'MY_DOMAIN', 'Y', '-2000');

-- example here uses the OpenAPI client, but each client that needs to hit the endpoint needs a row

INSERT INTO blc_client_permissions(id, permission)
VALUES ('openapi', 'ALL_MY_DOMAIN');

INSERT INTO blc_client_scopes(id, scope)
VALUES ('openapi', 'MY_DOMAIN');

Second, we’ll add the related user permissions to Admin User Services:

INSERT INTO blc_admin_permission (id, "name", tenant_id)
VALUES ('-3000', 'READ_MY_DOMAIN', '5DF1363059675161A85F576D'),
       ('-3001', 'ALL_MY_DOMAIN', '5DF1363059675161A85F576D');

-- -1    Partial access user
-- -2    Full access user

INSERT INTO blc_admin_role_admin_permission_xref (admin_role_id, admin_permission_id)
VALUES ('-1', '-3000'),
       ('-2', '-3001');

With these set, we should be able to get an access token to allow our requests.

Getting an Access Token

We will need to get an access token before we can hit the new endpoint. This should be included in the request’s authentication. The following is an example cURL to retrieve an access token.

Tip
You can replace <client_id> with one of the out-of-box client’s ID like openapi and <client_secret> with its default openapi_secret.
Important
You should update the default client secrets before going live. See Configuring Client Credentials.
curl -v https://localhost:8446/auth/oauth/token \
  -H "Accept: application/json" \
  -u "<client_id>:<client_secret>" \
  -d "grant_type=client_credentials&scope=MY_DOMAIN" \
  -k
Note
I added the -k to turn off cURL’s verification of the certificate since for localhost it is self-signed. You may not need this.

With the access token, we should be ready to hit the new endpoint!

Managing in the Admin

If you also want to know how to include management of a new entity in the Admin UI, checkout the Metadata tutorial.