Microservices: Passing Parameters

Most of the time your microservice API methods will take parameters. This post will review how we send parameters with a web request and how we interpret those parameters correctly on the server.

Passing Parameters in a Web Request

Let’s consider how information might be communicated in a web request. First, what are the components of a web request? We have:

  • The request verb and URL
  • Request headers (including cookies)
  • The body

Below is a sample request. Since HTTP is a text-based protocol, I’m including not just the URL but also the headers and body as they would appear in the request (some headers have been omitted for clarity):

POST http://api.teamjohnston.net/services/HomeDepot.Platform.Issues.Microservice/1.0.1/Issue/resolve/42?notify=all
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVC...
Content-Length: 16 Content-type: application/json
{"restart":true}

The Request Verb

In the above request, the verb is POST. Verbs have distinct meanings. GET implies that we are reading or querying information. POST means that we are creating data (although POST is commonly used to update existing data as well). PUT specifies an update, and DELETE of course signifies a request for data to be removed.

By itself, the verb doesn’t communicate parameter information, but it does constrain our choices in how we specify parameters. A POST or PUT can include a request body, but a GET does not. Technically a GET request could include a body, but web servers will always ignore it.

The URL

At a minimum, the URL specifies the destination of the request, including a host (here api.teamjohnston.net) and optionally a port. By default, HTTP requests are sent on port 80 so no port is included in our example URL. The remainder of the URL are segments delimited by the / (slash) character. Typically the first few of these identifies a route for the request, which is information that the web server uses to determine what to do with the request. In our example, /services/HomeDepot.Platform.Issues.Microservice/1.0.1/Issue/resolve is the route information, which the server interprets as a request to the Resolve() method of the Issue class in version 1.0.1 of the Issues microservice.

Following any routing information, we might include segments of the URL that are parameter values. The remainder of our example URL is 42?notify=all. The question mark separates the URL path segments (on the left) from the query string on the right.

Headers

Following the verb and the URL, a request may contain headers, each consisting of a key and value. In our example, one of the headers has a key of “Authorization” and a value beginning with “Bearer”. Most headers have purposes that are predefined by the HTTP protocol. We can add our own headers however.

Cookies

Cookies are just a header where the key is “Cookie”. Cookies are returned in the response to a web request, and are then automatically included by the web browser in future requests to the same internet domain. Because cookies are specific to an internet domain, a cookie from teamjohnston.net will never be sent by the browser to facebook.com or any other domain.

URL segments

The URL path segments that are not host, port or routing information are interpreted as parameters by our microservice framework. In our example, there is one such value, 42. Only certain characters are allowed in a URL (letters, digits, dash, dot, underscore, tilde). Other characters (such as spaces) must be url-encoded with a % notation.

Query string

A URL can end with an optional query string that is specified by a question mark and one or more pairs of keys and values. In our example, we have one pair: the key is “notify” and the value is “all”.

POST body

When the verb is POST or PUT, a request could also include additional text. This is known as the request body. The format of this text is specified by the Content-Type header. Our microservice framework expects POST body data to be in JSON format.

Interpreting request parameters on the server

Once a microservice API web request is sent to the server, the web server must read the API function parameters from the request URL, headers and body and map them to the parameters expected by the function. In the example above, we have the following possible parameters:

  • 42 (the last URL path segment)
  • notify=all (the URL query string)
  • {“restart”:true} (the request body)

Note that I didn’t include any headers in the above list of parameters, as all of the headers sent had predefined HTTP purposes.

Let’s assume that the API method that we’re calling is Issue.Resolve(). See my previous post about type discovery and instancing for details about how we determine which microservice class and method to call based on the request URL.

Once we have received the parameters, we need to associate them with the method arguments and convert them from their text form in the request to the types expected by the method.

Which argument is it?

Some request parameters are named. In the query string pair notify=all, we can deduce that the argument name is “notify”. Similarly, since the request body is formatted as JSON, we have keys that can be mapped easily to argument names (“restart”).

When a parameter is a positional URL segment, such as “42”, we need a little help. We don’t have explicit information about the name of the parameter in this case, so we use a left-to-right mapping convention to guide us in matching positional parameters in the URL with the method arguments. But this gets complicated when some of the arguments come from the POST body or the query string.

Rather than rely purely on convention, the Forge microservice framework uses server-side method annotation (and sensible defaults) to specify the source of method arguments. For example, the Issue.Resolve() method looks like this:

[APIContract(APIContractVerb.POST)]
public async Task Resolve([APIQueryStringParameter] NotifyGroup notify, bool restart)
{
...
}

Here we are specifying that we expect a POST (indicated by the [APIContract] attribute) and that by default, all the arguments to the method will be in the POST body. Any arguments that will appear in URL path segments or in the URL query string will need to be specifically annotated.

The notify parameter is specified as a query string parameter, and restart is not annotated, so we know that it will be present in the POST body. But what about the parameter “42”?

I mentioned in my previous post that microservice API methods that are instance methods of a domain entity class require a specific instance Id parameter be supplied with the request. This lets us identify the correct instance to call the API method on. By convention, the URL for all Forge API instance methods contains the instance Id in the path segment following the API method name. So in this case, “42” is an implicit parameter that is used by the Forge framework, but is not passed to the API method itself.

For POST requests, the default parameter source is the request body. Since GET and DELETE don’t have request bodies, we expect parameters to appear in URL path segments by default, and we map them to the method arguments from left to right. Consider a method like:

[APIContract]
public IEnumerable<Movie> GetAll(string title = null, string director = null)
{
...
}

This API method appears on a repository class that represents a collection of entities, rather than an instance method of an entity class. That just means that there is no instance Id in the request URL after the /getAll segment. Thus, we expect that the URL might look like:

/services/movieService/1.0.0/movies/getAll/Reservoir%20Dogs/Tarantino

and result in the method call: GetAll(“Reservoir Dogs”, “Tarantino”)

Note that default values are present in the method signature, so for example if the director was not specified in the URL, the call to GetAll() would receive a null value for the director parameter. Note also that the default verb (if not specified in [APIContract]) is GET.

Nullable parameters and default values
If a parameter type is nullable or has a default value in the method signature, extra care must be taken in designing the method signature. When specifying that such parameters will be in URL path segments, remember that the last segment included in the URL defines the last parameter value that will be provided to the method. The remaining arguments will receive null values (if nullable) or default values if a default was specified in the signature. This constraint doesn’t apply to query string parameters or POST body parameters since they don’t need to be ordered in the request.

If GetAll() specified any parameters that should appear in the query string instead of URL path segments, we would ignore those when mapping the parameters found in the URL path. For example:

[APIContract]
public IEnumerable<Movie> GetAll(string title = null, [APIQueryStringParameter] string filter, string director = null)
{
...
}

A call to this version of GetAll() might be represented as this URL:

/services/movieService/1.0.0/movies/getAll/Reservoir%Dogs/Tarantino?filter=Keitel

Here we would still use the first URL path segment after /getAll as the title parameter and the following segment as the director parameter, even though filter is the second argument in the method signature.

URL Method Templates

The last mechanism Forge provides to determine how to match request parameters to method arguments is the URL method template. This template specifies the order of parameters that will appear in the URL path segments. You can also override the name of the method that should appear in the URL. For example:

[APIContract(APIContractVerb.GET, "getEffectiveAcl/{entityType}/{entityId}/{userId}")]
public async Task<Acl> GetEffectiveAclAsync(string entityType, int entityId, int? userId)
{
....
}

The template is the second parameter to the [APIContract] attribute. In this case it doesn’t reorder the method parameters, but if the template specified {entityId} as the first parameter, we would interpret the first URL segment following /getEffectiveAcl as the entityId argument, instead of expecting entityType to come first as the method signature suggests. In this example the use case of the template is to allow a url of /services/…/acl/getEffectiveAcl instead of /services/…/acl/getEffectiveAclAsync.

Type Conversions

For the most part, parameter values that appear in the URL can be converted easily to simple types that are expected by the API method (such as string, int, enum, double, bool, DateTime). Because we want our API URLs to be human-readable and “hackable” (easily remembered and entered manually in the browser), we generally avoid complex parameters in URL path segments or in the query string. Anything that isn’t a primitive type would have to be JSON-formatted and URL-encoded, leading a very messy URL.

In the POST body, we fully expect complex parameter shapes that map directly to class type method arguments. We attempt to deserialize these to the target types, capturing the body parameters as strongly typed objects.

GET and complex parameters
The reality of parameter complexity in API methods also leads us pragmatically to use POST instead of GET for query API methods that have many or complex parameters (arrays for example). Some browsers (IE) also have a URL length limit that prevents us from specifying a long parameter value in a GET.

Form data and file uploads

A common scenario when calling an API method is to provide parameters specified in an HTML form submission. In this case the FormData object in the browser can be used to collect a set of keys and values. If any of the values are files selected by the user, the request body must be sent as multipart form data instead of JSON format. To indicate that an API method will receive form data, we specify one of the method parameters as having the FormData type:

[APIContract(APIContractVerb.POST, "uploadImage/{userId}")]
public async Task UploadImageAsync([APIPathParameter] int userId, FormData form)
{
var image = form.Files.FirstOrDefault();
await _userImageRepository.Upload(userId, image);
}

Inside the API method, we can use the form parameter to get at any files uploaded in the form data (through the Files property) as well as any other form values (through the Parameters property).

Note that the verb for a method accepting form data must be a POST. The name of the FormData parameter is not important, as it represents the entire POST body.

The above method is a good example of many of the parameter mechanisms that we have described:

  • Default parameter sources (form is a POST parameter because it is in a POST method and is not annotated)
  • Explicit parameter sources (userId would normally be a POST parameter, but here is annotated as being a URL path parameter)
  • URL template (the UploadImageAsync method is called using the /uploadImage endpoint)

Encoded parameters

An additional annotation allows us to specify that a method parameter must be encoded. This is not encryption! We’re relying on SSL/TLS to secure the connection between the browser and our microservice. We provide simple encoding only to prevent casual inspection of sensitive parameters that might be sent to an API method during a debugging session. For example, the Identity microservice has an endpoint /user/changePassword that routes to this method:

[APIContract(APIContractVerb.POST)]
public async Task ChangePassword([APIPostParameter(Encode = true)] string password)
{
...
}

The Encode option means that the password parameter will be sent in base64 encoded form, and should be decoded before passing the parameter value to the ChangePassword() method.

As you can see, passing parameters from the client to the microservice API requires some thought, but the mechanisms are relatively straightforward. Annotations on the microservice class methods provide us with the information we need to easily interpret the source and form of the parameters.

In a future post I’ll discuss how these annotations can be used to surface information about the available microservice API methods and how to call them. This is particularly interesting for developers who want to build clients that call the API methods. They need to know the names and types of the API parameters, as well as whether each parameter belongs in the URL or request body. We’ll also see how JavaScript proxy scripts can be generated by the microservice itself to simplify the work of formatting requests in the client.


Originally published at teamjohnston.net on July 18, 2016.