From f77faca1e2693b376d772e7842149a7b6a91dc8d Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 12:22:14 -0400 Subject: [PATCH 01/16] Move XQuery modules into the modules subdirectory --- app.xqm => modules/app.xqm | 0 browse.xq => modules/browse.xq | 0 snapshot.xq => modules/snapshot.xq | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename app.xqm => modules/app.xqm (100%) rename browse.xq => modules/browse.xq (100%) rename snapshot.xq => modules/snapshot.xq (100%) diff --git a/app.xqm b/modules/app.xqm similarity index 100% rename from app.xqm rename to modules/app.xqm diff --git a/browse.xq b/modules/browse.xq similarity index 100% rename from browse.xq rename to modules/browse.xq diff --git a/snapshot.xq b/modules/snapshot.xq similarity index 100% rename from snapshot.xq rename to modules/snapshot.xq From 66c2e8c62876a95d231cb64db1719d46dea69fd9 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 12:22:58 -0400 Subject: [PATCH 02/16] [ignore] Reformat --- expath-pkg.xml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/expath-pkg.xml b/expath-pkg.xml index a1122d0..d7d80d7 100644 --- a/expath-pkg.xml +++ b/expath-pkg.xml @@ -1,9 +1,8 @@ - - Airlock - - - + + Airlock + + + From de3e97ac52bbe92966f0e00e7cf894abdbe8d932 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:11:42 -0400 Subject: [PATCH 03/16] Register the Roaster dependency --- expath-pkg.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/expath-pkg.xml b/expath-pkg.xml index d7d80d7..0ce1ce8 100644 --- a/expath-pkg.xml +++ b/expath-pkg.xml @@ -5,4 +5,5 @@ + From a37b466f61ba276e25aa4656dbf11e10b0027d87 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:12:51 -0400 Subject: [PATCH 04/16] Route all requests through Roaster API query --- controller.xql | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/controller.xql b/controller.xql index 5c3cd1e..c815ba7 100644 --- a/controller.xql +++ b/controller.xql @@ -6,20 +6,23 @@ declare variable $exist:controller external; declare variable $exist:prefix external; declare variable $exist:root external; +declare variable $base-url := request:get-context-path() || $exist:prefix || $exist:controller; + (: redirect requests for app root "" to "/" :) if ($exist:path eq "") then -(: handle request for landing page "/" :) -else if ($exist:path eq "/") then - - - - -(: pass all other requests through :) +(: all other requests are passed on the Open API router :) else - - + + + + + + + + + \ No newline at end of file From 79c5ca98e7fea261c2b4b1379ea5efc1bfa7ebc6 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:13:30 -0400 Subject: [PATCH 05/16] Add OpenAPI JSON and Roaster handler query --- api.json | 479 +++++++++++++++++++++++++++++++++++++++++++++++++ modules/api.xq | 72 ++++++++ 2 files changed, 551 insertions(+) create mode 100644 api.json create mode 100644 modules/api.xq diff --git a/api.json b/api.json new file mode 100644 index 0000000..4ef6b53 --- /dev/null +++ b/api.json @@ -0,0 +1,479 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Airlock API", + "description": "Airlock custom endpoints" + }, + "servers": [ + { + "description": "Endpoint for testing on localhost", + "url": "http://localhost:8080/exist/apps/airlock" + } + ], + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "cookieAuth": { + "type": "apiKey", + "name": "airlock.joewiz.org.login", + "in": "cookie" + } + } + }, + "paths": { + "/": { + "get": { + "summary": "Landing page", + "operationId": "bases:welcome", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the landing page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases": { + "get": { + "summary": "List bases", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the listing of bases!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/records/{record-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "record-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/fields/{field-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "field-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/{base-id}/snapshot": { + "get": { + "summary": "Take snapshot", + "operationId": "snapshot:hit-me", + "x-constraints": { + "group": "dba" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "{base-id}/base-metadata": { + "post": { + "summary": "Post body as application/octet-stream", + "operationId": "update:base-metadata", + "x-constraints": { + "group": "dba" + }, + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files[]": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Upload result", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "details": { + "type": "object", + "nullable": true + } + } + } + } + } + }, + "404": { + "description": "Upload collection not found", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "500": { + "description": "Upload collection not found", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/login": { + "post": { + "summary": "Login the user", + "description": "Login the given user", + "operationId": "api:login", + "parameters": [ + { + "name": "user", + "in": "query", + "description": "Name of the user", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "password", + "in": "query", + "schema": { + "type": "string", + "format": "password", + "example": "simple", + "nullable": true + } + }, + { + "name": "logout", + "in": "query", + "description": "Set to some value to log out the current user", + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "string" + }, + "groups": { + "type": "array", + "items":{ + "type": "string" + } + }, + "dba": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "basicAuth": [] + } + ] +} \ No newline at end of file diff --git a/modules/api.xq b/modules/api.xq new file mode 100644 index 0000000..5da139d --- /dev/null +++ b/modules/api.xq @@ -0,0 +1,72 @@ +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"; + +import module namespace login="http://exist-db.org/xquery/login" at "resource:org/exist/xquery/modules/persistentlogin/login.xql"; + +import module namespace config="http://exist-db.org/xquery/apps/config" at "config.xqm"; + +import module namespace bases="http://joewiz.org/ns/xquery/airlock/bases" at "bases.xqm"; +import module namespace snapshot="http://joewiz.org/ns/xquery/airlock/snapshot" at "snapshot.xqm"; +import module namespace update="http://joewiz.org/ns/xquery/airlock/update" at "update.xqm"; + +import module namespace errors="http://e-editiones.org/roaster/errors"; +import module namespace roaster="http://e-editiones.org/roaster"; +import module namespace rutil="http://e-editiones.org/roaster/util"; + + +(:~ + : list of definition files to use + :) +declare variable $api:definitions := ("api.json"); + + +(:~ + : You can add application specific route handlers here. + : Having them in imported modules is preferred. + :) + +(:~ + : Either login a user (if parameter `user` is specified) or check if the current user is logged in. + : Setting parameter `logout` to any value will log out the current user. + :) +declare function api:login($request as map(*)) { + let $loginDomain := +(: $request?loginDomain:) + $config:login-domain + return + ( + if ($request?parameters?user) then + login:set-user($loginDomain, (), false()) + else + (), + let $user := request:get-attribute($loginDomain || ".user") + return + if (exists($user)) then + map { + "user": $user, + "groups": array { sm:get-user-groups($user) }, + "dba": sm:is-dba($user) + } + else + error($errors:UNAUTHORIZED, "Wrong user or password", map { + "user": $user, + "domain": $request?loginDomain + }) + ) +}; + + +(: end of route handlers :) + +(:~ + : This function "knows" all modules and their functions + : that are imported here + : You can leave it as it is, but it has to be here + :) +declare function api:lookup($name as xs:string) { + function-lookup(xs:QName($name), 1) +}; + +roaster:route($api:definitions, api:lookup#1) From c25f003d4e8dfbb6f5b2ed4a38d8d7737f653cdc Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:15:46 -0400 Subject: [PATCH 06/16] Move main modules into library modules for Roaster --- modules/bases.xqm | 633 +++++++++++++++++++++++++++++++++++++++++++ modules/browse.xq | 619 ------------------------------------------ modules/config.xqm | 102 +++++++ modules/snapshot.xq | 136 ---------- modules/snapshot.xqm | 143 ++++++++++ modules/update.xqm | 26 ++ 6 files changed, 904 insertions(+), 755 deletions(-) create mode 100644 modules/bases.xqm delete mode 100644 modules/browse.xq create mode 100644 modules/config.xqm delete mode 100644 modules/snapshot.xq create mode 100644 modules/snapshot.xqm create mode 100644 modules/update.xqm diff --git a/modules/bases.xqm b/modules/bases.xqm new file mode 100644 index 0000000..43f3aed --- /dev/null +++ b/modules/bases.xqm @@ -0,0 +1,633 @@ +xquery version "3.1"; + +module namespace bases="http://joewiz.org/ns/xquery/airlock/bases"; + +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; +import module namespace markdown="http://exist-db.org/xquery/markdown"; + +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +declare option output:method "html"; +declare option output:media-type "text/html"; + +(: + : TODO + : - implement remaining data types in bases:render-field + : - implement remaining field typeOptions + : - show field name instead of field ID when displaying formulaTextParsed +:) + + +(: I captured this list of class pairings from Airtable's multiselect color picker. I don't have a definitive list of color-to-css-class mappings, but any missing ones should be here: + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +The values used in Indexing Sandbox are: + + blue, + blueDarker, + cyan, + gray, + green, + orange, + pink, + pinkMedium, + purple, + purpleDark, + purpleMedium, + red, + redDark, + redDarker, + teal, + tealDarker, + yellow + +:) +declare variable $bases:color-to-css-class := + map { + "blue": "blueBright text-white", + "blueDarker": "blueDark1 text-blue-light2", + "cyan": "cyanBright text-white", + "gray": "grayBright text-white", + "grayDark": "grayDark1 text-gray-light2", + "green": "greenBright text-white", + "greenDark": "greenDark1 text-green-light2", + "orange": "orangeBright text-white", + "pink": "pinkBright text-white", + "pinkDark": "pinkDark1 text-pink-light2", + "pinkMedium": "pinkLight1 text-pink-dark1", + "purple": "purpleBright text-white", + "purpleDark": "purpleDark1 text-purple-light2", + "purpleMedium": "purpleLight1 text-purple-dark1", + "red": "redBright text-white", + "redDark": "redDark1 text-red-light2", + "redDarker": "redDark1 text-red-light2", + "teal": "tealBright text-white", + "tealDarker": "tealDark1 text-teal-light2", + "yellow": "yellowBright text-white", + "yellowDark": "yellowDark1 text-yellow-light2" + } +; + +declare function bases:render-airtable-flavored-markdown($markdown) { + $markdown + => replace("\*\*", "") + => replace(" ", " ") + => markdown:parse() +}; + +declare function bases:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) { + let $fields := $record?fields + let $field := $fields?($field-key) + let $columns := $table?columns + let $column := $columns?*[?name eq $field-key] + let $type := $column?type + let $type-options := $column?typeOptions + return + switch ($type) + (: TODO: add handling for these types: + - formula(text|date) + - lookup(error|text|multilineText|foreignKey) + - rollup + - select + - checkbox + - date + - number + - collaborator + - multipleAttachment + :) + case "text" return + if ($type-options?validatorName eq "url") then + element a { + attribute href { + $field + }, + $field + } + else + $field + case "multilineText" + case "richText" return + bases:render-airtable-flavored-markdown($field) + case "foreignKey" return + let $foreign-table-id := $column?foreignTableId + let $foreign-table := $tables[?id eq $foreign-table-id] + let $primary-column-name := $foreign-table?primaryColumnName + return + if (array:size($field) gt 1) then + element ul { + for $foreign-record-id in $field?* + let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] + let $foreign-record-label := $foreign-record?fields?($primary-column-name) + return + element li { + element a { + attribute href { + "?" + || string-join(( + "base-id=" || $base-id, + "snapshot-id=" || $snapshot-id, + "table-id=" || $foreign-table-id, + "record-id=" || $foreign-record-id + ), "&") + }, + $foreign-record-label + } + } + } + else + let $foreign-record-id := $field + let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] + let $foreign-record-label := $foreign-record?fields?($primary-column-name) + return + element a { + attribute href { + "?" + || string-join(( + "base-id=" || $base-id, + "snapshot-id=" || $snapshot-id, + "table-id=" || $foreign-table-id, + "record-id=" || $foreign-record-id + ), "&") + }, + $foreign-record-label + } + case "multiSelect" return + let $values := $field + let $choices := $type-options?choices + return + if (count($values) gt 1) then + element ol { + let $ordered-values := sort($values, (), function($value) { index-of($type-options?choiceOrder?*, $value) }) + for $value in $ordered-values + let $color := $choices[?name eq $value?color] + return + element li { + element span { + attribute class { "badge badge-pill " || $bases:color-to-css-class?($color) }, + $value + } + } + } + else + let $value := $values + let $color := $choices[?name eq $value]?color + return + element span { + attribute class { "badge badge-pill " || $bases:color-to-css-class?($color) }, + $value + } + case "multipleAttachment" return + let $attachments := $field?* + for $attachment in $attachments + let $filename := $attachment?filename + let $type := $attachment?type + let $url := $attachment?url + let $size := $attachment?size + return + switch ($type) + case "image/jpeg" case "application/pdf" return + let $thumbnails := $attachment?thumbnails + let $large := $thumbnails?large + return + element p { + element a { + attribute href { $url }, + element img { + attribute src { + $large?url + } + } + } + } + default return + "unknown image type " || $type + default return + if ($field instance of array(*)) then + if (array:size($field) gt 1) then + element ul { + $field?* ! element li { . } + } + else + $field?* + (: "Position Length" : { "specialValue" : "NaN" } :) + else if ($field instance of map(*)) then + $field?* + else + $field +}; + +declare function bases:view($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $snapshot-id := $request?parameters?snapshot-id + let $table-id := $request?parameters?table-id + let $field-id := $request?parameters?field-id + let $record-id := $request?parameters?record-id + let $bases := doc("/db/apps/airlock-data/bases/bases.xml")//base + let $base := $bases[id eq $base-id] + let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot + let $snapshot := $snapshots[id eq $snapshot-id] + let $tables := if ($base-id and $snapshot-id) then xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables") ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables/" || .) else () + let $table := $tables[?id eq $table-id] + let $columns := $table?columns?* + let $records := $table?records?* + let $column := $columns[?id eq $field-id] + let $record := $records[?id eq $record-id] + let $fields := $record?fields + let $render-function := function($field-key) { bases:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) } + let $base-name := $base/name/string() + let $api-key := $base/api-key/string() + let $custom-reports := $base//custom-report + let $snapshot-dateTime := $snapshot/created-dateTime => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]") + let $table-name := $table?name + let $column-name := $column?name + let $primary-column-name := $table?primaryColumnName + let $adjusted-primary-column-name := + if ($primary-column-name eq "id") then + "ID" + else + $primary-column-name + let $record-primary-field := $fields?($adjusted-primary-column-name) + let $item := + if (empty($base-id)) then + + + + + + + + + + + { + for $base in $bases + let $base-id := $base/id/string() + let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot + let $base-name := $base/name/string() + let $api-key := $base/api-key/string() + let $created-dateTime := $base/created-dateTime cast as xs:dateTime + let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) + order by $base-name + return + + + + + + + + } +
NameBase IDAPI KeyDate CreatedLast Snapshot
{$base-name}{$base-id}{$api-key}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{if (exists($last-snapshot)) then format-dateTime($last-snapshot, "[MNn] [D], [Y] [h]:[m01] [PN]") else No snapshots}
+ else if (exists($base-id) and empty($snapshot-id)) then + let $created-dateTime := $base/created-dateTime cast as xs:dateTime + let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) + return +
+
+
Base ID
+
{$base-id}
+
API Key
+
{$api-key}
+
+
    + +
  • Take a new snapshot (Note: This may take several minutes, depending on the size of the base.)
  • +
  • To avoid errors or omitted data, open the base-metadata.json file in eXide and make sure it is present and up-to-date, since snapshots require a complete list of every table's name; to obtain a current copy, go to this base’s API documentation and use the Airtable Schema Extractor Chrome extension to copy the complete JSON file, save it as base-metadata.json and upload it to the /db/apps/airlock-data/bases/{$base-id} collection in eXist using eXide or a WebDAV client like oXygen or Transmit.
  • + { + for $report in $custom-reports + return +
  • {$report/label/string()}: {$report/description/string()}
  • + } + +
+ + + + + + + + + + + + { + for $snapshot in $snapshots + let $snapshot-id := $snapshot/id/string() + let $created-dateTime := $snapshot/created-dateTime cast as xs:dateTime + order by $created-dateTime + return + + + + + + + + + } +
SnapshotDate CreatedTablesRecordsFieldsCells
{$snapshot-id}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{$snapshot/tables-count/string()}{$snapshot/records-count/string()}{$snapshot/fields-count/string()}{$snapshot/cells-count/string()}
+
+ else if (exists($base-id) and exists($snapshot-id) and empty($table-id)) then + element div { + element h2 { "Tables" }, + element ul { + for $table in $tables + let $table-id := $table?id + let $table-name := $table?name + order by $table-name + return + element li { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $table-id }, + $table-name + } + } + } + } + else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and (empty($record-id) and empty($field-id))) then + element div { + element h2 { "Fields" }, + element ul { + for $column in $columns + let $column-id := $column?id + let $column-name := $column?name + return + element li { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $table-id || "/fields/" || $column?id }, + $column-name + } + } + }, + element h2 { "Records" }, + element ul { + let $primary-column-name := $table?primaryColumnName + let $adjusted-primary-column-name := + (: + if ($primary-column-name eq "id") then + "ID" + else + :) + $primary-column-name + (: return element pre { serialize($table, map{"indent": true()}) }:) + let $records := $table?records + for $record in $records?* + let $record-id := $record?id + let $fields := $record?fields + let $record-name := $fields?($adjusted-primary-column-name) + order by $record-name collation "http://www.w3.org/2013/collation/UCA?numeric=yes" + return + element li { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $table-id || "/records/" || $record-id }, + $record-name + } + } + } + } + else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and exists($field-id)) then + element dl { + element dt { "id" }, + element dd { $field-id }, + element dt { "name" }, + element dd { $column?name }, + element dt { "type" }, + element dd { $column?type }, + if (map:contains($column, "typeOptions")) then + ( + element dt { "typeOptions" }, + element dd { + let $entries := $column?typeOptions + let $choices := $entries?choices?* + let $choice-order := $entries?choiceOrder + let $foreign-table-id := $entries?foreignTableId + let $foreign-table := $tables[?id eq $foreign-table-id] + let $symmetric-column-id := $entries?symmetricColumnId + let $symmetric-column := $foreign-table?columns?*[?id eq $symmetric-column-id] + let $relation-column-id := $entries?relationColumnId + let $relation-column := $table?columns?*[?id eq $relation-column-id] + (: "foreignTableRollupColumnId":) + return + if (exists($choices) and exists($choice-order)) then + element dl { + element dt { "choices" }, + element dd { + let $ordered-choices := sort($choices, (), function($choice) { index-of($choice-order, $choice?id) }) + return + element ol { + for $choice in $ordered-choices + let $color := $choice?color + return + element li { + element span { + attribute class { "badge badge-pill " || $bases:color-to-css-class?($color) }, + $choice?name + } + } + } + } + } + else + for $key in ($entries ! map:keys(.)[not(. = ("choices", "choice-order"))]) (: except ($choices, $choice-order) :) + let $entry := $entries($key) + return + element dl { + element dt { $key }, + element dd { + switch ($key) + case "foreignTableId" return + element a { + attribute href { + $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $foreign-table-id + }, + $foreign-table?name + } + case "symmetricColumnId" return + element a { + attribute href { + $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $foreign-table-id || "/fields/" || $symmetric-column-id + }, + $symmetric-column?name + } + case "relationColumnId" return + element a { + attribute href { + $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $table-id || "/fields/" || $relation-column-id + }, + $relation-column?name + } + case "dependencies" return + element ol { + for $referenced-column-id in $entry/?referencedColumnIdsForValue?* + let $referenced-column := $table?columns?*[?id eq $referenced-column-id] + return + element li { + element a { + attribute href { + $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $table-id || "/fields/" || $referenced-column-id + }, + $referenced-column?name + } + } + } + (: TODO parse field references :) + case "formulaTextParsed" return + element code { $entry } + default return + $entry + } + } + } + ) + else + () + } + else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and exists($record-id)) then + element dl { + element dt { "id" }, + element dd { $record-id }, + for $field-key in ($adjusted-primary-column-name, map:keys($fields)[. ne $adjusted-primary-column-name]) + let $column-id := $columns[?name eq $field-key]?id + return + ( + element dt { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/" || $table-id || "/fields/" || $column-id}, + $field-key + } + }, + element dd { $render-function($field-key) } + ) + } + else + () + let $breadcrumbs := + ( + Airlock, + text { " > " }, + Bases, + if ($base-id) then + ( + text { " > " }, + {$base-name} + ) + else + (), + if ($snapshot-id) then + ( + text { " > " }, + Snapshot {$snapshot-id} + ) + else + (), + if ($table-id) then + ( + text { " > " }, + “{$table-name}” Table + ) + else + (), + if ($field-id) then + ( + text { " > " }, + “{$column-name}” Field + ) + else + (), + if ($record-id) then + ( + text { " > " }, + “{$record-primary-field}” Record + ) + else + () + ) + let $title := $breadcrumbs/normalize-space() + let $content := + element div { + element h1 { + $breadcrumbs + }, + $item + } + return + app:wrap($content, $title) +}; + +declare function bases:welcome($request as map(*)) { + let $title := "Airlock" + let $base-url := $request?parameters?base-url + let $content := +
+

{$title}

+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+ return + app:wrap($content, $title) +}; diff --git a/modules/browse.xq b/modules/browse.xq deleted file mode 100644 index e62dc8a..0000000 --- a/modules/browse.xq +++ /dev/null @@ -1,619 +0,0 @@ -xquery version "3.1"; - -import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; -import module namespace markdown="http://exist-db.org/xquery/markdown"; - -declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; - -declare option output:method "html"; -declare option output:media-type "text/html"; - -(: - : TODO - : - implement remaining data types in local:render-field - : - implement remaining field typeOptions - : - show field name instead of field ID when displaying formulaTextParsed -:) - - -(: I captured this list of class pairings from Airtable's multiselect color picker. I don't have a definitive list of color-to-css-class mappings, but any missing ones should be here: - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -The values used in Indexing Sandbox are: - - blue, - blueDarker, - cyan, - gray, - green, - orange, - pink, - pinkMedium, - purple, - purpleDark, - purpleMedium, - red, - redDark, - redDarker, - teal, - tealDarker, - yellow - -:) -declare variable $local:color-to-css-class := - map { - "blue": "blueBright text-white", - "blueDarker": "blueDark1 text-blue-light2", - "cyan": "cyanBright text-white", - "gray": "grayBright text-white", - "grayDark": "grayDark1 text-gray-light2", - "green": "greenBright text-white", - "greenDark": "greenDark1 text-green-light2", - "orange": "orangeBright text-white", - "pink": "pinkBright text-white", - "pinkDark": "pinkDark1 text-pink-light2", - "pinkMedium": "pinkLight1 text-pink-dark1", - "purple": "purpleBright text-white", - "purpleDark": "purpleDark1 text-purple-light2", - "purpleMedium": "purpleLight1 text-purple-dark1", - "red": "redBright text-white", - "redDark": "redDark1 text-red-light2", - "redDarker": "redDark1 text-red-light2", - "teal": "tealBright text-white", - "tealDarker": "tealDark1 text-teal-light2", - "yellow": "yellowBright text-white", - "yellowDark": "yellowDark1 text-yellow-light2" - } -; - -declare function local:render-airtable-flavored-markdown($markdown) { - $markdown - => replace("\*\*", "") - => replace(" ", " ") - => markdown:parse() -}; - -declare function local:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) { - let $fields := $record?fields - let $field := $fields?($field-key) - let $columns := $table?columns - let $column := $columns?*[?name eq $field-key] - let $type := $column?type - let $type-options := $column?typeOptions - return - switch ($type) - (: TODO: add handling for these types: - - formula(text|date) - - lookup(error|text|multilineText|foreignKey) - - rollup - - select - - checkbox - - date - - number - - collaborator - - multipleAttachment - :) - case "text" return - if ($type-options?validatorName eq "url") then - element a { - attribute href { - $field - }, - $field - } - else - $field - case "multilineText" - case "richText" return - local:render-airtable-flavored-markdown($field) - case "foreignKey" return - let $foreign-table-id := $column?foreignTableId - let $foreign-table := $tables[?id eq $foreign-table-id] - let $primary-column-name := $foreign-table?primaryColumnName - return - if (array:size($field) gt 1) then - element ul { - for $foreign-record-id in $field?* - let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] - let $foreign-record-label := $foreign-record?fields?($primary-column-name) - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $foreign-table-id, - "record-id=" || $foreign-record-id - ), "&") - }, - $foreign-record-label - } - } - } - else - let $foreign-record-id := $field - let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] - let $foreign-record-label := $foreign-record?fields?($primary-column-name) - return - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $foreign-table-id, - "record-id=" || $foreign-record-id - ), "&") - }, - $foreign-record-label - } - case "multiSelect" return - let $values := $field - let $choices := $type-options?choices - return - if (count($values) gt 1) then - element ol { - let $ordered-values := sort($values, (), function($value) { index-of($type-options?choiceOrder?*, $value) }) - for $value in $ordered-values - let $color := $choices[?name eq $value?color] - return - element li { - element span { - attribute class { "badge badge-pill " || $local:color-to-css-class?($color) }, - $value - } - } - } - else - let $value := $values - let $color := $choices[?name eq $value]?color - return - element span { - attribute class { "badge badge-pill " || $local:color-to-css-class?($color) }, - $value - } - case "multipleAttachment" return - let $attachments := $field?* - for $attachment in $attachments - let $filename := $attachment?filename - let $type := $attachment?type - let $url := $attachment?url - let $size := $attachment?size - return - switch ($type) - case "image/jpeg" case "application/pdf" return - let $thumbnails := $attachment?thumbnails - let $large := $thumbnails?large - return - element p { - element a { - attribute href { $url }, - element img { - attribute src { - $large?url - } - } - } - } - default return - "unknown image type " || $type - default return - if ($field instance of array(*)) then - if (array:size($field) gt 1) then - element ul { - $field?* ! element li { . } - } - else - $field?* - (: "Position Length" : { "specialValue" : "NaN" } :) - else if ($field instance of map(*)) then - $field?* - else - $field -}; - -let $base-id := request:get-parameter("base-id", ()) -let $snapshot-id := request:get-parameter("snapshot-id", ()) -let $table-id := request:get-parameter("table-id", ()) -let $field-id := request:get-parameter("field-id", ()) -let $record-id := request:get-parameter("record-id", ()) -let $bases := doc("/db/apps/airlock-data/bases/bases.xml")//base -let $base := $bases[id eq $base-id] -let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot -let $snapshot := $snapshots[id eq $snapshot-id] -let $tables := if ($base-id and $snapshot-id) then xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables") ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables/" || .) else () -let $table := $tables[?id eq $table-id] -let $columns := $table?columns?* -let $records := $table?records?* -let $column := $columns[?id eq $field-id] -let $record := $records[?id eq $record-id] -let $fields := $record?fields -let $render-function := function($field-key) { local:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) } -let $base-name := $base/name/string() -let $api-key := $base/api-key/string() -let $snapshot-dateTime := $snapshot/created-dateTime => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]") -let $table-name := $table?name -let $column-name := $column?name -let $primary-column-name := $table?primaryColumnName -let $adjusted-primary-column-name := - if ($primary-column-name eq "id") then - "ID" - else - $primary-column-name -let $record-primary-field := $fields?($adjusted-primary-column-name) -let $item := - if (empty($base-id)) then - - - - - - - - - - - { - for $base in $bases - let $base-id := $base/id/string() - let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot - let $base-name := $base/name/string() - let $api-key := $base/api-key/string() - let $created-dateTime := $base/created-dateTime cast as xs:dateTime - let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) - order by $base-name - return - - - - - - - - } -
NameBase IDAPI KeyDate CreatedLast Snapshot
{$base-name}{$base-id}{$api-key}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{if (exists($last-snapshot)) then format-dateTime($last-snapshot, "[MNn] [D], [Y] [h]:[m01] [PN]") else No snapshots}
- else if (exists($base-id) and empty($snapshot-id)) then - let $created-dateTime := $base/created-dateTime cast as xs:dateTime - let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) - return -
-
-
Base ID
-
{$base-id}
-
API Key
-
{$api-key}
-
-
    - -
  • Take new snapshot (Note: This may take ~1-2 minutes, depending on the size of the base; to avoid errors or omitted data, be sure that the base-metadata.json file is up-to-date, since snapshots require a complete list of every table's name; to obtain a current copy, go to this base’s API documentation and use the Airtable Schema Extractor Chrome extension to copy and paste the complete JSON file into a text editor; save the file as base-metadata.json and upload it to the /db/apps/airlock-data/bases/{$base-id} collection in eXist using eXide or a WebDAV client like oXygen or Transmit.)
  • - -
- - - - - - - - - - - - { - for $snapshot in $snapshots - let $snapshot-id := $snapshot/id/string() - let $created-dateTime := $snapshot/created-dateTime cast as xs:dateTime - order by $created-dateTime - return - - - - - - - - - } -
SnapshotDate CreatedTablesRecordsFieldsCells
{$snapshot-id}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{$snapshot/tables-count/string()}{$snapshot/records-count/string()}{$snapshot/fields-count/string()}{$snapshot/cells-count/string()}
-
- else if (exists($base-id) and exists($snapshot-id) and empty($table-id)) then - element div { - element h2 { "Tables" }, - element ul { - for $table in $tables - let $table-id := $table?id - let $table-name := $table?name - order by $table-name - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $table-id - ), "&" - ) - }, - $table-name - } - } - } - } - else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and (empty($record-id) and empty($field-id))) then - element div { - element h2 { "Fields" }, - element ul { - for $column in $columns - let $column-id := $column?id - let $column-name := $column?name - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $table-id, - "field-id=" || $column-id - ), "&" - ) - }, - $column-name - } - } - }, - element h2 { "Records" }, - element ul { - let $primary-column-name := $table?primaryColumnName - let $adjusted-primary-column-name := - (: - if ($primary-column-name eq "id") then - "ID" - else - :) - $primary-column-name - (: return element pre { serialize($table, map{"indent": true()}) }:) - let $records := $table?records - for $record in $records?* - let $record-id := $record?id - let $fields := $record?fields - let $record-name := $fields?($adjusted-primary-column-name) - order by $record-name collation "http://www.w3.org/2013/collation/UCA?numeric=yes" - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $table-id, - "record-id=" || $record-id - ), "&" - ) - }, - $record-name - } - } - } - } - else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and exists($field-id)) then - element dl { - element dt { "id" }, - element dd { $field-id }, - element dt { "name" }, - element dd { $column?name }, - element dt { "type" }, - element dd { $column?type }, - if (map:contains($column, "typeOptions")) then - ( - element dt { "typeOptions" }, - element dd { - let $entries := $column?typeOptions - let $choices := $entries?choices?* - let $choice-order := $entries?choiceOrder - let $foreign-table-id := $entries?foreignTableId - let $foreign-table := $tables[?id eq $foreign-table-id] - let $symmetric-column-id := $entries?symmetricColumnId - let $symmetric-column := $foreign-table?columns?*[?id eq $symmetric-column-id] - let $relation-column-id := $entries?relationColumnId - let $relation-column := $table?columns?*[?id eq $relation-column-id] -(: "foreignTableRollupColumnId":) - return - if (exists($choices) and exists($choice-order)) then - element dl { - element dt { "choices" }, - element dd { - let $ordered-choices := sort($choices, (), function($choice) { index-of($choice-order, $choice?id) }) - return - element ol { - for $choice in $ordered-choices - let $color := $choice?color - return - element li { - element span { - attribute class { "badge badge-pill " || $local:color-to-css-class?($color) }, - $choice?name - } - } - } - } - } - else - for $key in ($entries ! map:keys(.)[not(. = ("choices", "choice-order"))]) (: except ($choices, $choice-order) :) - let $entry := $entries($key) - return - element dl { - element dt { $key }, - element dd { - switch ($key) - case "foreignTableId" return - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $foreign-table-id - }, - $foreign-table?name - } - case "symmetricColumnId" return - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $foreign-table-id || "&field-id=" || $symmetric-column-id - }, - $symmetric-column?name - } - case "relationColumnId" return - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $table-id || "&field-id=" || $relation-column-id - }, - $relation-column?name - } - case "dependencies" return - element ol { - for $referenced-column-id in $entry/?referencedColumnIdsForValue?* - let $referenced-column := $table?columns?*[?id eq $referenced-column-id] - return - element li { - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $table-id || "&field-id=" || $referenced-column-id - }, - $referenced-column?name - } - } - } - (: TODO parse field references :) - case "formulaTextParsed" return - element code { $entry } - default return - $entry - } - } - } - ) - else - () - } - else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and exists($record-id)) then - element dl { - element dt { "id" }, - element dd { $record-id }, - for $field-key in ($adjusted-primary-column-name, map:keys($fields)[. ne $adjusted-primary-column-name]) - let $column-id := $columns[?name eq $field-key]?id - return - ( - element dt { - element a { - attribute href { "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $table-id || "&field-id=" || $column-id}, - $field-key - } - }, - element dd { $render-function($field-key) } - ) - } - else - () -let $breadcrumbs := - ( - Airlock, - text { " > " }, - Bases, - if ($base-id) then - ( - text { " > " }, - {$base-name} - ) - else - (), - if ($snapshot-id) then - ( - text { " > " }, - Snapshot {$snapshot-id} - ) - else - (), - if ($table-id) then - ( - text { " > " }, - “{$table-name}” Table - ) - else - (), - if ($field-id) then - ( - text { " > " }, - “{$column-name}” Field - ) - else - (), - if ($record-id) then - ( - text { " > " }, - “{$record-primary-field}” Record - ) - else - () - ) -let $title := $breadcrumbs/normalize-space() -let $content := - element div { - element h1 { - $breadcrumbs - }, - $item - } -return - app:wrap($content, $title) \ No newline at end of file diff --git a/modules/config.xqm b/modules/config.xqm new file mode 100644 index 0000000..48f9231 --- /dev/null +++ b/modules/config.xqm @@ -0,0 +1,102 @@ +xquery version "3.1"; + +(:~ + : Configuration options for the application and a set of helper functions to access + : the application context. + :) + +module namespace config="http://exist-db.org/xquery/apps/config"; + +declare namespace system="http://exist-db.org/xquery/system"; + +declare namespace expath="http://expath.org/ns/pkg"; +declare namespace repo="http://exist-db.org/xquery/repo"; + +declare variable $config:login-domain := "org.joewiz.airlock.login"; + +(: Determine the application root collection from the current module load path :) +declare variable $config:app-root := + let $rawPath := system:get-module-load-path() + let $modulePath := + (: strip the xmldb: part :) + if (starts-with($rawPath, "xmldb:exist://")) then + if (starts-with($rawPath, "xmldb:exist://embedded-eXist-server")) then + substring($rawPath, 36) + else if (starts-with($rawPath, "xmldb:exist://null")) then + substring($rawPath, 19) + else + substring($rawPath, 15) + else + $rawPath + return + substring-before($modulePath, "/modules") +; + +(: Default collection and resource names for binary assets and extracted package metadata :) + +declare variable $config:app-data-parent-col := "/db/apps"; +declare variable $config:app-data-col-name := "airlock-data"; +declare variable $config:bases-col-name := "bases"; +declare variable $config:snapshots-col-name := "snapshots"; +declare variable $config:tables-json-col-name := "tables-json"; +declare variable $config:tables-xml-col-name := "tables-xml"; + +declare variable $config:app-data-col := $config:app-data-parent-col || "/" || $config:bases-col-name; +declare variable $config:bases-col := $config:app-data-col || "/" || $config:bases-col-name; + +declare variable $config:bases-doc-name := "bases.xml"; +declare variable $config:base-metadata-doc-name := "base-metadata.json"; + +declare variable $config:bases-doc := $config:bases-col || "/" || $config:bases-doc-name; + + + +(:~ + : Returns the repo.xml descriptor for the current application. + :) +declare function config:repo-descriptor() as element(repo:meta) { + doc(concat($config:app-root, "/repo.xml"))/repo:meta +}; + +(:~ + : Returns the permissions information from the repo.xml descriptor. + :) +declare function config:repo-permissions() as map(*) { + config:repo-descriptor()/repo:permissions ! + map { + "user": ./@user/string(), + "group": ./@group/string(), + "mode": ./@mode/string() + } +}; + +(:~ + : Returns the expath-pkg.xml descriptor for the current application. + :) +declare function config:expath-descriptor() as element(expath:package) { + doc(concat($config:app-root, "/expath-pkg.xml"))/expath:package +}; + +(:~ + : For debugging: generates a table showing all properties defined + : in the application descriptors. + :) +declare function config:app-info($node as node(), $params as element(parameters)?, $modes as item()*) { + let $expath := config:expath-descriptor() + let $repo := config:repo-descriptor() + return + + + + + + { + for $attr in ($expath/@*, $expath/*, $repo/*) + return + + + + + } +
app collection:{$config:app-root}
{node-name($attr)}:{$attr/string()}
+}; \ No newline at end of file diff --git a/modules/snapshot.xq b/modules/snapshot.xq deleted file mode 100644 index f421a2b..0000000 --- a/modules/snapshot.xq +++ /dev/null @@ -1,136 +0,0 @@ -xquery version "3.1"; - -import module namespace airtable="http://joewiz.org/ns/xquery/airtable"; -import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; - -declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; - -declare option output:method "html"; -declare option output:media-type "text/html"; - -declare function local:get-table($api-key as xs:string, $base-id as xs:string, $base-metadata as map(*)) { - let $table := airtable:list-records($api-key, $base-id, $base-metadata?name, true(), (), (), (), (), (), (), ()) - return - (: pass error messages back through :) - if ($table instance of element()) then - $table - else - map { - "name": $base-metadata?name, - "id": $base-metadata?id, - "primaryColumnName": $base-metadata?primaryColumnName, - "columns": $base-metadata?columns?*, - "records": array:join($table?records) - } -}; - -(: ensure columns entry contains an array :) -declare function local:fix-columns-entry($table as map(*)*) { - map:merge(( - for $key in map:keys($table) - return - if ($key eq "columns" and map:get($table, $key) instance of map(*)) then - map:entry("columns", array { $table?columns } ) - else - map:entry($key, $table($key)) - )) -}; - -let $base-id := request:get-parameter("base-id", ()) -let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] -let $base-id := $base/id/string() -let $base-name := $base/name/string() -let $api-key := $base/api-key/string() -let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots -let $next-id := ($snapshots/snapshot[last()]/id, "0")[1] cast as xs:integer + 1 -let $prepare := - ( - xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id, "snapshots"), - xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id || "/snapshots", $next-id), - xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, "tables"), - xmldb:copy-resource( - "/db/apps/airlock-data/bases/" || $base-id, - "base-metadata.json", - "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, - "base-metadata.json" - ), - update insert element snapshot { element id { $next-id }, element created-dateTime { current-dateTime() } } into $snapshots - ) - -(: https://github.com/Airtable/airtable.js/issues/12#issuecomment-349987627 - : - : for exporting table metadata, start from https://airtable.com/appe0AfkruafOCgrw/api/docs :) - -let $tables := json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/base-metadata.json")?* -let $store := - for $base-metadata in $tables - let $contents := local:get-table($api-key, $base-id, $base-metadata) - (: - if ($contents instance of element()) then - app:wrap( - element div { - element h1 { error }, - element pre { $contents => serialize() } - }, - "Error" - ) - else - :) - return - xmldb:store( - "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables", - ($base-metadata?name || ".json") - (: space, nbsp, plus, colon, slash, bullet point :) - => replace("[  +:/•]", "-") - (: parentheses :) - => replace("[\(\)]", "") - => replace("-+", "-") - => replace("^-", "_") - => lower-case(), - $contents => serialize(map{"method": "json", "indent": true()}) - ) -let $summarize := - let $resources := xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables") - let $tables := $resources ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables/" || .) - let $records-count := - sum( - for $table in $tables - return - array:size($table?records) - ) - let $fields-count := - sum( - for $table in $tables - return - if ($table?columns instance of array(*)) then - array:size($table?columns) - else - 1 - ) - let $cells-count := - sum( - for $record in $tables?records?* - return - map:size($record?fields) - ) - return - update insert ( - element tables-count { count($tables) }, - element records-count { $records-count }, - element fields-count { $fields-count }, - element cells-count { $cells-count } - ) - into $snapshots//snapshot[id eq $next-id cast as xs:string] -let $content := - element div { - element p { "Stored " || count($store) || " resources:" }, - element ul { $store ! element li { . } }, - element p { - element a { - attribute href { "browse.xq?base-id=" || $base-id || "&snapshot-id=" || $next-id }, - "View snapshot " || $next-id - } - } - } -return - app:wrap($content, "Created snapshot " || $next-id) diff --git a/modules/snapshot.xqm b/modules/snapshot.xqm new file mode 100644 index 0000000..c58e181 --- /dev/null +++ b/modules/snapshot.xqm @@ -0,0 +1,143 @@ +xquery version "3.1"; + +module namespace snapshot="http://joewiz.org/ns/xquery/airlock/snapshot"; + +import module namespace airtable="http://joewiz.org/ns/xquery/airtable"; +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; + +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +declare option output:method "html"; +declare option output:media-type "text/html"; + +declare function snapshot:get-table($api-key as xs:string, $base-id as xs:string, $base-metadata as map(*)) { + let $table := airtable:list-records($api-key, $base-id, $base-metadata?name, true(), (), (), (), (), (), (), ()) + return + (: pass error messages back through :) + if ($table instance of element()) then + $table + else + map { + "name": $base-metadata?name, + "id": $base-metadata?id, + "primaryColumnName": $base-metadata?primaryColumnName, + "columns": $base-metadata?columns?*, + "records": array:join($table?records) + } +}; + +(: ensure columns entry contains an array :) +declare function snapshot:fix-columns-entry($table as map(*)*) { + map:merge(( + for $key in map:keys($table) + return + if ($key eq "columns" and map:get($table, $key) instance of map(*)) then + map:entry("columns", array { $table?columns } ) + else + map:entry($key, $table($key)) + )) +}; + +declare function snapshot:hit-me($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $base-name := $base/name/string() + let $api-key := $base/api-key/string() + let $snapshots-doc := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots + let $next-id := ($snapshots-doc/snapshot[last()]/id, "0")[1] cast as xs:integer + 1 + let $snapshot := element snapshot { element id { $next-id }, element created-dateTime { current-dateTime() } } + let $prepare := + ( + xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id, "snapshots"), + xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id || "/snapshots", $next-id), + xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, "tables"), + xmldb:copy-resource( + "/db/apps/airlock-data/bases/" || $base-id, + "base-metadata.json", + "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, + "base-metadata.json" + ), + if (exists($snapshots-doc)) then + update insert $snapshot into $snapshots-doc + else + xmldb:store("/db/apps/airlock-data/bases/" || $base-id, "snapshots.xml", element snapshots { $snapshot } ) + ) + + (: https://github.com/Airtable/airtable.js/issues/12#issuecomment-349987627 + : + : for exporting table metadata, start from https://airtable.com/appe0AfkruafOCgrw/api/docs :) + let $tables := json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/base-metadata.json")?* + let $store := + for $base-metadata in $tables + let $contents := snapshot:get-table($api-key, $base-id, $base-metadata) + (: + if ($contents instance of element()) then + app:wrap( + element div { + element h1 { error }, + element pre { $contents => serialize() } + }, + "Error" + ) + else + :) + return + xmldb:store( + "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables", + ($base-metadata?name || ".json") + (: space, nbsp, plus, colon, slash, bullet point :) + => replace("[  +:/•]", "-") + (: parentheses :) + => replace("[\(\)]", "") + => replace("-+", "-") + => replace("^-", "_") + => lower-case(), + $contents => serialize(map{"method": "json", "indent": true()}) + ) + let $summarize := + let $resources := xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables") + let $tables := $resources ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables/" || .) + let $records-count := + sum( + for $table in $tables + return + array:size($table?records) + ) + let $fields-count := + sum( + for $table in $tables + return + if ($table?columns instance of array(*)) then + array:size($table?columns) + else + 1 + ) + let $cells-count := + sum( + for $record in $tables?records?* + return + map:size($record?fields) + ) + return + update insert ( + element tables-count { count($tables) }, + element records-count { $records-count }, + element fields-count { $fields-count }, + element cells-count { $cells-count } + ) + into $snapshots-doc//snapshot[id eq $next-id cast as xs:string] + let $content := + element div { + element p { "Stored " || count($store) || " resources:" }, + element ul { $store ! element li { . } }, + element p { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/" || $next-id }, + "View snapshot " || $next-id + } + } + } + return + app:wrap($content, "Created snapshot " || $next-id) +}; \ No newline at end of file diff --git a/modules/update.xqm b/modules/update.xqm new file mode 100644 index 0000000..e55c694 --- /dev/null +++ b/modules/update.xqm @@ -0,0 +1,26 @@ +xquery version "3.1"; + +module namespace update="http://joewiz.org/ns/xquery/airlock/update"; + +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +declare option output:method "html"; +declare option output:media-type "text/html"; + +declare function update:base-metadata($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $file := request:get-uploaded-file-data("files[]") + let $base-col := + if (xmldb:collection-available("/db/apps/airlock-data/bases/" || $base-id)) then + "/db/apps/airlock-data/bases/" || $base-id + else + xmldb:create-collection("/db/apps/airlock-data/bases", $base-id) + let $store := xmldb:store("/db/apps/airlock-data/bases/" || $base-id, "base-metadata.json", $fil) + return +
+

Success

+

Successfully stored {$store}

+

Return to browsing base {$base-id}

+
+}; \ No newline at end of file From e89977ee0b5bfa55fa0f96ec0539196afa65c9a1 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:19:47 -0400 Subject: [PATCH 07/16] Remove unused post-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There’s now an explicit login function for functions that write to the database --- post-install.xq | 9 --------- repo.xml | 1 - 2 files changed, 10 deletions(-) delete mode 100644 post-install.xq diff --git a/post-install.xq b/post-install.xq deleted file mode 100644 index 796952f..0000000 --- a/post-install.xq +++ /dev/null @@ -1,9 +0,0 @@ -xquery version "3.1"; - -import module namespace xmldb="http://exist-db.org/xquery/xmldb"; - -(: the target collection into which the app is deployed :) -declare variable $target external; - -sm:chown(xs:anyURI($target || "/snapshot.xq"), "admin"), -sm:chmod(xs:anyURI($target || "/snapshot.xq"), "rwsr-xr-x") \ No newline at end of file diff --git a/repo.xml b/repo.xml index 0e7d40c..73e6b4c 100644 --- a/repo.xml +++ b/repo.xml @@ -9,7 +9,6 @@ true application airlock - post-install.xq
    From 06d74223e4aafcef8ec19551ebe6cdd448cadb5a Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:39:06 -0400 Subject: [PATCH 08/16] Fix mocha app_spec test The expath-pkg.xml title is the app name, whereas repo.xml, build.xml, and package.json contain the app description --- test/mocha/app_spec.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/mocha/app_spec.js b/test/mocha/app_spec.js index 247b322..9bdab96 100644 --- a/test/mocha/app_spec.js +++ b/test/mocha/app_spec.js @@ -85,17 +85,11 @@ describe('file system checks', function () { var repoDesc = parsed.childNamed('description').val } - if (fs.existsSync('expath-pkg.xml')) { - const exPkg = fs.readFileSync('expath-pkg.xml', 'utf8') - const parsed = new xmldoc.XmlDocument(exPkg) - var exPkgDesc = parsed.childNamed('title').val - } - - const desc = [exPkgDesc, buildDesc, pkgDesc, repoDesc, buildDesc].filter(Boolean) + const desc = [buildDesc, pkgDesc, repoDesc].filter(Boolean) let i = 0 // console.log(desc) desc.forEach(function () { - expect(desc[i]).to.equal(exPkgDesc) + expect(desc[i]).to.equal(pkgDesc) i++ }) done() From 4e63941e0fd1523a950a42754d73c8a1f13b3d7c Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 20 Mar 2021 13:40:07 -0400 Subject: [PATCH 09/16] Fix mocha rest_spec and cypress tests Update pending tests so they pass Remove pending designation so pending tests run --- test/cypress/integration/landing_spec.js | 7 +++---- test/mocha/rest_spec.js | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/test/cypress/integration/landing_spec.js b/test/cypress/integration/landing_spec.js index ef72532..31beabf 100644 --- a/test/cypress/integration/landing_spec.js +++ b/test/cypress/integration/landing_spec.js @@ -1,9 +1,8 @@ /* global cy */ describe('The landing page', function () { - it.skip ('should load ', function () { - cy.visit('/exist/apps/airlock/index.html') - .get('.alert') - .contains('app.xql') + it ('should load ', function () { + cy.visit('/exist/apps/airlock/') + .contains('Airlock') }) }) diff --git a/test/mocha/rest_spec.js b/test/mocha/rest_spec.js index d79ee02..dff202a 100644 --- a/test/mocha/rest_spec.js +++ b/test/mocha/rest_spec.js @@ -29,9 +29,9 @@ describe('rest api returns', function () { }) }) - it.skip('file index.html exists in application root', function (done) { + it('application root is available from rest endpoint', function (done) { client - .get('/exist/rest/db/apps/airlock/index.html') + .get('/exist/rest/db/apps/airlock') .expect(200) .end(function (err, res) { expect(res.status).to.equal(200) From b3f77e75d302c5b97c2240282bfa1f8ffd50e05b Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 23 Mar 2021 03:39:04 -0400 Subject: [PATCH 10/16] [ignore] Reformat --- repo.xml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/repo.xml b/repo.xml index 73e6b4c..05b34f0 100644 --- a/repo.xml +++ b/repo.xml @@ -1,19 +1,19 @@ - - Take snapshots of Airtable bases for offline browsing and transformation - Joe Wicentowski - https://github.com/joewiz/airlock - stable - AGPL-3.0 - true - application - airlock - - -
      -
    • Initial release
    • -
    -
    -
    + + Take snapshots of Airtable bases for offline browsing and + transformation + Joe Wicentowski + https://github.com/joewiz/airlock + stable + AGPL-3.0 + true + application + airlock + + +
      +
    • Initial release
    • +
    +
    +
    From 66f26b7ee97e04b88b9b6230d049046674229909 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 23 Mar 2021 03:40:20 -0400 Subject: [PATCH 11/16] Use post-install: set up collections, permissions --- modules/config.xqm | 10 +++-- post-install.xq | 99 ++++++++++++++++++++++++++++++++++++++++++++++ repo.xml | 3 ++ 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 post-install.xq diff --git a/modules/config.xqm b/modules/config.xqm index 48f9231..34db962 100644 --- a/modules/config.xqm +++ b/modules/config.xqm @@ -5,7 +5,7 @@ xquery version "3.1"; : the application context. :) -module namespace config="http://exist-db.org/xquery/apps/config"; +module namespace config="http://joewiz.org/ns/xquery/airlock/config"; declare namespace system="http://exist-db.org/xquery/system"; @@ -37,19 +37,21 @@ declare variable $config:app-root := declare variable $config:app-data-parent-col := "/db/apps"; declare variable $config:app-data-col-name := "airlock-data"; declare variable $config:bases-col-name := "bases"; +declare variable $config:keys-col-name := "keys"; declare variable $config:snapshots-col-name := "snapshots"; declare variable $config:tables-json-col-name := "tables-json"; declare variable $config:tables-xml-col-name := "tables-xml"; -declare variable $config:app-data-col := $config:app-data-parent-col || "/" || $config:bases-col-name; +declare variable $config:app-data-col := $config:app-data-parent-col || "/" || $config:app-data-col-name; declare variable $config:bases-col := $config:app-data-col || "/" || $config:bases-col-name; +declare variable $config:keys-col := $config:app-data-col || "/" || $config:keys-col-name; declare variable $config:bases-doc-name := "bases.xml"; +declare variable $config:keys-doc-name := "keys.xml"; declare variable $config:base-metadata-doc-name := "base-metadata.json"; declare variable $config:bases-doc := $config:bases-col || "/" || $config:bases-doc-name; - - +declare variable $config:keys-doc := $config:keys-col || "/" || $config:keys-doc-name; (:~ : Returns the repo.xml descriptor for the current application. diff --git a/post-install.xq b/post-install.xq new file mode 100644 index 0000000..40c5506 --- /dev/null +++ b/post-install.xq @@ -0,0 +1,99 @@ +xquery version "3.1"; + +(:~ + : This post-install script sets permissions on the data collection hierarchy. + : When pre-install creates the data collection, its permissions are admin/dba. + : This ensures the collections are owned by the default user and group for the app. + :) + +import module namespace config="http://joewiz.org/ns/xquery/airlock/config" at "modules/config.xqm"; + +declare namespace sm="http://exist-db.org/xquery/securitymanager"; +declare namespace system="http://exist-db.org/xquery/system"; +declare namespace xmldb="http://exist-db.org/xquery/xmldb"; + +(: The following external variables are set by the repo:deploy function :) + +(: file path pointing to the exist installation directory :) +declare variable $home external; +(: path to the directory containing the unpacked .xar package :) +declare variable $dir external; +(: the target collection into which the app is deployed :) +declare variable $target external; + + +(: Helper function to recursively create a collection hierarchy :) +declare function local:mkcol-recursive($collection as xs:string, $components as xs:string*) { + if (exists($components)) then + let $newColl := concat($collection, "/", $components[1]) + return ( + xmldb:create-collection($collection, $components[1]), + local:mkcol-recursive($newColl, subsequence($components, 2)) + ) + else + () +}; + +(: Create a collection hierarchy :) +declare function local:mkcol($collection as xs:string, $path as xs:string) { + local:mkcol-recursive($collection, tokenize($path, "/")) +}; + +(:~ + : Set user and group to be owner by values in repo.xml + :) +declare function local:set-data-collection-permissions($resource as xs:string) { + if (sm:get-permissions(xs:anyURI($resource))/sm:permission/@group = config:repo-permissions()?group) then + () + else + ( + sm:chown($resource, config:repo-permissions()?user), + sm:chgrp($resource, config:repo-permissions()?group), + sm:chmod(xs:anyURI($resource), config:repo-permissions()?mode) + ) +}; + +(: Create the data collection hierarchy :) + +xmldb:create-collection($config:app-data-parent-col, $config:app-data-col-name), +let $col-names := + ( + $config:bases-col-name, + $config:keys-col-name + ) +for $col-name in $col-names +return + xmldb:create-collection($config:app-data-col, $col-name), + +(: Create the blank bases.xml and keys.xml documents:) + +if (doc-available($config:bases-doc)) then + () +else + xmldb:store($config:bases-col, $config:bases-doc-name, ), +if (doc-available($config:keys-doc)) then + () +else + xmldb:store($config:keys-col, $config:keys-doc-name, ), + +(: Set user and group ownership on the data collection hierarchy :) + +for $resource in ( + $config:app-data-col, + $config:bases-col, + $config:bases-doc, + $config:keys-col, + $config:keys-doc + ) +return + local:set-data-collection-permissions($resource) +, + +(: Set login.xq handler to admin/dba with sticky bit so that login can call dba functions to check group membership :) + +xs:anyURI($target || "/modules/login.xq") ! + ( + sm:chown(., "admin"), + sm:chgrp(., "dba"), + sm:chmod(., "rwxrwsr-x") + ) \ No newline at end of file diff --git a/repo.xml b/repo.xml index 05b34f0..1ed40dc 100644 --- a/repo.xml +++ b/repo.xml @@ -9,6 +9,9 @@ true application airlock + + post-install.xq +
      From 07f0ae3038694b84d66324b8a893bf2328c71590 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 23 Mar 2021 03:42:08 -0400 Subject: [PATCH 12/16] Split login API from main API to keep logins to members of the airlock group, the login.xq needs to run as dba; group sticky bit is set in post-install --- api.json | 479 -------------------- controller.xql | 10 +- modules/api.json | 1065 ++++++++++++++++++++++++++++++++++++++++++++ modules/login.json | 117 +++++ modules/login.xq | 41 ++ 5 files changed, 1231 insertions(+), 481 deletions(-) delete mode 100644 api.json create mode 100644 modules/api.json create mode 100644 modules/login.json create mode 100644 modules/login.xq diff --git a/api.json b/api.json deleted file mode 100644 index 4ef6b53..0000000 --- a/api.json +++ /dev/null @@ -1,479 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "version": "1.0.0", - "title": "Airlock API", - "description": "Airlock custom endpoints" - }, - "servers": [ - { - "description": "Endpoint for testing on localhost", - "url": "http://localhost:8080/exist/apps/airlock" - } - ], - "components": { - "securitySchemes": { - "basicAuth": { - "type": "http", - "scheme": "basic" - }, - "cookieAuth": { - "type": "apiKey", - "name": "airlock.joewiz.org.login", - "in": "cookie" - } - } - }, - "paths": { - "/": { - "get": { - "summary": "Landing page", - "operationId": "bases:welcome", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the landing page!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/bases": { - "get": { - "summary": "List bases", - "operationId": "bases:view", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the listing of bases!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/bases/{base-id}": { - "get": { - "summary": "View a base", - "operationId": "bases:view", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the info about the base!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/bases/{base-id}/snapshots/{snapshot-id}": { - "get": { - "summary": "View a base", - "operationId": "bases:view", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "snapshot-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the info about the base!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}": { - "get": { - "summary": "View a base", - "operationId": "bases:view", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "snapshot-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "table-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the info about the base!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/records/{record-id}": { - "get": { - "summary": "View a base", - "operationId": "bases:view", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "snapshot-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "table-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "record-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the info about the base!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/fields/{field-id}": { - "get": { - "summary": "View a base", - "operationId": "bases:view", - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "snapshot-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "table-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "field-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the info about the base!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "/{base-id}/snapshot": { - "get": { - "summary": "Take snapshot", - "operationId": "snapshot:hit-me", - "x-constraints": { - "group": "dba" - }, - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "... the result of the snapshot!", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - } - } - } - }, - "{base-id}/base-metadata": { - "post": { - "summary": "Post body as application/octet-stream", - "operationId": "update:base-metadata", - "x-constraints": { - "group": "dba" - }, - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "files[]": { - "type": "array", - "items": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "parameters": [ - { - "name": "base-url", - "in": "query", - "required": true, - "schema": { "type": "string" } - }, - { - "name": "base-id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "Upload result", - "content": { - "text/html": { - "schema": { "type": "string" } - } - } - }, - "401": { - "description": "Permission denied", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "details": { - "type": "object", - "nullable": true - } - } - } - } - } - }, - "404": { - "description": "Upload collection not found", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "500": { - "description": "Upload collection not found", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - } - } - } - }, - "/login": { - "post": { - "summary": "Login the user", - "description": "Login the given user", - "operationId": "api:login", - "parameters": [ - { - "name": "user", - "in": "query", - "description": "Name of the user", - "schema": { - "type": "string", - "example": "tei", - "nullable": true - } - }, - { - "name": "password", - "in": "query", - "schema": { - "type": "string", - "format": "password", - "example": "simple", - "nullable": true - } - }, - { - "name": "logout", - "in": "query", - "description": "Set to some value to log out the current user", - "schema": { - "type": "string", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "type": "string" - }, - "groups": { - "type": "array", - "items":{ - "type": "string" - } - }, - "dba": { - "type": "boolean" - } - } - } - } - } - }, - "401": { - "description": "Wrong user or password" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "basicAuth": [] - } - ] -} \ No newline at end of file diff --git a/controller.xql b/controller.xql index c815ba7..d6db81c 100644 --- a/controller.xql +++ b/controller.xql @@ -13,11 +13,17 @@ if ($exist:path eq "") then - + (: all other requests are passed on the Open API router :) else - + diff --git a/modules/api.json b/modules/api.json new file mode 100644 index 0000000..44cd8d3 --- /dev/null +++ b/modules/api.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Airlock API", + "description": "Airlock custom endpoints" + }, + "servers": [ + { + "description": "Endpoint for testing on localhost", + "url": "http://localhost:8080/exist/apps/airlock" + } + ], + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "cookieAuth": { + "type": "apiKey", + "name": "airlock.joewiz.org.login", + "in": "cookie" + } + } + }, + "paths": { + "/": { + "get": { + "summary": "Landing page", + "operationId": "bases:welcome", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the landing page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases": { + "get": { + "summary": "List bases", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the listing of bases!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Add a new base", + "description": "Add a new base", + "operationId": "bases:create-base", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "query", + "description": "Base ID", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "base-name", + "in": "query", + "description": "Base Name", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "permission-level", + "in": "query", + "description": "Permission level", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "Base notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + }, + "/bases/{base-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/edit": { + "get": { + "summary": "Edit a base", + "operationId": "bases:edit-base-form", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Edit a base", + "operationId": "bases:edit-base", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "description": "Base ID", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "new-base-id", + "in": "query", + "description": "Base Name", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "base-name", + "in": "query", + "description": "Base Name", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "permission-level", + "in": "query", + "description": "Permission level", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "Base notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/delete": { + "get": { + "summary": "Delete a base", + "operationId": "bases:delete-base-confirm", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Delete a base", + "operationId": "bases:delete-base", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/edit/custom-reports": { + "get": { + "summary": "Add new Custom Report form", + "description": "Add new Custom Report form", + "operationId": "bases:create-custom-report-form", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + }, + "post": { + "summary": "Add a new Custom Report", + "description": "Add a new Custom Report", + "operationId": "bases:create-custom-report", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "report-label", + "in": "query", + "description": "Airtable Username", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "report-description", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "report-location", + "in": "query", + "description": "Metadata API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/delete": { + "get": { + "summary": "Delete a snapshot", + "operationId": "bases:delete-snapshot", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/records/{record-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "record-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/fields/{field-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "field-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshot": { + "get": { + "summary": "Take snapshot", + "operationId": "bases:create-snapshot", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/base-metadata": { + "post": { + "summary": "Post body as application/octet-stream", + "operationId": "bases:update-base-metadata", + "x-constraints": { + "group": "airlock" + }, + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files[]": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Upload result", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "details": { + "type": "object", + "nullable": true + } + } + } + } + } + }, + "404": { + "description": "Upload collection not found", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "500": { + "description": "Upload collection not found", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/keys": { + "get": { + "summary": "Manage API keys", + "description": "Manage API Keys", + "operationId": "keys:welcome", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + }, + "post": { + "summary": "Add a new API key", + "description": "Login the given user", + "operationId": "keys:create-key", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "username", + "in": "query", + "description": "Airtable Username", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "metadata-api-key", + "in": "query", + "description": "Metadata API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "API key notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + }, + "/keys/{key-id}/delete": { + "get": { + "summary": "Delete API key", + "description": "Delete API key", + "operationId": "keys:delete-key-confirm", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Delete API key", + "description": "Delete API key", + "operationId": "keys:delete-key", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/keys/{key-id}": { + "get": { + "summary": "Edit API key", + "description": "Edit API key", + "operationId": "keys:edit-form", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Edit API key", + "description": "Edit API key", + "operationId": "keys:update-key", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "username", + "in": "query", + "description": "Airtable Username", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "metadata-api-key", + "in": "query", + "description": "Metadata API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "API key notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "basicAuth": [] + } + ] +} \ No newline at end of file diff --git a/modules/login.json b/modules/login.json new file mode 100644 index 0000000..924bbe3 --- /dev/null +++ b/modules/login.json @@ -0,0 +1,117 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Airlock API", + "description": "Airlock custom endpoints" + }, + "servers": [ + { + "description": "Endpoint for testing on localhost", + "url": "http://localhost:8080/exist/apps/airlock" + } + ], + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "cookieAuth": { + "type": "apiKey", + "name": "airlock.joewiz.org.login", + "in": "cookie" + } + } + }, + "paths": { + "/login": { + "get": { + "summary": "Edit API key", + "description": "Edit API key", + "operationId": "app:login-form", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Log the user in", + "description": "Log the given user in", + "operationId": "app:login", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "user", + "in": "query", + "description": "Name of the user", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "password", + "in": "query", + "schema": { + "type": "string", + "format": "password", + "example": "simple", + "nullable": true + } + }, + { + "name": "logout", + "in": "query", + "description": "Set to some value to log out the current user", + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "basicAuth": [] + } + ] +} \ No newline at end of file diff --git a/modules/login.xq b/modules/login.xq new file mode 100644 index 0000000..3a7df60 --- /dev/null +++ b/modules/login.xq @@ -0,0 +1,41 @@ +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"; + +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; +import module namespace bases="http://joewiz.org/ns/xquery/airlock/bases" at "bases.xqm"; +import module namespace config="http://joewiz.org/ns/xquery/airlock/config" at "config.xqm"; +import module namespace keys="http://joewiz.org/ns/xquery/airlock/keys" at "keys.xqm"; +import module namespace snapshot="http://joewiz.org/ns/xquery/airlock/snapshot" at "snapshot.xqm"; +import module namespace update="http://joewiz.org/ns/xquery/airlock/update" at "update.xqm"; + +import module namespace errors="http://e-editiones.org/roaster/errors"; +import module namespace roaster="http://e-editiones.org/roaster"; +import module namespace rutil="http://e-editiones.org/roaster/util"; + + +(:~ + : list of definition files to use + :) +declare variable $api:definitions := "modules/login.json"; + + +(:~ + : You can add application specific route handlers here. + : Having them in imported modules is preferred. + :) + + +(: end of route handlers :) + +(:~ + : This function "knows" all modules and their functions + : that are imported here + : You can leave it as it is, but it has to be here + :) +declare function api:lookup($name as xs:string) { + function-lookup(xs:QName($name), 1) +}; + +roaster:route($api:definitions, api:lookup#1) From 79876c0a9774e8a70eed1e191f7711d35f30cd47 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 23 Mar 2021 03:43:02 -0400 Subject: [PATCH 13/16] Add key forms for managing API keys --- modules/keys.xqm | 227 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 modules/keys.xqm diff --git a/modules/keys.xqm b/modules/keys.xqm new file mode 100644 index 0000000..1701ede --- /dev/null +++ b/modules/keys.xqm @@ -0,0 +1,227 @@ +xquery version "3.1"; + +module namespace keys="http://joewiz.org/ns/xquery/airlock/keys"; + +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; + +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +declare option output:method "html"; +declare option output:media-type "text/html"; + +declare function keys:create-key-set($key-id as xs:string, $username as xs:string, $rest-api-key as xs:string, $metadata-api-key as xs:string, $notes as xs:string, $created-dateTime as xs:dateTime, $last-modified-dateTime as xs:dateTime?) { + element key-set { + element id { $key-id }, + element username { $username }, + element rest-api-key { $rest-api-key }, + element metadata-api-key { $metadata-api-key }, + element notes { $notes }, + element created-dateTime { $created-dateTime }, + element last-modified-dateTime { $last-modified-dateTime } + } +}; + +declare function keys:delete-key-confirm($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $title := "Are you sure?" + let $content := + + return + app:wrap($content, $title) +}; + +declare function keys:delete-key($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $key-set := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set[id eq $key-id] + let $action := update delete $key-set + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +declare function keys:update-key($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $key-set := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set[id eq $key-id] + let $username := $request?parameters?username + let $rest-api-key := $request?parameters?rest-api-key + let $metadata-api-key := $request?parameters?metadata-api-key + let $notes := $request?parameters?notes + let $new-key-set := keys:create-key-set($key-id, $username, $rest-api-key, $metadata-api-key, $notes, $key-set/created-dateTime cast as xs:dateTime, current-dateTime()) + let $update := update replace $key-set with $new-key-set + let $title := "Success" + let $content := + + return + $content => app:wrap($title) +}; + +declare function keys:create-key($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-sets := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets + let $max-id := max(($key-sets/key-set/id ! (. cast as xs:integer), 0)) + let $new-id := $max-id + 1 + let $username := $request?parameters?username + let $rest-api-key := $request?parameters?rest-api-key + let $metadata-api-key := $request?parameters?metadata-api-key + let $notes := $request?parameters?notes + let $new-key-set := keys:create-key-set($new-id, $username, $rest-api-key, $metadata-api-key, $notes, current-dateTime(), ()) + let $update := update insert $new-key-set into $key-sets + let $title := "Success" + let $content := + + return + $content => app:wrap($title) +}; + +declare function keys:edit-form($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $key-set := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set[id eq $key-id] + let $title := "Edit Key " || $key-id + let $content := +
      +

      Airlock

      + +

      {$title}

      + +
      +
      + + +
      The Airtable username associated with your API key. To help you distinguish between multiple API keys.
      +
      +
      + + +
      To find your API Key, go to https://airtable.com/account and look at the section called “API.”
      +
      +
      + + +
      If you have applied for a Airtable Metadata API Access, Airtable support will email the Metadata API Key to you; otherwise, leave this blank.
      +
      +
      + + +
      Any additional notes.
      +
      + + +
      + Delete +
      +
      + return + app:wrap($content, $title) +}; + +declare function keys:welcome($request as map(*)) { + let $title := "API Keys" + let $base-url := $request?parameters?base-url + let $key-sets := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set + let $content := +
      +

      Airlock

      + +

      {$title}

      + { + if (exists($key-sets)) then + + + + + + + + + + + + + + { + for $key-set in $key-sets + return + + + + + + + + + + + } +
      IDAirtable UsernameREST API KeyMetadata API KeyNotesDate CreatedLast ModifiedActions
      {$key-set/id/string()}{$key-set/username/string()}{$key-set/rest-api-key/string()}{$key-set/metadata-api-key/string()}{$key-set/notes/string()}{ + ($key-set/created-dateTime cast as xs:dateTime) + => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]")}{ + if ($key-set/last-modified-dateTime ne "") then + ($key-set/last-modified-dateTime cast as xs:dateTime) + => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]") + else + No modifications + }Edit
      + else +

      No keys have been added.

      + } +

      Create a new key

      +
      +
      + + +
      The Airtable username associated with your API key. To help you distinguish between multiple API keys.
      +
      +
      + + +
      To find your API Key, go to https://airtable.com/account and look at the section called “API.”
      +
      +
      + + +
      If you have applied for a Airtable Metadata API Access, Airtable support will email the Metadata API Key to you; otherwise, leave this blank.
      +
      +
      + + +
      +
      + + +
      + Delete +

      Custom Reports

      + { + if ($custom-reports) then + + + + + + + + + + + + { + for $report in $custom-reports + return + + + + + + + + } + +
      IDLabelDescriptionLocationAction
      {$report/id/string()}{$report/label/string()}{$report/description/normalize-space()}{$report/location/string()}Edit
      + else +

      No custom reports. Add a custom report.

      + } return app:wrap($content, $title) }; + +declare function bases:custom-report-element($report-id as xs:string, $report-label as xs:string, $report-description as xs:string, $report-location as xs:string) as element(custom-report) { + element custom-report { + element id { $report-id }, + element label { $report-label }, + element description { $report-description }, + element location { $report-location } + } +}; + +declare function bases:create-custom-report-form($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $custom-reports := $base/custom-reports + let $max-id := max(($custom-reports/custom-report/id ! (. cast as xs:integer), 0)) + let $next-id := $max-id + 1 + let $title := "Create custom report" + let $content := +
      +

      Airlock

      + +

      {$title}

      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      + + +
      +
      + return + app:wrap($content, $title) +}; + +declare function bases:create-custom-report($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $custom-reports := $base/custom-reports + let $max-id := max(($custom-reports/custom-report/id ! (. cast as xs:integer), 0)) + let $next-id := $max-id + 1 + let $report-label := $request?parameters?report-label + let $report-description := $request?parameters?report-description + let $report-location := $request?parameters?report-location + let $new-custom-report := bases:custom-report-element($next-id, $report-label, $report-description, $report-location) + let $add := update insert $new-custom-report into $custom-reports + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +declare function bases:base-element($base-id as xs:string, $base-name as xs:string, $api-key as xs:string, $permission-level as xs:string, $notes as xs:string, $custom-reports as element(custom-report)*, $created-dateTime as xs:dateTime, $last-modified-dateTime as xs:dateTime?) { + element base { + element id { $base-id }, + element name { $base-name }, + element api-key { $api-key }, + element permission-level { $permission-level }, + element notes { $notes }, + element custom-reports { $custom-reports }, + element created-dateTime { $created-dateTime }, + element last-modified-dateTime { $last-modified-dateTime } + } +}; + +declare function bases:create-base($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base-name := $request?parameters?base-name + let $rest-api-key := $request?parameters?rest-api-key + let $permission-level := $request?parameters?permission-level + let $notes := $request?parameters?notes + let $new-base := bases:base-element($base-id, $base-name, $rest-api-key, $permission-level, $notes, (), current-dateTime(), ()) + let $add := + ( + update insert $new-base into doc("/db/apps/airlock-data/bases/bases.xml")/bases, + bases:mkcol("/db/apps/airlock-data/bases", $base-id || "/snapshots"), + bases:store("/db/apps/airlock-data/bases/" || $base-id, "snapshots.xml", ) + ) + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +declare function bases:update-base-metadata($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $file := request:get-uploaded-file-data("files[]") + let $base-col := + if (xmldb:collection-available("/db/apps/airlock-data/bases/" || $base-id)) then + "/db/apps/airlock-data/bases/" || $base-id + else + bases:mkcol("/db/apps/airlock-data/bases", $base-id) + let $store := bases:store("/db/apps/airlock-data/bases/" || $base-id, "base-metadata.json", $file) + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +(:~ + : Recursively create a collection hierarchy + :) +declare %private function bases:mkcol($collection as xs:string, $path as xs:string) { + bases:mkcol-recursive($collection, tokenize($path, "/")) +}; + +declare + %private +function bases:mkcol-recursive($collection as xs:string, $components as xs:string*) { + if (exists($components)) then + let $newColl := concat($collection, "/", $components[1]) + return ( + xmldb:create-collection($collection, $components[1]) ! + ( + sm:chgrp(xs:anyURI(.), config:repo-permissions()?group), + sm:chmod(xs:anyURI(.), config:repo-permissions()?mode), + . + ), + bases:mkcol-recursive($newColl, subsequence($components, 2)) + ) + else + () +}; + + +(:~ + : Helper function to store resources and set permissions for access by repo group + :) +declare function bases:store($collection-uri as xs:string, $resource-name as xs:string, $contents as item()?) as xs:string { + xmldb:store($collection-uri, $resource-name, $contents) ! + ( + sm:chgrp(., config:repo-permissions()?group), + sm:chmod(., config:repo-permissions()?mode), + . + ) +}; + +declare function bases:get-table-for-snapshot($api-key as xs:string, $base-id as xs:string, $base-metadata as map(*)) { + let $table := airtable:list-records($api-key, $base-id, $base-metadata?name, true(), (), (), (), (), (), (), ()) + return + (: pass error messages back through :) + if ($table instance of element()) then + $table + else + map { + "name": $base-metadata?name, + "id": $base-metadata?id, + "primaryColumnName": $base-metadata?primaryColumnName, + "columns": $base-metadata?columns?*, + "records": array:join($table?records) + } +}; + +(: ensure columns entry contains an array :) +declare function bases:fix-columns-entry($table as map(*)*) { + map:merge(( + for $key in map:keys($table) + return + if ($key eq "columns" and map:get($table, $key) instance of map(*)) then + map:entry("columns", array { $table?columns } ) + else + map:entry($key, $table($key)) + )) +}; + +declare function bases:create-snapshot($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $base-name := $base/name/string() + let $api-key := $base/api-key/string() + let $snapshots-doc := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots + let $next-id := ($snapshots-doc/snapshot[last()]/id, "0")[1] cast as xs:integer + 1 + let $snapshot := element snapshot { element id { $next-id }, element created-dateTime { current-dateTime() } } + let $prepare := + ( + bases:mkcol("/db/apps/airlock-data/bases/", $base-id || "/snapshots/" || $next-id || "/tables"), + xmldb:copy-resource( + "/db/apps/airlock-data/bases/" || $base-id, + "base-metadata.json", + "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, + "base-metadata.json", + true() + ), + if (exists($snapshots-doc)) then + update insert $snapshot into $snapshots-doc + else + bases:store("/db/apps/airlock-data/bases/" || $base-id, "snapshots.xml", element snapshots { $snapshot } ) + ) + + (: https://github.com/Airtable/airtable.js/issues/12#issuecomment-349987627 + : + : for exporting table metadata, start from https://airtable.com/appe0AfkruafOCgrw/api/docs :) + let $tables := json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/base-metadata.json")?* + let $store := + for $base-metadata in $tables + let $contents := bases:get-table-for-snapshot($api-key, $base-id, $base-metadata) + (: + if ($contents instance of element()) then + app:wrap( + element div { + element h1 { error }, + element pre { $contents => serialize() } + }, + "Error" + ) + else + :) + return + bases:store( + "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables", + ($base-metadata?name || ".json") + (: space, nbsp, plus, colon, slash, bullet point :) + => replace("[  +:/•]", "-") + (: parentheses :) + => replace("[\(\)]", "") + => replace("-+", "-") + => replace("^-", "_") + => lower-case(), + $contents => serialize(map{ "method": "json", "indent": true() }) + ) + let $summarize := + let $resources := xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables") + let $tables := $resources ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables/" || .) + let $records-count := + sum( + for $table in $tables + return + array:size($table?records) + ) + let $fields-count := + sum( + for $table in $tables + return + if ($table?columns instance of array(*)) then + array:size($table?columns) + else + 1 + ) + let $cells-count := + sum( + for $record in $tables?records?* + return + map:size($record?fields) + ) + return + update insert ( + element tables-count { count($tables) }, + element records-count { $records-count }, + element fields-count { $fields-count }, + element cells-count { $cells-count } + ) + into $snapshots-doc//snapshot[id eq $next-id cast as xs:string] + let $content := + element div { + attribute class { "alert alert-success" }, + attribute role { "alert" }, + element h4 { attribute class { "alert-heading" }, "Success" }, + element p { "Stored " || count($store) || " resources:" }, + element ul { $store ! element li { . } }, + element p { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/snapshots/" || $next-id }, + "View snapshot " || $next-id + } + } + } + return + app:wrap($content, "Created snapshot " || $next-id) +}; + +declare function bases:delete-snapshot($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $snapshot-id := $request?parameters?snapshot-id + let $snapshot := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots/snapshot[id eq $snapshot-id] + let $delete := + ( + update delete $snapshot, + xmldb:remove("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id) + ) + let $content := + element div { + attribute class { "alert alert-success" }, + attribute role { "alert" }, + element h4 { attribute class { "alert-heading" }, "Success" }, + element p { "Deleted snapshot " || $snapshot-id }, + element p { + element a { + attribute href { $base-url || "/bases/" || $base-id }, + "Return to base" + } + } + } + return + app:wrap($content, "Success") +}; + +declare function bases:welcome($request as map(*)) { + let $title := "Airlock" + let $base-url := $request?parameters?base-url + let $content := +
      +

      {$title}

      + +

      Welcome to Airlock. Guest users can browse existing bases. To add an API key, add bases, and take snapshots, log in as a user who is a member of the {config:repo-permissions()?group} group.

      +
        + { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then +
      • API Keys
      • + else + () + } +
      • Bases
      • + { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then +
      • Log out. You are logged in as {sm:id()//sm:real/sm:username/string()}.
      • + else +
      • Log in
      • + } +
      +
      + return + app:wrap($content, $title) +}; + diff --git a/modules/snapshot.xqm b/modules/snapshot.xqm deleted file mode 100644 index c58e181..0000000 --- a/modules/snapshot.xqm +++ /dev/null @@ -1,143 +0,0 @@ -xquery version "3.1"; - -module namespace snapshot="http://joewiz.org/ns/xquery/airlock/snapshot"; - -import module namespace airtable="http://joewiz.org/ns/xquery/airtable"; -import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; - -declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; - -declare option output:method "html"; -declare option output:media-type "text/html"; - -declare function snapshot:get-table($api-key as xs:string, $base-id as xs:string, $base-metadata as map(*)) { - let $table := airtable:list-records($api-key, $base-id, $base-metadata?name, true(), (), (), (), (), (), (), ()) - return - (: pass error messages back through :) - if ($table instance of element()) then - $table - else - map { - "name": $base-metadata?name, - "id": $base-metadata?id, - "primaryColumnName": $base-metadata?primaryColumnName, - "columns": $base-metadata?columns?*, - "records": array:join($table?records) - } -}; - -(: ensure columns entry contains an array :) -declare function snapshot:fix-columns-entry($table as map(*)*) { - map:merge(( - for $key in map:keys($table) - return - if ($key eq "columns" and map:get($table, $key) instance of map(*)) then - map:entry("columns", array { $table?columns } ) - else - map:entry($key, $table($key)) - )) -}; - -declare function snapshot:hit-me($request as map(*)) { - let $base-url := $request?parameters?base-url - let $base-id := $request?parameters?base-id - let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] - let $base-name := $base/name/string() - let $api-key := $base/api-key/string() - let $snapshots-doc := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots - let $next-id := ($snapshots-doc/snapshot[last()]/id, "0")[1] cast as xs:integer + 1 - let $snapshot := element snapshot { element id { $next-id }, element created-dateTime { current-dateTime() } } - let $prepare := - ( - xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id, "snapshots"), - xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id || "/snapshots", $next-id), - xmldb:create-collection("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, "tables"), - xmldb:copy-resource( - "/db/apps/airlock-data/bases/" || $base-id, - "base-metadata.json", - "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, - "base-metadata.json" - ), - if (exists($snapshots-doc)) then - update insert $snapshot into $snapshots-doc - else - xmldb:store("/db/apps/airlock-data/bases/" || $base-id, "snapshots.xml", element snapshots { $snapshot } ) - ) - - (: https://github.com/Airtable/airtable.js/issues/12#issuecomment-349987627 - : - : for exporting table metadata, start from https://airtable.com/appe0AfkruafOCgrw/api/docs :) - let $tables := json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/base-metadata.json")?* - let $store := - for $base-metadata in $tables - let $contents := snapshot:get-table($api-key, $base-id, $base-metadata) - (: - if ($contents instance of element()) then - app:wrap( - element div { - element h1 { error }, - element pre { $contents => serialize() } - }, - "Error" - ) - else - :) - return - xmldb:store( - "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables", - ($base-metadata?name || ".json") - (: space, nbsp, plus, colon, slash, bullet point :) - => replace("[  +:/•]", "-") - (: parentheses :) - => replace("[\(\)]", "") - => replace("-+", "-") - => replace("^-", "_") - => lower-case(), - $contents => serialize(map{"method": "json", "indent": true()}) - ) - let $summarize := - let $resources := xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables") - let $tables := $resources ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables/" || .) - let $records-count := - sum( - for $table in $tables - return - array:size($table?records) - ) - let $fields-count := - sum( - for $table in $tables - return - if ($table?columns instance of array(*)) then - array:size($table?columns) - else - 1 - ) - let $cells-count := - sum( - for $record in $tables?records?* - return - map:size($record?fields) - ) - return - update insert ( - element tables-count { count($tables) }, - element records-count { $records-count }, - element fields-count { $fields-count }, - element cells-count { $cells-count } - ) - into $snapshots-doc//snapshot[id eq $next-id cast as xs:string] - let $content := - element div { - element p { "Stored " || count($store) || " resources:" }, - element ul { $store ! element li { . } }, - element p { - element a { - attribute href { $base-url || "/bases/" || $base-id || "/" || $next-id }, - "View snapshot " || $next-id - } - } - } - return - app:wrap($content, "Created snapshot " || $next-id) -}; \ No newline at end of file diff --git a/modules/update.xqm b/modules/update.xqm deleted file mode 100644 index e55c694..0000000 --- a/modules/update.xqm +++ /dev/null @@ -1,26 +0,0 @@ -xquery version "3.1"; - -module namespace update="http://joewiz.org/ns/xquery/airlock/update"; - -declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; - -declare option output:method "html"; -declare option output:media-type "text/html"; - -declare function update:base-metadata($request as map(*)) { - let $base-url := $request?parameters?base-url - let $base-id := $request?parameters?base-id - let $file := request:get-uploaded-file-data("files[]") - let $base-col := - if (xmldb:collection-available("/db/apps/airlock-data/bases/" || $base-id)) then - "/db/apps/airlock-data/bases/" || $base-id - else - xmldb:create-collection("/db/apps/airlock-data/bases", $base-id) - let $store := xmldb:store("/db/apps/airlock-data/bases/" || $base-id, "base-metadata.json", $fil) - return -
      -

      Success

      -

      Successfully stored {$store}

      -

      Return to browsing base {$base-id}

      -
      -}; \ No newline at end of file From 8901041bfd1eaf9cdb13cc489bd40eda3c91212d Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 23 Mar 2021 03:56:10 -0400 Subject: [PATCH 16/16] Fix tests - normalize-space.js https://stackoverflow.com/questions/16974664/remove-extra-spaces-in-string-javascript --- test/mocha/app_spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/mocha/app_spec.js b/test/mocha/app_spec.js index 9bdab96..8aaa821 100644 --- a/test/mocha/app_spec.js +++ b/test/mocha/app_spec.js @@ -70,19 +70,19 @@ describe('file system checks', function () { if (fs.existsSync('build.xml')) { const build = fs.readFileSync('build.xml', 'utf8') const parsed = new xmldoc.XmlDocument(build) - var buildDesc = parsed.childNamed('description').val + var buildDesc = parsed.childNamed('description').val.replace(/\s+/g,'') } if (fs.existsSync('package.json')) { const pkg = fs.readFileSync('package.json', 'utf8') const parsed = JSON.parse(pkg) - var pkgDesc = parsed.description + var pkgDesc = parsed.description.replace(/\s+/g,'') } if (fs.existsSync('repo.xml')) { const repo = fs.readFileSync('repo.xml', 'utf8') const parsed = new xmldoc.XmlDocument(repo) - var repoDesc = parsed.childNamed('description').val + var repoDesc = parsed.childNamed('description').val.replace(/\s+/g,'') } const desc = [buildDesc, pkgDesc, repoDesc].filter(Boolean)