Skip to content

Commit

Permalink
Merge pull request #40 from line-o/feat/better-media-types
Browse files Browse the repository at this point in the history
Better media type handling
  • Loading branch information
line-o authored Nov 5, 2021
2 parents 9022108 + 8bc96a4 commit 527becf
Show file tree
Hide file tree
Showing 10 changed files with 612 additions and 330 deletions.
174 changes: 119 additions & 55 deletions content/router.xql
Original file line number Diff line number Diff line change
Expand Up @@ -239,59 +239,105 @@ function router:middleware-reducer (
array { apply($next-middleware, $args) }
};

(:~
: Try to retrieve and convert the request body if specified
:)
declare function router:body ($request as map(*)) {
if (
not(map:contains($request?config, "requestBody")) or
not(map:contains($request?config?requestBody, "content"))
)
then () (: this route expects no body, return an empty sequence :)
declare %private function router:content-type ($request as map(*)) as map(*) {
if (not(exists($request?config?requestBody?content)))
then (map{}) (: this route expects no body, return an empty map :)
else if (not($request?config?requestBody?content instance of map(*)))
then error($errors:OPERATION, "requestBody.content is not defined correctly", $request?config)
else (
let $content-type-header := (: strip charset info from mime-type if present :)
request:get-header("Content-Type")
=> replace("^([^;]+);?.*$", "$1")
let $defined-content-types := map:keys($request?config?requestBody?content)

let $raw-content-type-header-value := request:get-header("Content-Type")
let $media-type :=
if (contains($raw-content-type-header-value, ";"))
then substring-before($raw-content-type-header-value, ";")
else $raw-content-type-header-value

let $definition := $request?config?requestBody?content
let $charset :=
if (contains($raw-content-type-header-value, "charset="))
then (
substring-after($raw-content-type-header-value, "charset=")
=> lower-case()
=> replace("^([a-z0-9\-]+).*$", "$1")
)
else ()

let $registry := substring-before($media-type, "/")
let $format-hint := substring-after($media-type, "+")

return
if (
map:contains($definition, $content-type-header) or
map:contains($definition, "*/*")
$media-type = $defined-content-types or (
$defined-content-types = "*/*" and
$registry = (
"application", "audio", "example", "font", "image",
"message", "model", "multipart", "text", "video"
)
)
)
then (
switch ($content-type-header)
case "multipart/form-data" return
() (: TODO: implement form-data handling? :)
case "application/json" return
try {
request:get-data() => util:binary-to-string() => parse-json()
}
catch * {
error($errors:BODY_CONTENT_TYPE, "Invalid JSON", $err:description)
}
case "application/xml" return
try {
(:
: workaround for eXist-DB specific behaviour,
: this way we will get parse errors as early as possible
: while still having access to the data afterwards
:)
let $data := request:get-data()
let $_test := parse-xml($data)
(: return root node instead of a document fragment :)
return $data/node()
}
catch * {
error($errors:BODY_CONTENT_TYPE, "Invalid XML", $err:description)
}
default return
request:get-data()
then map {
"media-type": $media-type,
"charset": $charset,
"registry": $registry,
"format":
if ($media-type = ("application/json", "text/json"))
then "json"
else if ($media-type = ("application/xml", "text/xml"))
then "xml"
else if ($media-type = "multipart/form-data")
then "form-data"
else if ($format-hint)
then $format-hint
else "binary"
}
else error(
$errors:BODY_CONTENT_TYPE,
"Body with media-type '" || $media-type || "' is not allowed",
$request
)
else
error($errors:BODY_CONTENT_TYPE,
"Passed in Content-Type " || $content-type-header || " not allowed")
)
};

(:~
: Try to retrieve and convert the request body if specified
:)
declare function router:body ($request as map(*)) {
if (not(exists($request?media-type)))
then () (: this route expects no body, return an empty sequence :)
else (
try {
switch ($request?format)
(: Q: do we need to handle form-data? :)
case "form-data" return ()
(:
Parse body contents to XQuery data structure for media types
that were identified as being in JSON format.
NOTE: The data needs to be serialized again before it can be stored.
:)
case "json" return
request:get-data()
=> util:binary-to-string()
=> parse-json()
(:
Workaround for eXist-DB specific behaviour,
this way we will get parse errors as early as possible
while still having access to the data afterwards.
NOTE: Returns the root node instead of a document (fragment).
:)
case "xml" return
let $data := request:get-data()
let $_test := parse-xml($data)
return $data/node()
(: Treat everything else as binary data :)
default return request:get-data()
}
catch * {
error(
$errors:BODY_CONTENT_TYPE,
"Body with media type '" || $request?media-type || "' could not be parsed (invalid " || upper-case($request?format) || ").",
$err:description
)
}
)
};

Expand All @@ -304,7 +350,8 @@ declare %private function router:execute-handler ($base-request as map(*), $use,
error($errors:OPERATION, "Operation does not define an operationId", $base-request?config)
else
try {
let $request-with-body := map:put($base-request, "body", router:body($base-request))
let $request-with-content-type := map:merge(($base-request, router:content-type($base-request)))
let $request-with-body := map:put($request-with-content-type, "body", router:body($request-with-content-type))

let $request-response-array :=
fold-left($use, [$request-with-body, map {}], router:middleware-reducer#2)
Expand Down Expand Up @@ -523,18 +570,35 @@ declare %private function router:safe-set-header ($header as xs:string, $value a

(:~
: Q: binary types?
: XSLT default values: "xml", "xhtml", "html", "text", "json", "adaptive"
: "html5" is an eXist-DB provided extension
:)
declare %private function router:method-for-content-type ($type as xs:string) as xs:string {
switch($type)
case "application/json" return "json"
case "text/html" return "html5"
case "text/text" return "text"
case "application/octet-stream" return "text"
case "application/xml" return "xml"
default return "xml"
switch (substring-before($type, "/"))
case "application" return
if (ends-with($type, "json")) then "json" (: matches application/json and any type ending in +json :)
else if (ends-with($type, "xhtml+xml")) then "xhtml"
else if (ends-with($type, "xml")) then "xml" (: matches application/xml and any type ending in +xml :)
else "text"
case "text" return
if (ends-with($type, "/xml")) then "xml"
else if (ends-with($type, "/html")) then "html5"
else "text"
case "image" return
if (ends-with($type, "/svg+xml")) then "xml"
else "text"
case "multipart"
case "audio"
case "font"
case "example"
case "message"
case "model"
case "video" return
"text" (: assume binary content :)
default return
error($errors:OPERATION, "Unknown media type '" || $type || '"')
};


(: helpers :)

declare %private function router:is-response-map($value as item()*) as xs:boolean {
Expand Down
2 changes: 1 addition & 1 deletion icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions test/app/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,24 @@
}
}
},
"/api/avatar": {
"get": {
"summary": "Retrieve avatar as SVG",
"operationId": "api:avatar",
"responses": {
"200": {
"description": "Not found",
"content": {
"image/svg+xml":{
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/errors/handle": {
"get": {
"summary": "Test error handler",
Expand Down
13 changes: 13 additions & 0 deletions test/app/modules/api.xql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ xquery version "3.1";

declare namespace api="http://e-editiones.org/roasted/test-api";
declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization";
declare namespace svg="http://www.w3.org/2000/svg";

import module namespace roaster="http://e-editiones.org/roaster";

Expand Down Expand Up @@ -98,6 +99,17 @@ declare function api:get-uploaded-data ($request as map(*)) {
)
};

declare function api:avatar ($request as map(*)) {
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="darkgreen" stroke="lime" stroke-width=".25" transform="skewX(4) skewY(8) translate(0,.5)">{
for $pos in 1 to 10
let $zero-based := $pos - 1
let $x := $zero-based mod 4 * 3 + 2
let $y := $zero-based idiv 4 * 3 + 2
return <rect x="{$x}" y="{$y}" width="2" height="2" rx=".5" ry=".5" />
}</g>
</svg>
};

(: end of route handlers :)

Expand All @@ -110,4 +122,5 @@ declare function api:lookup ($name as xs:string) {
function-lookup(xs:QName($name), 1)
};

(: util:declare-option("output:indent", "no"), :)
roaster:route($api:definitions, api:lookup#1)
41 changes: 41 additions & 0 deletions test/auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const util = require('./util.js')
const chai = require('chai')
const expect = chai.expect

describe('On Login', function () {
let response

before(async function () {
await util.login()
response = await util.axios.get('api/parameters', {})
})

it('public route can be called', function () {
expect(response.status).to.equal(200);
})

it('user property is set on request map', function () {
expect(response.data.user).to.be.a('object')
expect(response.data.user.name).to.equal("admin")
expect(response.data.user.dba).to.equal(true)
})

describe('On logout', function () {
let logoutResponse
let guestResponse
before(async function () {
logoutResponse = await util.axios.get('logout')
guestResponse = await util.axios.get('api/parameters', {})
})
it('request returns true', function () {
expect(logoutResponse.status).to.equal(200)
expect(logoutResponse.data.success).to.equal(true)
})
it('public route sets guest as user', function () {
expect(guestResponse.status).to.equal(200)
expect(guestResponse.data.user.name).to.equal("guest")
expect(guestResponse.data.user.dba).to.equal(false)
})

})
})
41 changes: 41 additions & 0 deletions test/error.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const util = require('./util.js')
const chai = require('chai')
const expect = chai.expect

describe('Error reporting', function() {
it('receives error report', function() {
return util.axios.get('api/errors')
.catch(function(error) {
expect(error.response.status).to.equal(404)
expect(error.response.data.description).to.equal('document not found')
expect(error.response.data.value).to.equal('error details')
})
})

it('receives dynamic XQuery error', function() {
return util.axios.post('api/errors')
.catch(function(error) {
expect(error.response.status).to.equal(500)
expect(error.response.data.line).to.match(/\d+/)
expect(error.response.data.description).to.contain('$undefined')
})
})

it('receives explicit error', function() {
return util.axios.delete('api/errors')
.catch(function(error) {
expect(error.response.status).to.equal(403)
expect(error.response.headers['content-type']).to.equal('application/xml')
expect(error.response.data).to.equal('<forbidden/>')
})
})

it('calls error handler', function() {
return util.axios.get('api/errors/handle')
.catch(function(error) {
expect(error.response.status).to.equal(500)
expect(error.response.headers['content-type']).to.equal('text/html')
expect(error.response.data).to.contain('$undefined')
})
})
})
Loading

0 comments on commit 527becf

Please sign in to comment.