Papupata Documentation

Guide: Query-based variants

Overview

Sometimes you have APIs that only differ from each other based on their query parameters. For example, the response could be of a different type based on value of one. Papupata offers a way to deal with this.

Availability

This functionality is available from papupata version 1.8.0 onwards.

Table of contents

Prerequisites

Before starting this guide, you need to have an API declaration, with declared APIs. For information on how to get there, see Declaring APIs.

For the examples in the guide, the following code is assumed to be present in the scope:

import { APIDeclaration } from 'papupata'
const API = new APIDeclaration()

The Basics

Query parameter based API selection is embedded into the path portion of an API declaration. This is because it, like everything else in the path, affects the routing that connects the request to the correct handler.

The query parameter routing information is presented in a fashion not unlike regular query parameters, but there are extensions to the syntax.

const api = API.declareGetAPI('/myAPI?variant=orange').response<void>()

There can be any number of query parameter based constraints on an API, even multiple ones for the same parameter when it makes sense.

const api = API.declareGetAPI('/myAPI?variant=orange&travel=underwater&liquid=water').response<void>()

The Specifics

Specific (hard-coded) values

Requiring a query parameter to have a specific value is done with the regular query string syntax, such as

const api = API.declareGetAPI('/myAPI?variant=orange').response<void>()

requires all invocations of the API to have the query parameter variant set to orange. When using papupata to call the API, it automatically sets up the variable, and in fact you generally speaking cannot set it manually. That is, making the call:

await api()

You are not expected to include {variant: 'orange'} in the call, although it will be included in the actual REST call.

On the server, according to type information that parameter is not available, but it is in fact there in case, say, a middleware needs access to it. If you really need it in the types, you can add the parameter into the optionalQuery portion of the declaration, even though it is not actually optional. This is done to ensure that the invocation does not require the user to enter it.

You can have multiple possible values for the parameter. Of course the parameter cannot be all of them at once (as arrays are not supported), so this just means that any of the values will do. Note that this creates an ambiguous call.

const api = API.declareGetAPI('/myAPI?variant=orange&variant=red&variant=yellow').response<void>()
Non-matching value

You can have an API that only applies when a parameter has a value that is not one of the ones you've specified. Instead of the usual name=value notation, you instead use value!=value.

Older styles are still supported in later versions, just not preferred.
const api = API.declareGetAPI('/myAPI?variant!=orange')
  .query({variant: String})
  .response<void>()

requires all invocations of the API to have the query parameter variant not to be set to orange

await api({variant: 'purple'})

If you attempt to supply one of the forbidden values for a client invocation, it will throw an exception. On the server, this is just a regular query parameter.

Do note that the lack of a value is considered to pass this check, so the following is fine as well:

const api = API.declareGetAPI('/myAPI?variant!=orange').response<void>()
await api()

While it is a bit odd, this way the non-matching value is the exact opposite of the specific value routing, covering all cases. If you specifically want there to be a value which is not one of the ones you've provided, you can combine this with "any value" routing rule.

Older styles are still supported in later versions, just not preferred.
const api = API.declareGetAPI('/myAPI?variant!=orange&variant')
  .query({variant: String})
  .response<void>()
Lack of query parameter

You can indicate that the API variant is only to be called when a query parameter is not present by prepending an exclamation mark to the name of the parameter.

const api = API.declareGetAPI('/myAPI?!variant')
  .response<void>()

Do note that an empty value (calling the REST API with something like /myAPI?variant= or /myAPI?variant) is considered to be a call with the parameter present, and as such will not match the API as declared. You can declare an API that accepts both by combining it with specific value routing.

const api = API.declareGetAPI('/myAPI?!variant&variant=')
  .response<void>()

This creates an ambiguous call.

Presence of query parameter

You can indicate that the API variant is only to be called when a query parameter is present by simply having its name, with no equality or value in the query string part.

Older styles are still supported in later versions, just not preferred.
const api = API.declareGetAPI('/myAPI?variant')
  .query({variant: String})
  .response<void>()

Do note that an empty value (calling the REST API with something like /myAPI?variant= or /myAPI?variant) is considered to be a call with the parameter present and is match for this rule. You can specifically exclude empty values by using the non-matching value routing as well.

const api = API.declareGetAPI('/myAPI?variant&variant!=')
  .response<void>()

Ambiguous calls

By combining routing rules you can end up in a situation where there are multiple possible values for a query parameter. In these cases, when invoking the API using papupata, the client is guaranteed to choose valid parameters, but it may choose any valid combination in a potentially unpredictable fashion.

The big gotcha

Unless you really know what you're doing, having overlapping API declarations is a good way to cause issues for yourself down the line. That is, overlapping in the sense that as far as routing is concerned, multiple APIs could be valid for a single call.

To avoid doing this when using query-based API variants, instead of going with a default "anything not handled goes here" route, ensure your APIs use negations of the rules of the other routes to allow non-ambiguous routing.

A few examples:

Specific value and non-specific value

API.declareGetAPI('/api?variant=alpha')
API.declareGetAPI('/api?variant!=alpha')

Multiple specific values

API.declareGetAPI('/api?variant=alpha')
API.declareGetAPI('/api?variant=beta')
API.declareGetAPI('/api?variant!=alpha&variant!=beta')

Presence and non-presence

API.declareGetAPI('/api?variant')
API.declareGetAPI('/api?!variant')

Presence of non-empty and non-present/empty

API.declareGetAPI('/api?variant&variant!=')
API.declareGetAPI('/api?!variant&variant=')

If this seems impractical because of, say, complexity, you can have overlap as long as you consider its implications, specifically how routing considers routes:

  • if autoImplementAllAPIs is set to false (default in papupata 1.x), the variants are checked in their implementation order
  • if autoImplementAllAPIs is set to true (default in papupata 2.x), the variants are checked in their declaration order
  • If you encounter misrouting (whether in the route implementation or a middleware leading there), you can import skipHandlingRoutefrom papupata and return it to resume routing from later variants.

Use cases

Probably the most likely use case are APIs that either accept differents sets of parameters based on other parameters, or ones that return different data based on the query parameters.

So let's have an example of an API with different sets of inputs:

Older styles are still supported in later versions, just not preferred.
const simpleSearch = API.declarePostAPI('/search?advanced!=true')
  .query({queryString: String})
  .response<any>()

const advancedSearch = API.declarePostAPI('/search?advanced=true')
  .query({name: String, address: String, phone: String, email: String}})
  .response<any>()

So basically a single endpoint serves two types of search functionality, but using the query-based API variants we can have both of them work perfectly in a typed fashion.

And for different responses, perhaps something like this:

const myDetailsAPI = API.declarePostAPI('/my-details?includeRelations=false')
  .response<UserDetails>()

const myDetailsWithRelationsAPI = API.declarePostAPI('/my-details?includeRelations=true')
  .response<UserDetails & RelationInfo>()

Again, a single API and two types of output, fully typed.