REST API Versioning
- 1 Introduction
- 2 The accept annotation argument
- 3 Versioning Business Entity resources (@RestAddress)
- 4 Versioning invokable methods (@RestMethod)
- 5 Worked sample: versioning by media type parameter
- 6 Content negotiation rules
- 7 Response Content-Type
- 8 Swagger / OpenAPI documentation
- 9 Relationship to the contentType argument (SCL-5022)
- 10 Behavior summary
Introduction
There are multiple schemes for versioning REST APIs: URI versioning (/v2/Customers), query string versioning (?version=2), custom header versioning and media type versioning using the Accept header (see REST API Versioning for an overview). The SmartComponent Library implements media type versioning (SCL-2349): the client requests a specific version of a resource representation by sending a (typically vendor-specific) media type in the Accept header of the request:
GET /web/Entities/Customers/42 HTTP/1.1
Accept: application/vnd.consultingwerk.customer.v2+jsonMedia type versioning keeps the resource URI stable across versions - the same URL identifies the same resource, and the Accept header selects the representation. This is implemented for both @RestAddress endpoints (Business Entities exposed as RESTful resources, see RESTful services) and @RestMethod endpoints (invokable Business Task and Business Entity methods, see Support for RESTful invocation of Business Task and Business Entity Methods).
The feature is fully backward compatible: endpoints that do not use the new accept annotation argument behave exactly as before.
The accept annotation argument
Both the @RestAddress and the @RestMethod annotation support the optional accept argument. Its value is a comma-delimited list of media types served by the endpoint:
@RestMethod (address="/Customer", requestMethod="get",
parameterClassName="Consultingwerk.IntegerHolder",
accept="application/vnd.consultingwerk.customer.v2+json,application/vnd.consultingwerk.customer.v2.1+json",
response="return").Key characteristics:
When the
acceptargument is not specified, the endpoint matches anyAcceptheader - the current (Accept header agnostic) behavior is preserved.When the
acceptargument is specified, the endpoint is only used when the request'sAcceptheader accepts one of the listed media types.The URL of an endpoint is no longer unique: uniqueness is defined by the combination of the URL and the media type. The same URL may be registered multiple times with different
acceptmedia types - by different methods of the same class, by methods of different Business Tasks, or by different Business Entities.The version may be expressed as part of the media type name (
application/vnd.consultingwerk.customer.v2+json) or as a media type parameter (application/vnd.sports2000+json;version=2.0) - see the worked sample below.
Versioning Business Entity resources (@RestAddress)
Different versions of the same resource URI can be served by different Business Entities. A typical setup keeps the existing (unversioned) Business Entity as the default and adds a second Business Entity for the new representation:
/* CustomerBusinessEntity - serves the current (default) representation */
@RestAddress (type="record", address="/Customers/~{CustNum}", tables="eCustomer", id="CustNum",
fields="eCustomer.*", canRead="true", canUpdate="true")./* CustomerV2BusinessEntity - serves version 2 of the same resource */
@RestAddress (type="record", address="/Customers/~{CustNum}", tables="eCustomer", id="CustNum",
fields="eCustomer.*", canRead="true", canUpdate="true",
accept="application/vnd.consultingwerk.customer.v2+json").Requests are routed based on the Accept header:
GET /web/Entities/Customers/42
Accept: application/vnd.consultingwerk.customer.v2+json
--> served by CustomerV2BusinessEntity
Response Content-Type: application/vnd.consultingwerk.customer.v2+jsonGET /web/Entities/Customers/42
Accept: application/json
--> served by CustomerBusinessEntity (the default, no accept argument)
Response Content-Type: application/jsonVersioning invokable methods (@RestMethod)
Different versions of the same invokable endpoint are typically implemented as separate methods - either in the same Business Task or Business Entity, or in different ones. Every method carries its own @RestMethod annotation for the same address:
@RestMethod (address="/Customer", requestMethod="get",
parameterClassName="Consultingwerk.IntegerHolder",
accept="application/vnd.consultingwerk.customer.v1+json",
response="return").
METHOD PUBLIC JsonObject GetCustomerV1 (poParameter AS IntegerHolder):
@RestMethod (address="/Customer", requestMethod="get",
parameterClassName="Consultingwerk.IntegerHolder",
accept="application/vnd.consultingwerk.customer.v2+json,application/vnd.consultingwerk.customer.v2.1+json",
response="return").
METHOD PUBLIC JsonObject GetCustomerV2 (poParameter AS IntegerHolder):
@RestMethod (address="/Customer", requestMethod="get",
parameterClassName="Consultingwerk.IntegerHolder",
response="return").
METHOD PUBLIC JsonObject GetCustomer (poParameter AS IntegerHolder):A request with Accept: application/vnd.consultingwerk.customer.v2.1+json is routed into GetCustomerV2 (the second entry of the comma-delimited list matches) and the response is returned with Content-Type: application/vnd.consultingwerk.customer.v2.1+json. A request with Accept: application/json is routed into the unversioned GetCustomer method.
Worked sample: versioning by media type parameter
Instead of encoding the version in the media type name, the version may be expressed as a media type parameter. The base media type is then identical for all versions and the parameter (typically version) selects the representation. The following sample exposes the Sports2000 Employee table in two versions through two Business Entities that share the same resource URIs.
Version 1 - Demo.RestApiVersioning.Employees.V1.EmployeeBusinessEntity
Version 1 exposes the plain Employee record. Both the record and the collection address declare the version=1.0 media type:
@RestAddress (type="record", address="/Employees/~{EmpNum}", tables="eEmployee", id="EmpNum",
fields="eEmployee.*", canRead="true", canUpdate="true", canDelete="true",
tags="Employees",
accept="application/vnd.sports2000+json;version=1.0").
@RestAddress (type="collection", address="/Employees", tables="eEmployee", id="EmpNum",
fields="EmpNum,LastName,FirstName,Address,Address2", canCreate="true", canDelete="true",
tags="Employees",
accept="application/vnd.sports2000+json;version=1.0").
class Demo.RestApiVersioning.Employees.V1.EmployeeBusinessEntity
inherits BusinessEntity:Version 2 - Demo.RestApiVersioning.Employees.V2.EmployeeBusinessEntity
Version 2 exposes a changed representation of the same resource - its dataset adds a calculated FullName field and the collection returns a different field list. The accept list declares the version=2.0 media type and application/json, making version 2 also the representation served to plain JSON clients:
@RestAddress (type="record", address="/Employees/~{EmpNum}", tables="eEmployee", id="EmpNum",
fields="eEmployee.*", canRead="true", canUpdate="true", canDelete="true",
tags="Employees",
accept="application/vnd.sports2000+json;version=2.0,application/json").
@RestAddress (type="collection", address="/Employees", tables="eEmployee", id="EmpNum",
fields="EmpNum,LastName,FirstName,FullName,Address", canCreate="true", canDelete="true",
tags="Employees",
accept="application/vnd.sports2000+json;version=2.0,application/json").
class Demo.RestApiVersioning.Employees.V2.EmployeeBusinessEntity
inherits BusinessEntity:Request samples
Version 1 - the version=1.0 media type parameter routes into the V1 Business Entity:
GET /web/Entities/Employees/1 HTTP/1.1
Accept: application/vnd.sports2000+json;version=1.0HTTP/1.1 200 OK
Content-Type: application/vnd.sports2000+json;version=1.0
{
"id": 1,
"url": "http://localhost:8820/web/Entities/Employees/1",
"EmpNum": 1,
"LastName": "Sterling",
"FirstName": "Justine",
"Address": "152 Dudley Court",
"Address2": "",
"City": "Wollaston",
"State": "MA",
"PostalCode": "02127",
...
}Version 2 - the version=2.0 media type parameter routes into the V2 Business Entity, whose representation carries the additional FullName field:
GET /web/Entities/Employees/1 HTTP/1.1
Accept: application/vnd.sports2000+json;version=2.0HTTP/1.1 200 OK
Content-Type: application/vnd.sports2000+json;version=2.0
{
"id": 1,
"url": "http://localhost:8820/web/Entities/Employees/1",
"EmpNum": 1,
"LastName": "Sterling",
"FirstName": "Justine",
"FullName": "Justine Sterling",
"Address": "152 Dudley Court",
...
}Plain JSON clients - application/json is listed in the V2 accept list, so clients that do not use the vendor media type receive version 2:
GET /web/Entities/Employees HTTP/1.1
Accept: application/jsonHTTP/1.1 200 OK
Content-Type: application/json
[
{
"id": 1,
"url": "http://localhost:8820/web/Entities/Employees/1",
"EmpNum": 1,
"LastName": "Sterling",
"FirstName": "Justine",
"FullName": "Justine Sterling",
"Address": "152 Dudley Court"
},
...
]Unsupported version - a version no endpoint serves is rejected with HTTP 406 (Not Acceptable):
GET /web/Entities/Employees/1 HTTP/1.1
Accept: application/vnd.sports2000+json;version=3.0HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"title": "An application error has occurred (Consultingwerk.OERA.RestResource.NotAcceptableException)",
"error": "Consultingwerk.OERA.RestResource.NotAcceptableException",
"message": "The media types accepted by the request (application/vnd.sports2000+json;version=3.0) are not served by the REST endpoint /Employees/1",
...
}Parameter matching rules
Every parameter declared by the endpoint's media type (e.g.
version=1.0) must be present in the request'sAcceptheader entry with the same value for an exact match.A request whose
Acceptheader entry names the base media type without the parameter (e.g. justapplication/vnd.sports2000+json) does not contradict any version - it is treated like a wildcard match, so the first registered version serves the request.A request with a contradicting parameter value (e.g.
version=3.0when only 1.0 and 2.0 are served) matches no endpoint and receives HTTP 406 (Not Acceptable).The quality value (
;q=0.8) and the accept extension parameters following it are ignored -Accept: application/vnd.sports2000+json;version=2.0;q=0.9still selects version 2.0.Parameters of the request that the endpoint's media type does not declare (e.g.
charset) are ignored.
Content negotiation rules
The RestResourceService negotiates the media type between the request's Accept header and the media types of the registered endpoints using the following rules:
Exact match wins. An endpoint whose
acceptlist contains a media type explicitly listed in the request'sAcceptheader (including its media type parameters, see above) is selected first.Default endpoints are second. When no exact match exists, an endpoint without an
acceptargument serves the request (the current, Accept header agnostic behavior).Wildcard matches are third. An endpoint whose
acceptlist is only matched by a wildcard entry of theAcceptheader (*/*or e.g.application/*) - or by a base media type without the endpoint's parameters - is used last. This ordering matters for web browsers, which typically sendAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8- the trailing*/*must not "steal" the request from an explicitly requested media type or from the default endpoint.
Additional rules:
The quality value (
;q=0.8) and the accept extension parameters following it are ignored during the comparison; media type parameters such as;version=1.0are compared.A request without an
Acceptheader accepts any media type (per RFC 7231 a missing Accept header implies*/*). The RestEntitiesWebHandler defaults a missing Accept header toapplication/json.When the request URI matches registered endpoints, but none of them serves a media type accepted by the request, a Consultingwerk.OERA.RestResource.NotAcceptableException is raised, which is returned to the client as HTTP status code 406 (Not Acceptable).
For invokable methods without an
acceptargument, the established fallback is preserved: when the URI and request method match exactly one method, that method is invoked even when its content type does not match the Accept header.
Response Content-Type
When an endpoint was selected through its accept media types, the negotiated media type (the concrete entry of the endpoint's accept list that matched the request, including its media type parameters) becomes the Content-Type of the HTTP response. The negotiated media type is available to framework code through the NegotiatedContentType property of the resolved IRestRequest. For endpoints without the accept argument the response Content-Type is determined as before (the contentType argument of the @RestMethod annotation, or application/json).
Swagger / OpenAPI documentation
The generated Swagger document (see SwaggerRestEntitiesGenerator) reflects the versioned endpoints:
Every media type of an endpoint's
acceptlist is documented as a separate entry of the responsecontentobject - the documented media type is no longer alwaysapplication/json.When the same URL and HTTP method are served by multiple methods or Business Entities with different
acceptmedia types, they are merged into a single OpenAPI operation whose response content documents every media type with the schema of the endpoint serving it (the OpenAPI specification allows only one operation per path and HTTP method).
For the Employee sample above, the /Employees/{EmpNum} GET operation documents three media types - application/vnd.sports2000+json;version=1.0 with the version 1 schema, and application/vnd.sports2000+json;version=2.0 / application/json with the version 2 schema (including FullName):
{
"responses": {
"200": {
"content": {
"application/vnd.sports2000+json;version=1.0": { "schema": { ... } },
"application/vnd.sports2000+json;version=2.0": { "schema": { ... } },
"application/json": { "schema": { ... } }
}
}
}
}Relationship to the contentType argument (SCL-5022)
The @RestMethod annotation also supports the contentType argument (SCL-5022), which assigns a single media type to a method for content negotiation and response formatting - e.g. exposing the same address as application/json, text/xml and text/plain variants. The two arguments are complementary, not conflicting:
contentTypetargets format negotiation: the same version of a resource in different formats (JSON, XML, plain text).accepttargets versioning: different versions of a resource, each identified by one or more (vendor-specific) media types.When both are specified on the same annotation, the
acceptlist is used for routing and the negotiated media type becomes the response Content-Type; thecontentTypeargument then only serves as the fallback response Content-Type.Methods that only use
contentTypeparticipate in negotiation as before, with exact matches preferred over wildcard matches.
Behavior summary
Scenario | Result |
|---|---|
Request accepts a media type listed in an endpoint's | That endpoint serves the request; the response Content-Type is the negotiated media type |
Request accepts a media type of the second, third, ... entry of a comma-delimited | The endpoint serves the request; the response Content-Type is the matched entry |
Request carries a version media type parameter (e.g. | The endpoint declaring that parameter value serves the request |
Request carries a version media type parameter no endpoint serves (e.g. | HTTP 406 Not Acceptable (NotAcceptableException) |
Request accepts none of the versioned media types, a default endpoint (no | The default endpoint serves the request (current behavior) |
Request accepts none of the media types, no default endpoint exists for the URL | HTTP 406 Not Acceptable (NotAcceptableException) |
Request sends no | The default endpoint is preferred; without a default endpoint the first versioned endpoint serves the request |
Browser-style Accept header with trailing | The explicitly listed media type wins over the wildcard |