In this article, I aim to demonstrate a couple of methods for easily adding validation to REST APIs on InterSystems IRIS Data Platform. I believe a specification-first approach is an excellent idea for API development. IRIS already has features for generating an implementation stub from a specification and publishing that specification for external developers (use it with iris-web-swagger-ui for the best results). The remaining important thing not yet implemented in the platform is the request validator. Let's fix it!
The task sounds as follows: all inbound requests must be validated against the schema of the API described in OpenAPI format. As you know, the request contains: method (GET, POST, etc.), URL with parameters, headers (Content-Type, for example), and body (some JSON). All of it can be checked. To solve this task, I will be using Embedded Python because the rich library of open source Python code already has 2 suitable projects: openapi-core, openapi-schema-validator. One limitation here is that the IRIS Data Platform is using Swagger 2.0, an obsolete version of OpenAPI. Most of the tools do not support this version, so the first implementation of our validator will be restricted to checking the request body only.
Solution based on the openapi-schema-validator
Key inputs:
- Solution is fully compatible with the recommended InterSystems specification-first way in API development. No need for modifications in the generated API classes, except for the one small, more on this later
- Checking the request body only
- We need to extract the definition for the request type from the OpenAPI specification (class
spec.cls)
- The matching request JSON to the spec definition is done by setting a vendor-specific content type
First, you need to set a vendor-specific content type in the consumes property of the OpenAPI specification for your endpoint. Which one must look something like this: vnd.<company>.<project>.<api>.<request_type>+json. For example, I will use:
"paths":{
"post":{
"consumes":[
"application/vnd.validator.sample_api.test_post_req+json"
],
...
Next, we need a base class for our dispatch class. Here is the full code of this class; the code is also available on Git.
Class SwaggerValidator.Core.REST Extends %CSP.REST
{
Parameter UseSession As Integer = 1
ClassMethod OnPreDispatch(pUrl As %String, pMethod As %String, ByRef pContinue As %Boolean) As %Status
{
Set tSC = ..ValidateRequest()
If $$$ISERR(tSC) {
Do ..ReportHttpStatusCode(##class(%CSP.REST).#HTTP400BADREQUEST, tSC)
Set pContinue = 0
}
Return $$$OK
}
ClassMethod ValidateRequest() As %Status
{
Set tSC = ##class(%REST.API).GetApplication($REPLACE($CLASSNAME(),".disp",""), .spec)
Return:$$$ISERR(tSC) tSC
Set defName = $PIECE($PIECE(%request.ContentType, "+", 1), ".", *)
Return:defName="" $$$ERROR($$$GeneralError, $$$FormatText("No definition name found in Content-Type = %1", %request.ContentType))
Set type = spec.definitions.%Get(defName)
Return:type="" $$$ERROR($$$GeneralError, $$$FormatText("No definition found in specification by name = %1", defName))
Set schema = type.%ToJSON()
Set body = %request.Content.Read()
Try {Set tSC = ..ValidateImpl(schema, body)} Catch ex {Set tSC = ex.AsStatus()}
Return tSC
}
ClassMethod ValidateImpl(schema As %String, body As %String) As %Status [ Language = python ]
{
try:
validate(json.loads(body), json.loads(schema))
except Exception as e:
return iris.system.Status.Error(5001, f"Request body is invalid: {e}")
return iris.system.Status.OK()
}
XData %import [ MimeType = application/python ]
{
import iris, json
from openapi_schema_validator import validate
}
}
We are doing the next things here:
- Overrides
OnPreDispatch() for adding validation. This code will execute for each call of our API
- Uses
##class(%REST.API).GetApplication() to get the specification in a dynamic object (JSON)
- Extracts the definition name from the Content-Type header
- Takes the request schema by definition name:
spec.definitions.%Get(defName)
- Sends request schema + request body to Python code for validation
As you see, it is all pretty simple. Now all you need to do is change the Extends section of your disp.cls to SwaggerValidator.Core.REST. And of course, install the openapi-schema-validator Python library to the server (as described here).
Solution based on the openapi-core
Key inputs:
- This solution works with a hand-coded REST interface. We do not use API Management tools to generate the code from the OpenAPI specification. We only have a REST service as a
%CSP.REST subclass
- So, we are not attached to the 2.0/JSON version and will be using OpenAPI 3.0 in YAML format. This version offers more opportunities, and I find YAML more readable
- The following elements will be checked: path and query parameters in the URL, Content-Type, and request body
For starters, let's take our specification located on <server>/api/mgmnt/v1/<namespace>/spec/<web-application>. Yes, we have a generated OpenAPI specification even for manually coded REST APIs. This is not a complete spec because it has no schemas of requests and responses (the generator does not know where to get them). But the platform has already done half the work for us. We need to convert this specification to OpenAPI 3.0/YAML format and add definitions for request/responses. You can use a converter or just ask Codex:
Please, convert spec in class @Spec.cls to Swagger version 3.0 and YAML format
In the same way, we can ask Codex to generate request/response schemas based on JSON samples.
BTW, vibe coding works pretty well in IRIS development, but it is a subject for a separate topic. Please, let me know if it is interesting for you!
As in the previous solution, we need a base class for our %CSP.REST. This class is very similar:
Class SwaggerValidator.Core.RESTv2 Extends %CSP.REST
{
Parameter UseSession As Integer = 1
ClassMethod OnPreDispatch(pUrl As %String, pMethod As %String, ByRef pContinue As %Boolean) As %Status
{
Set tSC = ..ValidateRequest()
If $$$ISERR(tSC) {
Do ..ReportHttpStatusCode(##class(%CSP.REST).#HTTP400BADREQUEST, tSC)
Set pContinue = 0
}
Return $$$OK
}
ClassMethod ValidateRequest() As %Status
{
Set tSC = ..GetSpec(.swagger)
Return:$$$ISERR(tSC)||(swagger="") tSC
Set canonicalURI = %request.CgiEnvs("REQUEST_SCHEME")_"://"_%request.CgiEnvs("HTTP_HOST")_%request.CgiEnvs("REQUEST_URI")
Set httpBody = $SELECT($ISOBJECT(%request.Content)&&(%request.Content.Size>0):%request.Content.Read(), 1:"")
Set httpMethod = %request.CgiEnvs("REQUEST_METHOD")
Set httpContentType = %request.ContentType
Try {
Set tSC = ..ValidateImpl(swagger, canonicalURI, httpMethod, httpBody, httpContentType)
} Catch ex {
Set tSC = ex.AsStatus()
}
Return tSC
}
ClassMethod GetSpec(Output specification As %String, xdataName As %String = "OpenAPI") As %Status
{
Set specification = ""
Set specClassName = $CLASSNAME()
Set $PIECE(specClassName, ".", *) = "Spec"
Return:'##class(%Dictionary.ClassDefinition).%Exists($LISTBUILD(specClassName)) $$$OK
Set xdata = ##class(%Dictionary.XDataDefinition).%OpenId(specClassName_"||"_xdataName,,.tSC)
If $$$ISOK(tSC),'$ISOBJECT(xdata)||'$ISOBJECT(xdata.Data)||(xdata.Data.Size=0) {
Set tSC = $$$ERROR($$$RESTNoRESTSpec, xdataName, specClassName)
}
Return:$$$ISERR(tSC) tSC
Set specification = xdata.Data.Read()
Return tSC
}
ClassMethod ValidateImpl(swagger As %String, url As %String, method As %String, body As %String, contentType As %String) As %Status [ Language = python ]
{
spec = Spec.from_dict(yaml.safe_load(swagger))
data = json.loads(body) if (body != "") else None
headers = {"Content-Type": contentType}
req = requests.Request(method=method, url=url, json=data, headers=headers).prepare()
openapi_req = RequestsOpenAPIRequest(req)
try:
validate_request(openapi_req, spec=spec)
except Exception as ex:
return iris.system.Status.Error(5001, f"Request validation failed: {ex.__cause__ if ex.__cause__ else ex}")
return iris.system.Status.OK()
}
XData %import [ MimeType = application/python ]
{
import iris, json, requests, yaml
from openapi_core import Spec, validate_request
from openapi_core.contrib.requests import RequestsOpenAPIRequest
}
}
What to look out for: a class that contains a specification must be named Spec.cls and located in the same package as your %CSP.REST implementation. Specification class looks like:
Class Sample.API.Spec Extends %RegisteredObject
{
XData OpenAPI [ MimeType = application/yaml ]
{
... your YAML specification ...
}
}
To enable validation, you just need to extend your API class by inheriting from SwaggerValidator.Core.RESTv2 and place the Spec.cls file next to it.
That is all that I wanted to tell you about Swagger validation. Please feel free to ask me questions.