Frontier is a REST framework made with the purpose of reducing boilerplate code and imposing a clean coding style where dispatcher methods should always return a value or throw an exception. This way the application will never use the WRITE command manually inside these kind of methods.
Have you ever found yourself dealing with repetitive tasks like mounting objects, serializing them and eventually handling multiple kind of errors? Frontier was built to boost your development by making you focus on what really matters: your application.
%CSP.REST is the base class exclusive for creating RESTful applications. While Frontier uses the %CSP.REST for welcoming the requests, it overwrites how %CSP.REST transports the request to the dispatcher method in a way that you can use it even if your application is not RESTful. Here's how Frontier compares to the default %CSP.REST:
Feature | Frontier | %CSP.REST |
---|---|---|
Query parameters | By argument name | Using %request.Get |
Variable number of arguments | By indexed query parameter | Using %request.Get(name, index) |
Map placeholders | Supported | Parsing from %request.URL |
Routes with regular expressions | Supported when Strict="false" | Supported |
JSON serialization | Using %Dynamic instances | Writing to the device |
Error handling | Implicit | try/catch or trap mechanism with device write |
Argument instantiation | By argument class type (query/route parameters) | Manually |
Payload handling | Triggered by %Dynamic based arguments | Manually checking from %request.Content |
SQL result serialization | Using a wrapper for the SQL API | Writing to the device |
Unmarshaling | When typing an argument and setting UNMARSHAL=1 | Manually |
Marshalling | Seamlessly returning a mixed %Dynamic type | Manually |
Serving files | Using the Frontier Files API | Not supported on %CSP.REST, but %CSP.StreamServer |
Upload handling | By using the Frontier Files Upload API | Manually from %request.MimeData |
Error reporting | By using the Frontier Reporter API | Manually |
Authentication | Strategy-oriented (isolated) | Based on web application config / OAuth2 API |
Stream response | Implicit | Writing to the device |
Sharing common data | Using OnDataSet method and %frontier.Data object | N/A |
Property formatters | Using %frontier.PropertyFormatter | While mounting the object |
- Automatic exception handling: Handles the exception feedback accordingly when it's thrown. Recommended to be used along with status to notify the consumer application about errors.
ClassMethod SayHello() As %String
{
// This will return { "result": "hello" }
return "hello"
// or
$$$ThrowStatus($$$ERORR($$$GeneralError, "oops"))
// or
return %frontier.ThrowException("oops")
// or (this will be captured automatically)
set value = oops
}
- Typed argument: Set the argument type as something that extends from %Persistent and it'll be replaced with the instance resulting from an implicit %OpenId call right before the dispatcher method is invoked. This works for both: query parameters and route parameters.
ClassMethod TestGETRouteParams(class As Frontier.UnitTest.Fixtures.Class) As %Status
{
// curl -H "Content-Type: application/json" 'localhost:57772/api/frontier/test/route-params/6'
// {"Plate":"O5397","Students":[{"Name":"Drabek,Peter T.","__id__":"20"}],"__id__":"6"}
return class
}
- Query parameters: Unhandled arguments (not explicitly set on Route/Map) are considered as query parameters. By default they are required, however it's possible to make them optional simply by providing a default value.
ClassMethod SayHelloTo(who As %String = "John Doe") As %String
{
// curl -H "Content-Type: application/json" 'localhost:57772/api/frontier/test/query-params?who=Francis'
// curl -H "Content-Type: application/json" 'localhost:57772/api/frontier/test/query-params'
return "hello "_who
}
- Variable number of arguments: Multiple query parameters with the same name but different indexes. To enable it, use the three dot notation.
ClassMethod TestGETRestParametersSum(n... As %String) As %Integer
{
// curl -H "Content-Type: application/json" 'localhost:57772/api/frontier/test/rest-params?n1=10&n2=20&n3=30'
// {"result":60}
set sum = 0
for i=1:1:n set sum = sum + n(i)
return sum
}
- Payload handling: Client applications requiring to send payload data (normally JSON), can be handled by the server with methods whose parameters are typed from %DynamicAbstractObject instances.
ClassMethod EchoUserPayload(payload As %DynamicObject) As %DynamicObject
{
// curl -H "Content-Type: application/json" -X POST -d '{"username":"xyz","password":"xyz"}' 'http://localhost:57772/api/frontier/test/payload/single-object'
// {"username":"xyz","password":"xyz"}
return payload
}
- Unmarshaling: Set
UNMARSHAL=1
while typing the payload to a persistent class so that the payload will be parsed and unmarshalled to it.
ClassMethod CreateClass(class As Frontier.UnitTest.Fixtures.Class(UNMARSHAL=1)) As Frontier.UnitTest.Fixtures.Student
{
// curl -H "Content-Type: application/json" -X POST\
// -d '{"Plate": "R-2948","Students": [{"Name": "Rubens",\
// "BirthDate": "04/21/1970","SomeValue": 0}]}'\
// 'http://localhost:57772/api/frontier/unmarshal
$$$ThrowOnError(class.%Save())
return {
"ok": true,
"__id__": (class.%Id())
}
}
- SQL results: Return a serializable SQL result using the Frontier SQL API.
ClassMethod GetPaginatedSQLResult(
page As %Integer = 1,
rows As %Integer = 5) As Frontier.SQL.Provider
{
set offset = (page * rows) - (rows - 1)
set limit = page * rows
return %frontier.SQL.Prepare(
"SELECT *, %VID as Index FROM (SELECT TOP ? * FROM FRONTIER_UNITTEST_FIXTURES.STUDENT) WHERE %VID BETWEEN ? AND ?"
).Parameters(limit, offset, limit)
// or
return %frontier.SQL.Prepare("Package.Class:QueryName").Parameters(limit, offset)
}
- Streams: Return a stream instance to deliver a content that exceeds the maximum string length.
ClassMethod TestGETStream() As %Stream.Object
{
set stream = ##class(%Stream.GlobalCharacter).%New()
do stream.Write("This line is from a stream.")
return stream
}
- Seamless object serialization: Mix multiple object types into a single returning
%DynamicObject/%DynamicArray
instance and all its composition will be mutated to dynamic instances as well.
ClassMethod TestGETMixedDynamicObject(class As Frontier.UnitTest.Fixtures.Class) As %DynamicObject
{
// Class is a instance of a %Persistent derived type.
return {
"class": (class)
}
}
- Shareable object: Allows the context to share a set of objects that can be retrived on each dispatcher method.
ClassMethod OnDataSet(data As %DynamicObject) As %Status
{
/// This 'data' object is shared between all methods. Accessible using %frontier.Data.
set data.Message = "This 'Message' is shared between all methods."
return $$$OK
}
ClassMethod TestGETData() As %DynamicObject
{
// Prints { "results": "This 'Message' is shared for all methods." }.
return %frontier.Data
}
Each Router class provides a configuration method called OnSetup()
. This method allows the developer to define how the Router should behave for certain situations. Configuration is made by using the helpers provided in the %frontier
object which is composed by several modules to handle different tasks.
The helper AuthenticationManager
allows the declaration of a chain of strategies that attempt to match themselves with the authentication model and the credentials provided by the client. In other words, a strategy is elected from the chain and used to validate the credentials.
Below is an example on how to configure a strategy in the chain.
Implementation:
ClassMethod OnSetup() As %Status
{
// Asks the user for a Basic + Base64(username:password) encoded Authorization header.
set basicStrategy = ##class(Frontier.Authentication.BasicStrategy).%New({
"realm": "tests",
"validator": ($classname()_":ValidateCredentials")
})
// Tells Frontier that we should use this strategy.
$$$QuitOnError(%frontier.AuthenticationManager.AddStrategy(basicStrategy))
}
NOTE: This will make all the routes under the current Router to be protected. If you want to disable authentication you can set
UseAuth="false"
in the related<Route>
. You can also force a route to use a single strategy by usingAuthStrategy="MyAuth"
.
If you want to use your own strategy, you need to consider a few entry points:
Realm (String):
This should be used to define the 'realm' when sending the challenge to the client.GetChallenge (Method)
: This should return a validWWW-Authenticate
whenever adequate.Verify (Method):
This method takes four arguments:session
,request
,response
, anduser
. The implementation use the three first arguments to resolve theuser
which is represented by a %DynamicObject. Whenuser
is populated with ascope
property, then thisscope
will be used to validate against the Route'sScope
attribute if present.
To have a better idea on how to implement a custom strategy check out the class Frontier.Authentication.BasicStrategy. It implements all the entry points described here.
Reporters can be used to take an informative action on unhandled errors. just like the AuthenticationManager
, the ReporterManager
can have a chain of reporters as well. But instead of electing a single strategy and firing it, the ReportManager
will call each consecutively.
NOTE: Since reporters are fired after the request has been finished, you'll need to make the application aware of it, in order to do so, set up the event class
Frontier.SessionEvents
. You can also customize it for your needs.
Reporters should be set using the method OnSetup
and its syntax is very similar to the Authentication.
ClassMethod OnSetup() As %Status
{
// Reporters should be used to signal the developer about request errors.
$$$QuitOnError(%frontier.ReporterManager.AddReporter(##class(MyReporter.Email).%New()))
$$$QuitOnError(%frontier.ReporterManager.AddReporter(##class(MyReporter.GithubIssues).%New()))
return $$$OK
}
Since reporters provides more freedom on how to implement them, there is only two entry points:
Setup (method)
: This receivescontext
: an object representing the%frontier
. Use this method to prepare or configure the custom reporter.Report (method)
: This also receivescontext
, howeverReport
is called only when an abnormal error happens and the request has already finished. This is where the custom reporter should take an action.
Check out the class Frontier.Reporter.Log to see how to implement those entry points.
They're used for modifying the writing style for each property. The usage is pretty straightforward:
set %frontier.PropertyFormatter = ##class(Frontier.PropertyFormatter.SnakeCase).%New()
This will make all the properties to be written using snake_case
format. If you don't define a property formatter, then the original format is used.
If you want to output directly from SQL results, you need to return a provider. A provider can be created by using the Frontier SQL API, its syntax is:
%frontier
SQL
.Prepare(SQL or named query) // This returns the provider
.Parameters(n...) // while this
.Mode(DisplayMode) // and this decorates it. So they are optional.
Returning the provider will output { "results": [{...}, {...} ...]}
. If you want customize it, then just put the provider into an array or object so that the output will be merged with it.
NOTE #1: By default the Display mode is configured to 0: the internal format.
NOTE #2: You cannot use this API the iterate over each row, if you want to do so, then you're recommended to use the %SQL.Statement API itself.
You can also allow the client to provide you the query, this can be useful for creating in-app advanced filters. You can setup the query builder by making it respond in place of the default API. E.g.
ClassMethod InlineQuery(filter As %String = "", page As %String = "", fields As %String = "id, name, ssn", orderBy As %String = "id asc, dob desc", limit As %Integer = 50) As Frontier.SQL.Provider
{
set builder = %frontier.SQL.InlineQueryBuilder()
do builder.For("Sample.Person")
do builder.Filter(filter)
do builder.OrderBy("id as PersonID, SSN")
return builder.Build().Provide()
}
This enables the application to serve or receive static files.
There are two ways of serving a file. You can serve a directory or a single file.
Serving a directory means that the user can select what to retrieve by using the URL path. As long as it belongs
to the directory you provided the access: files are served starting from the root
path that's only know to the server, combined with the URL match that indicates the root path's subdirectory. E.g.
URL is /my/application/static/docs/help.txt
Root is /var/lib/app/public
Which resolves to /var/lib/app/public/docs/help.txt
In order to serve files inside a directory, you must make sure that you have configured a Route
that:
- Has
Strict
set to "false". So that you can use regular expressions. - Uses group to capture the file path. Something like
?(.*)?
should be enough. - Has a class method that directs to the file server.
- For steps 1 and 2:
<Route Url="/static/?(.*)?" Strict="false" Method="GET" Call="TestGETStaticFile"/>
- For step 3:
ClassMethod TestGETStaticFile() As %Stream.Object
{
return %frontier.Files.ServeFrom("/var/lib/app/public")
}
This is the most basic format to start serving files from a directory.
Check out the class Frontier.UnitTest.Router, method TestGETStaticFileWithCustomConfig
to learn how to use advanced configurations.
One of the cons on serving a directory is that the path inside the directory gets exposed. Serving a file however, allows the application to mask that path using the route url instead with the limitation of serving a file exclusively.
<Route Url="/static/documents/:id" Strict="false" Method="GET" Call="GetUserDocument"/>
ClassMethod GeUserDocument(id As %String) As %Status
{
set path = ##class(%File).NormalizeFilename(id_".pdf", "/uploads/files/pdf")
return %frontier.Files.ServeFile(path)
}
Files can be saved to the server by preparing a route capable of handling uploads. Configuring the upload handler requires less work in the Route definition, but instead more configuration in the uploader itself.
Finally, you need a class method to set up the upload handler, this must be same as the Call
attribute.
And now, you're ready to set up the upload handler.
NOTE: You need a
Route
that accepts a POST request, anything else won't work.
ClassMethod HandleUpload() As %Status
{
set location = "/var/lib/my/app/uploads"
set destination = location_"/:KEY/:FILE_NAME:EXTENSION"
// 512 KB
set maxFileSize = (1024**2/0.5)
return %frontier.Files.Upload({
"hooks": {
"onItemUploadSuccess": "Frontier.UnitTest.Router:WriteResultToProcessGlobal",
"onItemUploadError": "Frontier.UnitTest.Router:WriteErrorToProcessGlobal",
"onComplete": "Frontier.UnitTest.Router:WriteResultSummaryToProcessGlobal"
},
"filters": {
// Will throw an error instead of silently ignoring the entry.
"verbose": true,
"extensions": ["txt", "pdf"],
"maxFileSize": {
"value": (maxFileSize),
"errorTemplate": "The file exceeded the :VALUE bytes limit."
}
},
"destinations": {
"file_a": { "path": (destination) },
"file_b": { "path": (destination) },
"file_c": { "path": (destination), "optional": true },
"file_d": { "path": (destination), "optional": true },
// optional=false
"file_x": (destination),
"files": {
"path": (location_"/files/:INDEX/:FILE_NAME:EXTENSION"),
"slots": 3, // Enables sending multiple files with the same key.
"filters": { "maxFileSize": 500000000 }
}
}
})
}
Although for using Frontier you don't need any additional library, in order to run the test suites you'll need to execute the following steps:
- Download and install Port.
- Configure
Port
to the path you cloned this repo. E.g.: If you cloned to/projects/frontier
you'll need to run##class(Port.Configuration).SetCustomWorkspace("frontier", "/projects/frontier")
. - Download and install Forgery.
- Import the class
Frontier.UnitTest.WebApplicationInstaller.cls
. This will create the application required to run the tests.
If you want to contribute with this project, you're encouraged to do so, however please read the CONTRIBUTING file before doing so.
MIT.