REST API Controller

MSX promotes the usage of the common Controller > Service > Repository layered architecture within microservices.

The role of the Controller is to accept REST-based API requests from callers (UI, swagger, other microservices), and route them to the service.

To generate a complete domain, including the controller, use the skel tool.

In the Services section, we described the various components of every service, including REST API Controllers. The following sections assumes your familiarity with those components.

REST Controllers have specific requirements for:

  • Interface Definition
  • Mock
  • Dependencies
  • Implementation
  • Lifecycle Registration (for root components)

Interface Definition

The REST controller component is never mocked as it is a root component. No interface definition is required.

Mock

The REST controller component is never mocked as it is a root component. No mock generation is required.

Dependencies

REST Controllers typically depend exclusively on the Application Service. For example:

type applicationController struct {
    applicationService ApplicationServiceApi
}

The converter dependency has moved to the Application Service, which now receives and returns DTOs.

Implementation

EndpointsProducer

Modern go-msx REST Controllers must implement the restops.EndpointsProducer interface. The Endpoints function returns a list of endpoints implemented by the controller:

func (c *applicationController) Endpoints() (restops.Endpoints, error) {
    builders := restops.EndpointBuilders{
        c.getConfigApplicationResultsByConfigApplicationId(),
        ...
    }

    return builders.Endpoints()
}

Each endpoint or builder is generated by calling a method on the controller from the Endpoints function. These are then aggregated and returned as a slice of Endpoints.

EndpointTransformersProducer

A controller may also implement the restops.EndpointTransformersProducer interface in order to apply transformations to each of the registered endpoints, including tagging and path manipulation:

func (c *applicationController) EndpointTransformers() restops.EndpointTransformers {
    openapi.AddTag("Applications", "Configuration Applications Controller")
    
    return restops.EndpointTransformers{
        restops.AddEndpointPathPrefix(pathPrefixConfiguration),
        restops.AddEndpointTag("Applications"),
    }
}

Current transformers are:

  • AddEndpointTag: Adds a tag to each endpoint
  • AddEndpointPathPrefix: Adds a prefix to the path of each endpoint
  • AddEndpointErrorConverter: Sets a custom ErrorConverter for each endpoint
  • AddEndpointErrorCoder: Sets a custom ErrorCoder for each endpoint
  • AddEndpointContextInjector: Adds a Context injector to each endpoint
  • AddEndpointMiddleware: Adds an HTTP Middleware to each endpoint

EndpointBuilder

Each endpoint in the controller can be generated using one of the provided EndpointBuilder instances. go-msx provides builders for each active API style:

  • v2: Uses response envelopes and v2 pagination style
  • v8: Uses no response envelopes, v8 error response format, and v8 pagination style

Endpoint Types

Each API style builder provides a number of different endpoint types:

  • List: Return a series of entities matching a given criteria
  • Retrieve: Returns a single entity matching a primary key
  • Create: Instantiates a new entity using the supplied payload
  • Update: Replaces an existing entity using the supplied payload
  • Delete: Destroys an existing entity matching a primary key
  • Command: Executes an operation specific to the entity domain

Example

Here is an example (from lanservice) of a simple "Command" endpoint:

func (c *applicationController) applyConfiguration() restops.EndpointBuilder {
    type inputs struct {
        Id                types.UUID `req:"path"`
        IncludeSubTenants bool       `req:"query" required:"false" default:"false" description:"Include Sub Tenants"`
    }

    type outputs struct {
        Body api.ConfigurationResponse `resp:"body"`
    }

    return v2.
        NewCommandEndpointBuilder(pathSuffixConfigurationId, "applications").
        WithId("applyConfigurationForTenant").
        WithInputs(inputs{}).
        WithOutputs(outputs{}).
        WithPermissions(permissionManageSwitchConfigurations).
        WithDoc(new(openapi3.Operation).
            WithSummary("Apply configuration to a tenant")).
        WithHandler(func (ctx context.Context, inp *inputs) (out outputs, err error) {
            out.Body, err = c.applicationService.
                ApplyConfiguration(ctx, inp.Id, inp.IncludeSubTenants)
            return
        })
}

Above, you can see a number of different components:

  • input port structure: defines the fields to be retrieved from the request
  • output port structure: defines the fields to be applied to the response
  • builder: simplifies creating endpoints
    • operation name: defines the operation key in OpenApi and Tracing
    • permissions: enumerates the possible passing permission(s)
    • handler: called when the endpoint is activated
    • documentation: populates the OpenApi documentation

Handler

The Endpoint Handler accepts functions with arbitrary arguments, which it will fill out by matching the argument type. These can include:

  • context.Context: The context of the request
  • *http.Request: The inbound HTTP request being handled, allowing for manual request parsing
  • http.ResponseWriter: The outbound HTTP response to return, allowing for manual response handling
  • *inputs: The Input Port structure declared by a call to WithInputs, containing the populated inputs

The Endpoint Handler also accepts functions with arbitrary return values, which it will consume:

  • outputs: The Output Port structure declared by a call to WithOutputs, which you can populate with response outputs.
  • error: An error to be applied to the defined (or style default) error response body.

If the Output Port structure is excluded from the declaration of your return values, you are expected to use an http.ResponseWriter to manually send the success response (or return an error).

If both the Output Port structure and error are excluded, you are expected to manually send a response (whether error or success).

Standard Practice

The most common format for handler function includes context, inputs, outputs, and error:

    .WithHandler(
        func (ctx context.Context, inp *inputs) (out outputs, err error) {
            ...		
        })
Manual Handling

To manually handle the request/response cycle, use a standard go HTTP handler:

    .WithHttpHandler(
        func (resp http.ResponseWriter, req *http.Request){
            ...
        })

Custom Validation

Endpoint parameter validation can be performed in two ways:

  1. Define struct tags on each field declaring the jsonschema validation that is required. This is used to validate the format of strings, enumerations, etc.

  2. Any member of the inputs struct passed into Endpoint.Inputs which implement the validate.Validatable interface will be validated before being passed into the controller. Create and Update request bodies with complex inter-field interactions will typically use this. Common validators are provided by the github.com/go-ozzo/ozzo-validation package. A few custom validators are available in the validate package.

Any non-nil errors returned by the validation function will cause an instance of ValidationErrors to be sent back to the client (with a 400 Bad Request header) detailing the errors.

Response Codes

Success Responses

To use the default success status code (determined by which builder you used), no implementation is required. To override the success code, add a Code int resp:"code" field to your output port struct and populate it before returning from your handler.

Error Responses

REST operations have built-in default error coder, which you can override using a custom error mapper or error coder.

Default mappings include:

ErrorCode
js.ErrValidationFailed400
ops.ErrMissingRequiredValue400
rbac.ErrTenantDoesNotExist401
rbac.ErrUserDoesNotHaveTenantAccess401
repository.ErrAlreadyExists409
repository.ErrNotFound404

Lifecycle Registration

In order to instantiate your controller during application startup, you can register a simple init function:

func init() {
    app.OnCommandsEvent(
        []string{
            app.CommandRoot,
            app.CommandOpenApi,
        },
        app.EventStart,
        app.PhaseBefore,
        func (ctx context.Context) error {
            controller, err := newApplicationController(ctx)
            if err != nil {
                return err
            }
            
            return restops.
                ContextEndpointRegisterer(ctx).
                RegisterEndpoints(controller)
        })	
}

This will register your controller during normal microservice startup, as well as during OpenApi spec generation.

To ensure your module is included in the built microservice, include the module from your main.go:

import _ "cto-github.cisco.com/NFV-BU/lanservice/internal/application"