-
Notifications
You must be signed in to change notification settings - Fork 90
rest layer api
The http-rs module is designed to define and run a broad range of HTTP REST Services.
var rs = require('http/rs'); // serve GET HTTP requests sent to resource path "" (i.e. directly to hello-api.js) rs.service() .resource("") .get(function(ctx, request, response){ response.println("Hello there!"); }) .execute();
Sending GET /services/js/v3/test/hello-api.js
to the server, hosting the piece of code above in test/hello-api.js
will return response body "Hello there!"
.
Consider the simple sample above. Let's have a closer look at the methods chained in this fluent API.
First, we requested a new REST service instance from the framework:
rs.service()
Next, we configured the instance to serve HTTP GET requests sent to root path "", using the supplied function:
.resource("") .get(function(ctx, request, response){ response.println("Hello there!"); })
Technically, configuration is not required to execute a service, but obviously it will do nothing, if you don't instruct it what to do.
Finally, we run the service and it processes the HTTP request:
.execute();
Now, this is a fairly simplistic example aiming to give you a hint of how you can bring up a REST API to life with http-rs and there is a whole lot more that we shall explore in the next sections.
rs.service()
Creating new service instances is a simple as invoking rs.service()
. That returns a configurable and/or executable instance of the HttpController
class. The controller API allows to
- start configuring rest service (method
resource
) - serve requests (method
execute
)
and a couple of more advanced activities, which will be reviewed in the Advanced section below.
Additionally, the controller API features (since 3.1.5) also shortcut factory methods that are useful for simplistic configurations (such as the one in our initial example) such as get(sPath, fServe, arrConsume, arrProduces)
. Read below for more examples how to use.
execute()
The mechanism for serving requests is implemented in the HttpController
's execute
method. It will try to match the request to the service API configuration and upon success it will trigger the callback functions execution flow that will ultimately process the request and response. Or it will send a Bad Request error to the client otherwise. The request and response objects are implicitly those that were used to request the script where the execute method invocation occurred. But they can be exchanged for others as shown in the Advanced section.
The execute
method is defined in the service instance (class HttpController
) obtained with rs.service()
and can be triggered with e.g. rs.service().execute()
. The fluent configuration API also provides numerous references to the method so you can invoke it on any stage. For example,
rs.service().get("").execute()
rs.service().resource("").get().execute()
rs.service().resource("").get().produces(["text/json"]).execute()
are all valid ways to serve requests.
What you need to consider is that execute
must be the final method invocation. Even if you retain a reference to a configuration object and change it after that, it will be irrelevant since the response
will be flushed and closed by then.
There are three options as far as configuration is concerned.
- You can start from scratch and build the configuration using the rs fluent API, or
- you can use configuration objects that are essentially the same thing that the fluent API produces. And finally,
- the third option is to start with configuration object and then enhance or override the configuration using the fluent API.
Configuration objects
A configuration object is a JS object with canonical structure that rs can interpret. We will discuss its schema later on in this guide. For now, let's just consider that it's the same thing that the fluent API will actually produce behind the scenes so it's a completely valid alternative and complement to the fluent API configuration approach. Refer to the Advanced section for more details on using configuration objects.
resource(sPath, oConfiguration?)
Resources are the top-level configuration objects that represent an HTTP (server) resource, for which we will be defining a protocol. Each resource is identified by a URL on the server. You can have multiple resources per service configuration, provided that their URLs do not overlap.
Resource vs Path vs Resource Path
As per the REST terms, a resource is an abstraction or server-side resource that can be a file, a dynamically generated content or a procedure (although the last is considered heresy by purists). It's virtually anything hosted on a server that has address and can be accessed with a standard HTTP method. It is often referred to as 'path' or 'resource path' due to its singular most notable identifying characteristic. But to be precise, path is only a property of the resource. As far as configuration is concerned, the resource defines the configuration scope for which we define method handlers and constraints and is identifiable by its path property.
The sPath
string parameter of the resource method will serve as the resource URL and it is obviously mandatory. The path is relative to the path of the service script that runs the service. No path, i.e. request directly to the script, is an empty string.
The path can also be a URL template, i.e. parameterized.
For example consider the path template:
{id}/assets/{assetType}/{name}
This will resolve request paths such as /services/js/test.js/1/assets/longterm/building
to service path: 1/assets/longterm/building
.
If a request is matched to such path, the service mechanism will provide the resolved parameters as an object map to the function that handles the request. Using the sample path above the path parameters object will look like this:
{ "id": 1, "assetType": "longterm", "name": "building" }
Resource class API (link pending)
resource.get()
resource.post()
resource.put()
resource["delete"]()
and resource.remove()
resource.method()
By default, only the HTTP request methods that you have configured for a resource are allowed. The fluent API of Resource
instances, obtained with the resource(sPath)
method that we discussed above, exposes the most popular REST API methods (get
,post
,put
and delete
). They are simply aliases for the generic method method
.
Whichever we consider, we will receive a ResourceMethod
instance from the invocation and its API will allow us to specify processing functions and further specify constraints on the request/response for which they are applicable:
rs.resource('').get().produces(["application/json"]).serve(function(){})
Alternatively, as we have already seen, we can supply the serve
callback function directly as first argument to the method, which comes in handy if we have nothing more to setup (see Shortcuts below):
rs.resource('').get(function(){})
We can also use configuration object as a third option and this will be discussed in the Advanced section.
The samples here are all for configuring HTTP GET Method but the usage pattern is still the same for all:
rs.resource('').post().consumes(["application/json"]).serve(function(){})
You already noticed that instead of explicitly using serve
to configure callback for serving the requests we could directly provide the function as argument to the method configuring the HTTP method (e.g. get
).
rs.resource('').get(function(){})
rs.resource('').get().serve(function(){})
So why bother provisioning an explicit serve
function in the first place then? The answer is that serve
configures only one of the callback functions that are triggered during the request processing flow. And this shortcut is handy if it is only serve
that you are interested into configuring. Of course, nothing prevents you also from using the shortcut and still configure the other callback functions, unless you find it confusing. These are all valid options. Find out more about configuring request processing callback functions in the section dedicated to this.
When the controller API was discussed, it was mentioned that there are shortcut factory methods that combine a couple operations to produce directly a method handler for a resource path.
Example
rs.service() .get("", function(ctx, request, response){ response.print('ok'); }) .execute();
That would be equivalent to the following:
rs.service() .resource("") .get(function(ctx, request, response){ response.print('ok'); }) .execute();
These shortcut methods share the same names with those in Resource
that are used for defining HTTP method handlers: get
,post
, put
, 'delete
and its alias remove
, but differ in signature (first argument is a string for the resource path) and the return type (the HttpController
instance, instead of ResourceMethod
).
They are useful as a compact mechanism if you intend to build something simple and simplistic, such as a single resource and one or few handler functions for it. You will not be able to go much further with this API so if you consider anything even slightly more sophisticated you should look into the fluent API of resource
instead: rs.service().resource("")
.
Note that the scope of these shortcut methods is the controller, not the resource. That has effect on the method chaining. For clean code, do not confuse despite the similar names and avoid mixing them.
rs.resource("").get().produces("[application/json]")
rs.resource("").post().consumes("[application/json]")
rs.resource("").put().consumes("[application/json]").produces("[application/json]")
Optionally, but also quite likely, we will add some constraints on the data type consumed and produced by the resource method handler that we configure.
At request processing runtime, these constraints will be matched for compatibility against the HTTP request headers before delegating to the handler processing function. You can use wildcards (*) in the MIME types arguments both for type and sub-type and it will be considered as anything during the execution:
rs.resource("").post().consumes("[*/json]")
rs.resource("").post().consumes("[*/*]")
Before we continue, let us take a look at the request processing flow.
- The request is matched against the resource method handling definitions in the configuration and if there is a compatible one it is elicited for execution. Otherwise, a Bad request error is returned back to the client.
- The
before
callback function is invoked if any was configured. - The
serve
callback function is invoked if any was configured. - If an
Error
was thrown from theserve
function, acatch
callback function is invoked. The callback function is either configured or the default one. - A
finally
(always executed) function is invoked if one was configured.
Or in pseudocode:
try{ before(ctx, request, response, resourceMethod, controller); serve(ctx, request, response, resourceMethod, controller); } catch(err){ catch(ctx, err, request, response, resourceMethod, controller); } finally { finally(); }
As evident form the flow, it is only the serve
event callback handler function that is required to be setup. But if you require fine grained reaction to the other events, you can configure handlers for each of those you are interested in.
Currently, the API supports a single handler function per event so in multiple invocation of a setup method on the same resource method only the last will matter.
resource.get().before(function(ctx, request, response, resourceMethod, controller){ //Implements pre-processing logic })
resource.get().serve(function(ctx, request, response, resourceMethod, controller){ //Implements request-processing logic })
resource.get().catch(function(ctx, error, request, response, resourceMethod, controller){ //Implements error-processing logic overriding the default })
resource.get().finally(function(){ //Implements post-processing logic regardless of error or success of the serve function })
A valid, executable resource method configuration requires at least the serve
callback function to be setup:
resource.get().serve(function(ctx, request, response){ response.println('OK'); });
The rest are optional and/or have default implementations.
Errors thrown from the before
and serve
callbacks are delegated to the catch
callback. There is a default catch
callback that sends formatted error back in the response and it can be overridden using the catch
method to setup another error processing logic. The finally
callback is invoked after the response has been flushed and closed (regardless if in error or success) and can be used to cleanup resources.
Example:
rs.service().resource("") .get() .before(function(ctx, request, response){ request.setHeader('X-arestme-version', '1.0'); }) .serve(function(ctx, request, response){ response.println('Serving GET request'); }) .catch(function(ctx, err, request, response){ console.error(err.message); }) .finally(function(){ console.info('GET request processing finished'); })
Configuration objects are particularly useful when you are enhancing or overriding an existing protocol so you don't start configuring from scratch but rather amend or change pieces of the configuration. It is also useful when you are dealing with dynamically generated HTTP-based protocol configurations.
For example, consider the simple sample that we started with. It is completely identical with this one, which uses a configuration object and provides it to the service
function:
rs.service({ "": { "get":[{ "serve": function(ctx, request, response){ response.println("Hello there!"); } }] } }).execute();
It is also completely identical with this one:
rs.service() .resource("", { "get":[{ "serve": function(ctx, request, response){ response.println("Hello there!"); } }] }).execute();
or this one:
rs.service() .resource("") .get([{ "serve": function(ctx, request, response){ response.println("Hello there!"); } }]).execute();
In fact, here is a sample how to define a whole API providing configuration directly to the service method and then enhance it.
rs.service({ "": { "get":[{ "serve": function(ctx, request, response){ response.println("Hello there!"); } }] } }) .resource("") .post() .serve(function(ctx, request, response){ console.info(request.readText()); }) .execute();
In this way we essentially are exploiting the fluent API to configure a service but we will not start from scratch. Many of the API methods accept as a second argument configuration object and this doesn't prevent you to continue the API design with fluent API to enhance or override it.
The HttpController
class instances that we receive when rs.service()
is invoked, features a sendError
method. It implements the logic for formatting errors and returning them back to the client taking into account its type and content type preferences.
Should you require to change this behavior globally you can redefine the function. If you require different behavior for particular resources or resource method handlers, then using the catch
callback is the better approach.
Sometimes it's useful to reuse the method and send error in your handler functions. The standard request processing mechanism in HttpController
does not account for logical errors. It doesn't know for example that a parameter form a client input is out of valid range. For such cases you would normally implement validation either in before
event handler or in serve. And if you need tighter control on what is sent back, e.g. the HTTP code you wouldn't simply throw an Error but invoke the sendError function with the right parameters yourself. For these purposes the last argument of each event handler function is conveniently the controller instance.
rs.service().resource("") .get() .before(function(ctx, request, response, methodHandler, controller){ //check if requested file exists if(!file.exists()){ controller.sendError(); } }) .serve(function(){ //return file content })
mappings.readonly()
An obvious way of defining readonly APIs is to use only GET
resource methods definitions. In some cases though APIs can be created from external configuration that also contains other resource method handlers, or we can receive an API instance from another module factory, or we want to support two instances of the same API, one readonly and one with edit capabilities, with minimal code. In such cases, we already have non-GET resource methods that we have to get rid of somehow. Here the readonly
method steps in and does exactly this - removes all but the GET resource handlers if any.
Example:
rs.service() .resource("") .post() .serve(function(){}); .get() .serve(function(){}); .readonly() .execute();
If you inspect the configuration after .readonly()
is invoked (use resource("").configuration()
) you will notice that the post verb definition is gone. Consecutively, POST requests to this resource will end up in Bad Request (400).
Note that for this to work, this must be the last configuration action for a resource. Otherwise, you are resetting the resource configuration to readonly, only to define write methods again.
The readonly
method is available both for ResourceMapping
and Resource
objects returned by either invocations of service mappings()
method or retained references from configuration API invocations.
api.disable(sPath, sVerb, arrConsumesTypes, arrProducesTypes)
Similar to the use cases explored for the readonly
method above yo might not be in full control of the definition of the API, but rather takeover at some point. Similar to the readonly
method, this one will remove the handler definition identified by the four parameters - resource path, resource verb, consumes constraint array (not necessarily in same order), produces constraint array (not necessarily in same order), but it will do it for any verb, not only 'GET'. In that sense readonly
is a specialization of this one only for GET verbs.
Example:
var mappings = rs.service({ "": { "post": [{ serve: function(){} }], "get": [{ serve: function(){} }] } }).mappings(); mappings.disable("", "post");
With this API definition, invoking mappings.find("","get")
will return a reference to the only get handler defined there and you can manage it. Note that you get a reference to the configuration and not an API.
Example:
// add produces constraint and redefine the serve callback var mappings = rs.service().get(function(){}).mappings(); //later in code var handler = mappings.find("", "get"); handler.produces = ["application/json"] handler.serve = function(){ console.info("I was redefined"); }
The request and response parameters of the execute method are optional. If you don't supply them, the service will take the request/response objects that were used to request the script. Most of the time this is what you want. However, supplying your own request and response arguments can be very handy for testing as you can easily mock and inspect them.
The execute
method is defined by the service instance (HttpController
) obtained with rs.service()
and can be executed with: rs.service().execute()
. The fluent configuration API also provides references to the method, so you can actually invoke it on any stage. Examples:
rs.service().resource("").get(function(){}).execute()
rs.service().resource("").get().serve(function(){}).execute()
rs.service().resource("").get().produces(["text/json"]).serve(function(){}).execute()
rs .service() .resource("") .produces(["application/json"]) .get(function(){}) .resource("") .consumes(["*/json"]) .post(function(){}) .execute()
The API supplies two methods mappings()
and configuration()
that provide configuration in two forms.
The mappings method supplies typed API objects such as Resource
aggregating ResourceMethod
instances. To get a reference to a service mappings, invoke mappings on the service instance:
rs.service().mappings()
With a reference to mappings you have their fluent API at disposal. This is useful when extending and enhancing the core rs functionality to build dedicated services. For example the HttpController
constructor function is designed to accept mappings and if you extend or initialize it internally in another API you will likely need this form of configuration.
An invocation of the configuration method on the other hand provides the underlying JS configuration object. It can be used to supply generic configurations that are used to initialize new types of services as the public fluent API is designed to accept this form of configuration.
Both are represent configuration but while the mappings are sort of internal, parsed version, the configuration object is the version that the public api accepts and is also therefore kind of advanced public form of the internal configuration.
It is also possible to convert between the two:
rs.service(jsConfig).mappings()
rs.service().resource().configuration()
rs.service().mappings().find(sPath, sMethod, arrConsumesTypes, arrProducesTypes)
Suppose you want to redefine a handler definition to e.g. change the serve
callback, add a before
handler, change or add to the consumes
media types constraint etc. To do that you need a reference to the handler, which is identified by the four parameters - resource path, resource method, consumes constraint array (not necessarily in same order), produces constraint array (not necessarily in same order). On a successful search hit you get a reference to the handler definition and can perform changes on it.
Example:
rs.service() .resource("") .get(function(){});
With this API definition, invoking
rs.service().mappings().find("","get")
will return a reference to the only get
handler defined there and you can manage it. Note that you get a reference to the configuration and not an API.
Example:
// add produces constraint and redefine the serve callback var handler = svc.mappings().find("", "get"); handler.produces = ["application/json"] handler.serve = function(){ console.info("I was redefined"); }
With consumes and produces constraints on a resource method handler, getting a reference will require them specified too.
Example:
var svc = rs.service(); svc.mapings() .resource("") .post() .consumes(['application/json', 'text/json']) .produces(['application/json']) .serve(function(){}); var handler = svc.mapings().find("", "post", ['text/json', 'application/json'], ['application/json']);
Note, that the order of the MIME type string values in the consumes/produces array parameters is not significant. They will be sorted before matching the sorted corresponding arrays in the definition.
Having defined a resource with path we have two options for configuring it. We can proceed using its fluent API or we can provision a configuration JS object as second argument to the resource
method and have it done in one step. Considering the latter, we will be provisioning configuration for this resource only, so it should be an object with method definitions as root members.
rs.service() .resource("", { "get":[{ "serve": function(ctx, request, response){ response.println("Hello there!"); } }] }).execute();
Refer to the next sections for comparison how to achieve the same, using fluent API and/or configuration objects on the lower levels.
In progress. Check back later. Schema:
{ pathString: { methodString: [{ "consumes": ["types/subtype|*/subtype|type/*|*/*"] "produces": ["types/subtype|*/subtype|type/*|*/*"] "before": Function "serve": Function "catch": Function "finally": Function }] } }
- pathString is a string that represents the resource path. There could be 0 or more such non-overlapping members.
- methodString is a string for the HTTP resource method. There could be 0 or more such non-overlapping members.
- The value of methodString is an array of 0 or more objects, each defining a request method processing that will be executed under unique conditions (constraints) that match the request.
- A component in the methodString array, can consist of constraints (consumes, produces) and request processing flow event handlers (before, serve, catch, finally)
-
consumes
value is an array of 0 or more strings, each a valid MIME type string formatted astypes/subtype
. Can be undefined. -
produces
value is an array of 0 or more strings, each a valid MIME type string formatted astypes/subtype
. Can be undefined. -
before
,serve
,catch
andfinally
values are functions. Except for theserve
function, the rest can beundefined
.
The code snippet below shows a sample design for a REST API for simple CRUD file operations. It has illustrative purposes.
The service design is to work with files in the HOME directory of the user that runs the dirigible instance currently. Users can create, read, update and delete files by sending corresponding POST, GET, PUT and DELETE requests using the file name as path segment (e.g. /services/js/file-serivce.js/test.json
) and they can also upload files if they don't specify file name but send multipart-form-data POST request directly to the service (e.g. /services/js/file-serivce.js
).
Note how the before
handler is used to validate user has permissions on resources and how it makes use of controller's sendError
method.
var LOGGER = require("log/v3/logging").getLogger('http.filesvc'); var rs = require("http/v3/rs"); var upload = require('http/v3/upload'); var files = require('io/v3/files'); var user = require('security/v3/user'); var env = require('core/v3/env'); var validateRequest = function(permissions, ctx, request, response, methodHandler, controller){ var filePath = env.get('HOME') + '/' + ctx.pathParameters.fileName; if(!files.exists(filePath)){ LOGGER.info("Requested file "+filePath+" does not exist."); controller.sendError(response.NOT_FOUND, undefined, response.HttpCodesReasons.getReason(String(response.NOT_FOUND)), ctx.pathParameters.fileName + " does not exist."); return; } if(permissions){ var resourcePermissions = files.getPermissions(filePath); if(resourcePermissions !== null && resourcePermissions.indexOf(permissions)>-1){ var loggedUser = user.getName(); LOGGER.error("User {} does not have sufficient permissions[{}] for {}", loggedUser, files.getPermissions(filePath), filePath); controller.sendError(response.UNAUTHORIZED, undefined, response.HttpCodesReasons.getReason(String(response.UNAUTHORIZED)), "User " + loggedUser + " does not have sufficient permissions for " + ctx.pathParameters.fileName); return; } } LOGGER.error('validation successfull'); }; var postProcess = function(operationName){ LOGGER.info("{} operation finished", operationName); }; rs.service() .resource("") .post(function(ctx, request, response){ var fileItems = upload.parseRequest(); for (var i=0; i < fileItems.size(); i++) { var filePath = env.get('HOME') + '/'; var content; var fileItem = fileItems.get(i); if (!fileItem.isFormField()) { filePath += fileItem.getName(); content = String.fromCharCode.apply(null, fileItem.getBytes()); } else { filePath += fileItem.getFieldName(); content = fileItem.getText(); } LOGGER.debug("Creating file" + filePath); files.writeText(filePath, content); } response.setStatus(response.CREATED); }) .before(function(ctx, request, response, methodHandler, controller){ var loggedUser = user.getName(); if(files.getOwner(ctx.pathParameters.fileName) !== loggedUser) controller.sendError(response.UNAUTHORIZED, undefined, response.HttpCodesReasons.getReason(String(response.UNAUTHORIZED)), loggedUser + " is not owner of " + ctx.pathParameters.fileName); }) .finally(postProcess.bind(this, "Upload")) .consumes(["multipart/form-data"]) .resource("{fileName}") .post(function(ctx, request, response){ var content = request.getText(); var filePath = env.get('HOME') + '/' + ctx.pathParameters.fileName; LOGGER.debug("Creating file " + filePath); files.writeText(filePath, content); files.setPermissions(filePath, 'rw'); response.setStatus(response.CREATED); }) .finally(postProcess.bind(this, "Create")) .consumes(["application/json"]) .get(function(ctx, request, response){ var filePath = env.get('HOME') + '/' + ctx.pathParameters.fileName; LOGGER.error("Reading file " + filePath); var content = files.readText(filePath); response.setStatus(response.OK); response.print(content); }) .before(validateRequest.bind(this, 'r')) .finally(postProcess.bind(this, "Read")) .produces(["application/json"]) .put(function(ctx, request, response){ var filePath = env.get('HOME') + '/' + ctx.pathParameters.fileName; LOGGER.debug("Updating file " + filePath); var content = request.getJSON(); files.deleteFile(filePath); files.writeText(filePath, content); response.setStatus(response.ACCEPTED); }) .finally(postProcess.bind(this, "Update")) .before(validateRequest.bind(this, 'rw')) .consumes(["application/json"]) .remove(function(ctx, request, response){ var filePath = env.get('HOME') + '/' + ctx.pathParameters.fileName; LOGGER.debug("Removing file " + filePath); files.deleteFile(filePath); response.setStatus(response.NO_CONTENT); }) .before(validateRequest.bind(this, 'w')) .finally(postProcess.bind(this, "Delete")) .execute();
In progress. Check back later.
- returns HttpController
- returns ResourceMappings
- param sPath
- returns Resource
- returns Object
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns string
- returns Object
- returns ResourceMethod
- returns Resource
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod
- returns ResourceMethod