diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..c4a55d7 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,34 @@ +name: Run unit and integration tests + +on: + pull_request: + types: [opened, synchronize] + branches: ['master'] + paths: ['cls/**/*.cls', 'tests/**/*.cls'] + +jobs: + run_tests: + runs-on: ubuntu-latest + name: Run tests + steps: + - name: Clone this repository + uses: actions/checkout@v2 + with: + path: app + - name: Install dos2unix + run: | + sudo apt-get update + sudo apt-get install -y dos2unix + - name: Convert line endings to LF (Unix format) + run: find . -type f -exec dos2unix {} \; + - name: Strip ROUTINE marks + run: app/bin/strip-atelier-headers.sh app + - name: Back-compatibilize with Cache + run: app/bin/iris-bc.sh app + - name: Import classes and run tests + run: | + docker run --rm -t --name test-ci \ + -e TEST_SUITE="tests" \ + -e TEST_AUTOLOAD_DIR="_setup" \ + -v $PWD/app:/opt/ci/app ghcr.io/rfns/iris-ci/iris-ci:v0.6.5 + diff --git a/.github/workflows/xml-asset.yml b/.github/workflows/xml-asset.yml index cb8d520..493533d 100644 --- a/.github/workflows/xml-asset.yml +++ b/.github/workflows/xml-asset.yml @@ -25,33 +25,36 @@ jobs: - name: Convert line endings to LF (Unix format) run: find . -type f -exec dos2unix {} \; - name: Parse the tag - id: parse-tag - run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} + id: parse-metadata + run: | + export VERSION=${GITHUB_REF/refs\/tags\//} + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "REPOSITORY_NAME=$(echo "$GITHUB_REPOSITORY" | awk -F / '{print $2}' | sed -e "s/:refs//")" >> $GITHUB_OUTPUT + echo "PACKAGE_NAME=$(echo "$REPOSITORY_NAME-$VERSION")" >> $GITHUB_OUTPUT - name: Import this repository and generate XML artifacts run: | - touch $PWD/app/forgery.xml - chmod 777 $PWD/app/forgery.xml + touch "app/${{ steps.parse-metadata.outputs.REPOSITORY_NAME }}.xml" + chmod 777 "app/${{ steps.parse-metadata.outputs.REPOSITORY_NAME }}.xml" docker run --rm \ -t --name xml-ci \ -v $PWD/app:/opt/ci/app \ -v $PWD/iris-ci-xml/ci/App/Installer.cls:/opt/ci/App/Installer.cls \ -v $PWD/iris-ci-xml/ci/Runner.cls:/opt/ci/Runner.cls \ -e PORT_CONFIGURATION_PROJECTNAME="forgery" \ - -e PORT_CONFIGURATION_LOGLEVEL=1 \ + -e PORT_CONFIGURATION_LOGLEVEL=2 \ -e CI_XML_FLAGS="/exportversion=2016.2" \ - rfns/iris-ci:0.6.1 - - name: Retrieve the latest asset upload url - id: release-asset-metadata + ghcr.io/rfns/iris-ci/iris-ci:v0.6.5 + mv "app/${{ steps.parse-metadata.outputs.REPOSITORY_NAME }}.xml" "app/${{ steps.parse-metadata.outputs.PACKAGE_NAME }}.xml" + - name: Calculate SHA1s run: | - upload_url=$(curl -s -X GET -L -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/$GITHUB_REPOSITORY/releases/latest | jq -r '. | .upload_url') - echo ::set-output name=UPLOAD_URL::$upload_url - - name: Upload release asset - id: release-asset-upload - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + sha1sum app/manifest.cls | cut -f 1 -d " " > app/manifest.sha1 + sha1sum "app/${{ steps.parse-metadata.outputs.PACKAGE_NAME }}-iris.xml" | \ + cut -f 1 -d " " > "app/${{ steps.parse-metadata.outputs.PACKAGE_NAME }}-iris.sha1" + - name: Upload release files + id: release-files + uses: softprops/action-gh-release@v2 with: - upload_url: ${{ steps.release-asset-metadata.outputs.UPLOAD_URL }} - asset_path: app/forgery.xml - asset_name: forgery-${{ steps.parse-tag.outputs.VERSION }}.xml - asset_content_type: application/xml + files: | + app/${{ steps.parse-metadata.outputs.PACKAGE_NAME }}.xml + app/${{ steps.parse-metadata.outputs.PACKAGE_NAME }}.sha1 + diff --git a/bin/iris-bc.sh b/bin/iris-bc.sh new file mode 100755 index 0000000..0aaaddd --- /dev/null +++ b/bin/iris-bc.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +cwd_dir=${1:-$PWD} + +echo "iris-bc: Checking for class directory at $cwd_dir ..." + +if [[ -d "$cwd_dir/cls" ]]; then + echo "iris-bc: Found class directory. Swapping IRIS expressions for Caché back compatibility ..." + + find $cwd_dir/cls -type f -iname '*.cls' -exec sh -c 'cat "$1" | sed "s/%Storage.Persistent/%Library.CacheStorage/g" > "$1.bak" && mv "$1.bak" "$1"' sh {} \; + find $cwd_dir/cls -type f -iname '*.cls' -exec sh -c 'cat "$1" | sed "s/%Storage.SQL/%CacheSQLStorage/g" > "$1.bak" && mv "$1.bak" "$1"' sh {} \; + find $cwd_dir/cls -type f -iname '*.cls' -exec sh -c 'cat "$1" | sed "s/%Any/%CacheString/g" > "$1.bak" && mv "$1.bak" "$1"' sh {} \; + find $cwd_dir/cls -type f -iname '*.cls' -exec sh -c 'cat "$1" | sed "s/\[ Language = objectscript \]/\[ Language = cache \]/g" > "$1.bak" && mv "$1.bak" "$1"' sh {} \; +fi + +echo "iris-bc: Done." + +exit 0 + diff --git a/bin/strip-atelier-headers.sh b/bin/strip-atelier-headers.sh new file mode 100755 index 0000000..4294665 --- /dev/null +++ b/bin/strip-atelier-headers.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +cwd_dir=${1:-$PWD} + +find_and_replace() { + category="$1" + + echo "strip-atelier-headers: Scanning for files in $cwd_dir/$category" + + if [[ -d "$cwd_dir/$category" ]]; then + echo "strip-atelier-headers: Files for category $category were found. Stripping ..." + find "$cwd_dir/$category" -type f -iname "*.$category" -exec sh -c 'grep -v "ROUTINE\s.*\[Type=.*\]" "$1" > "$1.bak" && mv "$1.bak" "$1"' sh {} \; + else + echo "strip-atelier-headers: Category $category has been skipped: No entries were found at the destination." + fi +} + +find_and_replace "inc" +find_and_replace "int" +find_and_replace "mac" + +echo "strip-atelier-headers: Done." + +exit 0 diff --git a/cls/Forgery/Agent.cls b/cls/Forgery/Agent.cls index 6a76f25..abdf9a5 100644 --- a/cls/Forgery/Agent.cls +++ b/cls/Forgery/Agent.cls @@ -1,51 +1,44 @@ -Class Forgery.Agent Extends Forgery.Agent.Core +/// End-user agent for sending requests. +/// +/// This class provides the implementations required for dispatching requests. +/// However, it's recommended to instantiate it using the builder. +Class Forgery.Agent Extends (%RegisteredObject, Forgery.Internal.BaseAgent) { -Method Post(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) As %Status +/// Sends a HTTP POST request to a web application resource. +Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - set settings.method = "POST" - return ..Request(settings, .response, outputToDevice) + return ..DoRequest(..#HTTPPOSTMETHOD, resource, data, overrides) } -Method Get(settings As %DynamicObject = "", response As %Stream.Object, outputToDevice As %Boolean = 0) As %Status +/// Sends a HTTP GET request to a web application resource. +Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - if '$isobject(settings) { - set url = settings - set settings = { "url": (url) } - } - set settings.method = "GET" - return ..Request(settings, .response, outputToDevice) + return ..DoRequest(..#HTTPGETMETHOD, resource, queryParams, overrides) } -Method Put(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +/// Sends a HTTP PUT request to a web application resource. +Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - set settings.method = "PUT" - return ..Request(settings, .response, outputToDevice) + return ..DoRequest(..#HTTPPUTMETHOD, resource, data, overrides) } -Method Delete(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +/// Sends a HTTP DELETE request to a web application resource. +Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - set settings.method = "DELETE" - return ..Request(settings, .response, outputToDevice) + return ..DoRequest(..#HTTPDELETEMETHOD, resource, queryParams, overrides) } -Method Head(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +/// Sends a HTTP HEAD request to a web application resource. +Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - set settings.method = "HEAD" - set settings.data = {} - return ..Request(settings, .response, outputToDevice) + return ..DoRequest(..#HTTPHEADMETHOD, resource, queryParams, overrides) } -Method Patch(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +/// Sends a HTTP Patch request to a web application resource. +Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - set settings.method = "PATCH" - return ..Request(settings, .response, outputToDevice) -} - -Method Options(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) -{ - set settings.method = "OPTIONS" - return ..Request(settings, .response, outputToDevice) + return ..DoRequest(..#HTTPPATCHMETHOD, resource, data, overrides) } } diff --git a/cls/Forgery/Agent/ApplicationInfo.cls b/cls/Forgery/Agent/ApplicationInfo.cls deleted file mode 100644 index 8762bbb..0000000 --- a/cls/Forgery/Agent/ApplicationInfo.cls +++ /dev/null @@ -1,13 +0,0 @@ -Class Forgery.Agent.ApplicationInfo Extends %RegisteredObject -{ - -Property Name As %String; - -Property DispatchClass As %String; - -Property Path As %String; - -Property AppUrl As %String; - -} - diff --git a/cls/Forgery/Agent/Core.cls b/cls/Forgery/Agent/Core.cls deleted file mode 100644 index 02c157e..0000000 --- a/cls/Forgery/Agent/Core.cls +++ /dev/null @@ -1,197 +0,0 @@ -Class Forgery.Agent.Core Extends %RegisteredObject -{ - -Property BaseURL As %String [ Private ]; - -Property DefaultHeaders As %DynamicObject [ Private ]; - -Property Cache As %String [ Private ]; - -Property Namespace As %String [ Private ]; - -Property Jar As Forgery.Agent.CookieJar [ Private ]; - -Property Response As %CSP.Response [ Private ]; - -Property Reply As %Stream.Object [ Private ]; - -Method %OnNew(defaultSettings As %DynamicObject = {{}}, defaultHeaders As %DynamicObject = {{}}) As %Status -{ - - if $data(defaultSettings) { - if $isobject(defaultSettings) && 'defaultSettings.%IsA("%DynamicObject") { - set defaultSettings = {} - } elseif '$isobject(defaultSettings) { - set defaultSettings = { "baseURL": (defaultSettings) } - } - } - - if $isobject(defaultHeaders) && defaultHeaders.%IsA("%DynamicObject") && 'defaultSettings.%IsDefined("defaultHeaders") { - set defaultSettings.defaultHeaders = defaultHeaders - } - - - set ..Namespace = $namespace - set ..BaseURL = defaultSettings.baseURL - set ..DefaultHeaders = defaultSettings.defaultHeaders - - set ..Cache = "^|"""_..Namespace_"""|Forgery.Agent" - set ..Jar = ##class(Forgery.Agent.CookieJar).%New() - return $$$OK -} - -Method %OnClose() As %Status -{ - kill @i%Cache - return $$$OK -} - -Method NormalizeSettings(settings As %String) As %DynamicObject [ Private ] -{ - - set finalSettings = { - "url": ($zconvert(..BaseURL_settings.url, "I", "URL")), - "method": (settings.method), - "cookies": (settings.cookies), - "headers": (settings.headers) - } - - if '$isobject(finalSettings.headers) || 'finalSettings.headers.%IsA("%DynamicObject") { - set finalSettings.headers = {} - } - - if '$isobject(finalSettings.cookies) || 'finalSettings.cookies.%IsA("%DynamicObject") { - set finalSettings.cookies = {} - } - - if $lf($lb("POST", "PATCH", "PUT"), settings.method) && settings.%IsDefined("data") { - set finalSettings.data = settings.data - } - - do ..MergeWithDefaultHeaders(finalSettings.headers) - return finalSettings -} - -Method MergeWithDefaultHeaders(requestActionHeaders As %DynamicObject = {{}}) [ Private ] -{ - if '$isobject(..DefaultHeaders) return - - set iterator = ..DefaultHeaders.%GetIterator() - - while iterator.%GetNext(.key, .value) { - if requestActionHeaders.%IsDefined(key) continue - - set type = ..DefaultHeaders.%GetTypeOf(key) - do requestActionHeaders.%Set(key, value, type) - } -} - -Method Request(settings As %DynamicObject, reply As %Stream.Object = "", outputToDevice As %Boolean = 0) As %Status -{ - set sc = $$$OK - - set normalizedSettings = ..NormalizeSettings(settings) - $$$QuitOnError(..Forge(normalizedSettings, .reply)) - - if outputToDevice = 1 do reply.OutputToDevice() - return $$$OK -} - -Method Forge(settings As %DynamicObject, reply As %Stream.Object = "", outputToDevice As %Boolean = 0) As %Status [ Private ] -{ - kill %request, %session, %response - new %request, %session, %response - - set str = "" - $$$QuitOnError(..GetApplicationInfoFromUrl(settings.url, .appInfo)) - - set %request = ##class(Forgery.Request).CreateFromSettings(settings, appInfo) - set %session = ##class(%CSP.Session).%New(-1, 0) - set %response = ##class(%CSP.Response).%New() - - do %request.PickFromJar(..Jar) - - try { - $$$ThrowOnError(##class(Forgery.OutputCapturer).Capture(appInfo.DispatchClass, %request.URL, settings.method, .reply)) - do ..Jar.PutCookiesFromResponse(%response) - } catch ex { - set sc = ex.AsStatus() - } - - set ..Reply = reply - set ..Response = %response - kill %request, %session, %response - - // Makes sure that any attempts to change the namespace internally ends up in the original one. - set $namespace = ..Namespace - return sc -} - -Method GetApplicationInfoFromUrl(url As %String, Output info As Forgery.Agent.ApplicationInfo) As %DynamicObject [ Private ] -{ - - #define APPCACHE @i%Cache - - set info = ##class(Forgery.Agent.ApplicationInfo).%New() - - // Cache matches to prevent roundtrips to the %SYS namespace. - if $data($$$APPCACHE) { - set index = $lf($$$APPCACHE, url) - if index > 0 return $$ListToObject(index) - } - - set $namespace = "%SYS" - - set result = {} - set name = "" - set urlWithInitialSlash = $select($extract(url) '= "/" : "/"_url, 1: url) - - // Revert the ordering so that longer are considered first, note that the longer the path is higher is similarity with the url. - set rows = ##class(%SQL.Statement).%ExecDirect(, "SELECT TOP 1 Name, DispatchClass, Path FROM SECURITY.APPLICATIONS WHERE ? %STARTSWITH Name ORDER BY LEN(Name) DESC", urlWithInitialSlash) - if rows.%Next() { - set $list($$$APPCACHE, *+1) = urlWithInitialSlash - set index = $ll($$$APPCACHE) - set name = rows.%Get("Name") - set $list($$$APPCACHE, *+1) = name - set $list($$$APPCACHE, *+1) = rows.%Get("DispatchClass") - set $list($$$APPCACHE, *+1) = rows.%Get("Path") - set $list($$$APPCACHE, *+1) = name_$select($extract(name, *) '= "/" : "/", 1: "") - set info = $$ListToObject(index) - } - - set $namespace = ..Namespace - - if name = "" { - set info = "" - return $$$ERROR($$$GeneralError, "No application found for url: "_url) - } - - return $$$OK - -ListToObject(urlIndex) - set info.Name = $lg($$$APPCACHE, urlIndex + 1) - set info.DispatchClass = $lg($$$APPCACHE, urlIndex + 2) - set info.Path = $lg($$$APPCACHE, urlIndex + 3) - set info.AppUrl = $lg($$$APPCACHE, urlIndex + 4) - - return info -} - -Method EmptyCookieJar() As %Status -{ - do ..Jar.Empty() - return $$$OK -} - -Method GetLastResponse() As %CSP.Response -{ - return ..Response -} - -Method GetLastReply() As %Stream.Object -{ - return ..Reply -} - -} - diff --git a/cls/Forgery/AgentBuilder.cls b/cls/Forgery/AgentBuilder.cls new file mode 100644 index 0000000..8b6bbf8 --- /dev/null +++ b/cls/Forgery/AgentBuilder.cls @@ -0,0 +1,45 @@ +Class Forgery.AgentBuilder Extends %RegisteredObject +{ + +Property Configuration As Forgery.Configuration [ Private ]; + +Method %OnNew() As %Status +{ + set ..Configuration = ##class(Forgery.Configuration).%New() + return $$$OK +} + +Method SetWebApplication(webApplication As %String) As Forgery.AgentBuilder +{ + do ..Configuration.SetWebApplication(webApplication) + return $this +} + +Method UseDispatchHandler(handler As Forgery.IDispatchHandler) As Forgery.AgentBuilder +{ + do ..Configuration.SetDispatchHandler(handler) + return $this +} + +Method WithHeaders(headers As %DynamicObject = {{}}) As Forgery.AgentBuilder +{ + do ..Configuration.SetRequestDefaultHeaders(headers) + return $this +} + +Method WithCookies(cookies As %DynamicArray) As Forgery.AgentBuilder +{ + do ..Configuration.SetRequestDefaultCookies(cookies) + return $this +} + +Method Build(Output agent As Forgery.Agent = "") As %Status +{ + $$$QuitOnError(..Configuration.Validate()) + + set requestDispatcherFactory = ##class(Forgery.Internal.RequestDispatcherFactory).%New() + set agent = ##class(Forgery.Agent).%New(..Configuration, requestDispatcherFactory) + return $$$OK +} + +} diff --git a/cls/Forgery/Request.cls b/cls/Forgery/CSP/AbstractRequestLike.cls similarity index 63% rename from cls/Forgery/Request.cls rename to cls/Forgery/CSP/AbstractRequestLike.cls index 31488bd..d3efe34 100644 --- a/cls/Forgery/Request.cls +++ b/cls/Forgery/CSP/AbstractRequestLike.cls @@ -1,4 +1,4 @@ -Class Forgery.Request Extends %RegisteredObject +Class Forgery.CSP.AbstractRequestLike [ Abstract ] { Property URL As %String; @@ -25,97 +25,6 @@ Property CgiEnvs As %String [ MultiDimensional ]; Property Data As %String [ MultiDimensional ]; -Method %OnNew(url As %String, info As Forgery.Agent.ApplicationInfo, method As %String = "GET") As %Status [ Private ] -{ - set ..URL = url - set ..Method = method - set ..Content = ##class(%CSP.CharacterStream).%New() - set ..Application = info.AppUrl - do ..LoadDefaultCgiEnvs() - return $$$OK -} - -ClassMethod NormalizeURL(url As %String) As %String [ Private ] -{ - set url = $select($extract(url, 1) '= "/" : "/"_url, 1: url) - set url = $piece(url, "?") - return url -} - -ClassMethod CreateFromSettings(settings As %DynamicObject, appInfo As Forgery.Agent.ApplicationInfo) As Forgery.Request [ Internal ] -{ - set url = ..NormalizeURL(settings.url) - set request = ..%New(url, appInfo, settings.method) - - do request.AppendToRequest("headers", settings.headers) - do request.AppendToRequest("cookies", settings.cookies) - - if settings.%IsDefined("headers") { - if settings.headers.%IsDefined("Authorization") { - set request.Authorization = settings.headers.Authorization - } - } - - if settings.url [ "?" { - set queryParts = $replace(settings.url, "?", "&") - for i=2:1:$length(queryParts, "&") { - set qp = $piece(queryParts, "&", i) - set qn = $piece(qp, "=", 1) - set qv = $piece(qp, "=", 2) - do request.Insert(qn, qv) - } - } - - if settings.%IsDefined("data") && $isobject(settings.data) { - if settings.headers.%Get("Content-Type") [ "multipart/form-data" { - do request.AppendToRequest("mimedata", settings.data) - } elseif settings.data.%IsA("%Stream.Object") { - if settings.data.IsCharacter() set content = ##class(%CSP.CharacterStream).%New() - else set content = ##class(%CSP.BinaryStream).%New() - do content.CopyFrom(settings.data) - } elseif settings.data.%Extends("%DynamicAbstractObject") { - set content = ##class(%CSP.CharacterStream).%New() - do settings.data.%ToJSON(.content) - set request.Content = content - if settings.headers.%Get("Content-Type") '[ "application/json" { - do request.SetHeader("Content-Type", "application/json") - } - } else { - do request.AppendToRequest("queryparams", settings.data) - } - } - - return request -} - -Method PickFromJar(jar As Forgery.Agent.CookieJar) -{ - do jar.PutCookiesInRequest($this) -} - -Method AppendToRequest(settingName, settingData, parentKey = "") [ Private ] -{ - if '$isobject(settingData) quit - set iterator = settingData.%GetIterator() - - while iterator.%GetNext(.key, .val) { - set appendToKeyName = key - if $isobject(val) { - if val.%IsA("%DynamicObject") { - do ..AppendToRequest(settingName, val) - } elseif val.%IsA("%DynamicArray") { - do ..AppendToRequest(settingName, val, key) - } - } elseif parentKey '= "" { - set appendToKeyName = parentKey - } - if settingName = "headers" { do ..SetHeader(appendToKeyName, val) } - elseif settingName = "cookies" { do ..InsertCookie(appendToKeyName, val) } - elseif settingName = "mimedata" { do ..InsertMimeData(appendToKeyName, val) } - elseif settingName = "queryparams" { do ..Insert(appendToKeyName, val) } - } -} - Method AuthorizationSet(value As %String) As %Status [ Final ] { set i%CgiEnvs("HTTP_AUTHORIZATION") = value @@ -132,39 +41,11 @@ Method ContentTypeSet(value As %String) As %Status [ Private ] Method ContentLengthSet(value As %String) As %Status [ Private ] { - set i%CgiEvs("CONTENT_LENGTH") = value + set i%CgiEnvs("CONTENT_LENGTH") = value set i%ContentLength = value return $$$OK } -Method LoadDefaultCgiEnvs() [ Private ] -{ - do ##class(%Net.URLParser).Parse(..URL, .components) - do ParseQueryString(components("query"), .data) - - merge i%Data = data - - set i%CgiEnvs("REQUEST_METHOD") = $$$ucase(..Method) - set i%CgiEnvs("REQUEST_SCHEME") = "http" - set i%CgiEnvs("REQUEST_URI") = components("path") - set i%CgiEnvs("SERVER_NAME") = "localhost" - set i%CgiEnvs("SERVER_PORT") = 57772 - set i%CgiEnvs("SERVER_PROTOCOL") = "HTTP/1.1" - set i%CgiEnvs("REMOTE_ADDR") = "localhost" - -ParseQueryString(qs, data) - if qs = "" quit - - set qp = $lfs(qs, "&") - - for i=1:1:$ll(qp) { - set key = $piece($lg(qp, i), "=", 1) - set value = $piece($lg(qp, i), "=", 2) - if key '= "" && (value '= "") set data(key, 1) = value - } - quit -} - // Most of the methods below are a copy from %CSP.Request, since we need to keep // these methods working as they can be called by the application. @@ -357,4 +238,3 @@ Method NextIndex(name As %String, ByRef index As %Integer = "") As %String [ Fin } } - diff --git a/cls/Forgery/CSP/BasicDispatchHandler.cls b/cls/Forgery/CSP/BasicDispatchHandler.cls new file mode 100644 index 0000000..366eba3 --- /dev/null +++ b/cls/Forgery/CSP/BasicDispatchHandler.cls @@ -0,0 +1,31 @@ +Class Forgery.CSP.BasicDispatchHandler Extends (%RegisteredObject, Forgery.IDispatchHandler) +{ + +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, cspContext As Forgery.CSP.Context, interceptor As Forgery.IO.DeviceInterceptor) As %Status +{ + // Must publish these variables because of how %CSP.Page works. + set %request = cspContext.Request + set %response = cspContext.Response + set %session = cspContext.Session + + try { + $$$ThrowOnError($classmethod(restDispatchClass, "DispatchRequest", resource, httpMethod)) + } catch exception { + set %response.OutputSessionToken = 0 + do interceptor.Flush() + try { + do $classmethod(restDispatchClass, "Http500", exception) + } catch errorHandlingException { + return errorHandlingException.AsStatus() + } + } + return $$$OK +} + +Method OnDispose() As %Status +{ + kill %request, %session, %response + return $$$OK +} + +} diff --git a/cls/Forgery/CSP/Context.cls b/cls/Forgery/CSP/Context.cls new file mode 100644 index 0000000..79bb724 --- /dev/null +++ b/cls/Forgery/CSP/Context.cls @@ -0,0 +1,19 @@ +Class Forgery.CSP.Context Extends %RegisteredObject +{ + +Property Request As Forgery.CSP.Request; + +Property Response As %CSP.Response; + +Property Session As %CSP.Session; + +Method %OnNew(session As %CSP.Session, request As Forgery.CSP.Request, response As %CSP.Response) As %Status +{ + set ..Session = session + set ..Request = request + set ..Response = response + + return $$$OK +} + +} diff --git a/cls/Forgery/CSP/ContextFactory.cls b/cls/Forgery/CSP/ContextFactory.cls new file mode 100644 index 0000000..87fbb65 --- /dev/null +++ b/cls/Forgery/CSP/ContextFactory.cls @@ -0,0 +1,24 @@ +Class Forgery.CSP.ContextFactory Extends %RegisteredObject +{ + +Property Preparer As Forgery.Internal.RequestPreparer [ Private ]; + +Method %OnNew() As %Status +{ + set ..Preparer = ##class(Forgery.Internal.RequestPreparer).%New() + return $$$OK +} + +Method Create(application As %String, resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) +{ + set sessionId = $System.Encryption.GenCryptToken() + set session = ##class(%CSP.Session).%New(sessionId, 0) + set request = ##class(Forgery.CSP.Request).%New(application, resource, httpMethod) + set response = ##class(%CSP.Response).%New() + + do ..Preparer.Prepare(request, data, overrides) + + return ##class(Forgery.CSP.Context).%New(session, request, response) +} + +} diff --git a/cls/Forgery/CSP/Request.cls b/cls/Forgery/CSP/Request.cls new file mode 100644 index 0000000..6e63c3f --- /dev/null +++ b/cls/Forgery/CSP/Request.cls @@ -0,0 +1,59 @@ +Class Forgery.CSP.Request Extends (%RegisteredObject, Forgery.CSP.AbstractRequestLike) +{ + +Property Resource As %String [ ReadOnly ]; + +Method %OnNew(webApplication As %String, unformattedResource As %String, httpMethod As %String) As %Status [ Private ] +{ + set ..Method = httpMethod + set ..Application = webApplication + do ..ConsumeQueryParameters(unformattedResource) + do ..NormalizeResource(unformattedResource) + do ..LoadDefaultCgiEnvs() + return $$$OK +} + +Method ConsumeQueryParameters(unformattedResource As %String) [ Private ] +{ + if unformattedResource '[ "?" return + + set queryParts = $replace(unformattedResource, "?", "&") + for i=2:1:$length(queryParts, "&") { + set qp = $piece(queryParts, "&", i) + set qn = $piece(qp, "=", 1) + set qv = $piece(qp, "=", 2) + do ..Insert(qn, qv) + } +} + +Method NormalizeResource(unformattedResource As %String) [ Private ] +{ + set ..URL = ..Application + + set pathOnlyResource = $piece(unformattedResource, "?", 1) + + if $extract(pathOnlyResource, 1) = "/" { + set pathOnlyResource = $extract(pathOnlyResource, 2, *) + } + + set ..URL = ..Application_pathOnlyResource + set i%Resource = "/"_pathOnlyResource +} + +Method PickFromJar(jar As Forgery.Internal.CookieJar) +{ + do jar.PutCookiesInRequest($this) +} + +Method LoadDefaultCgiEnvs() [ Private ] +{ + set i%CgiEnvs("REQUEST_METHOD") = $$$ucase(..Method) + set i%CgiEnvs("REQUEST_SCHEME") = "http" + set i%CgiEnvs("REQUEST_URI") = ..URL + set i%CgiEnvs("SERVER_NAME") = "localhost" + set i%CgiEnvs("SERVER_PORT") = 80 + set i%CgiEnvs("SERVER_PROTOCOL") = "HTTP/1.1" + set i%CgiEnvs("REMOTE_ADDR") = "localhost" +} + +} diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls new file mode 100644 index 0000000..99b239b --- /dev/null +++ b/cls/Forgery/Configuration.cls @@ -0,0 +1,102 @@ +Class Forgery.Configuration Extends %RegisteredObject +{ + +Property WebApplication As %String [ Private ]; + +Property DispatchHandler As Forgery.IDispatchHandler [ Private ]; + +Property DeviceLineTerminator As %String [ InitialExpression = {$char(13,10)}, Private ]; + +Property DeviceCharset As %String [ InitialExpression = "utf-8", Private ]; + +Property RequestDefaultHeaders As %DynamicObject [ InitialExpression = {{}}, Private ]; + +Property RequestDefaultCookies As %DynamicArray [ InitialExpression = {[]}, Private ]; + +Method SetWebApplication(webApplication As %String) As %Status +{ + set ..WebApplication = webApplication + return $$$OK +} + +Method GetWebApplication() As %String [ CodeMode = expression ] +{ +..WebApplication +} + +Method SetDispatchHandler(handler As Forgery.IDispatchHandler) As %Status +{ + set ..DispatchHandler = handler + return $$$OK +} + +Method GetDispatchHandler() As Forgery.IDispatchHandler [ CodeMode = expression ] +{ +..DispatchHandler +} + +Method SetDeviceLineTerminator(lineTerminator As %String) As %Status +{ + set ..DeviceLineTerminator = lineTerminator + return $$$OK +} + +Method GetDeviceLineTerminator() As %String [ CodeMode = expression ] +{ +..DeviceLineTerminator +} + +Method SetDeviceCharset(charset As %String) As %Status +{ + set ..DeviceCharset = charset + return $$$OK +} + +Method GetDeviceCharset() As %String [ CodeMode = expression ] +{ +..DeviceCharset +} + +Method SetRequestDefaultHeaders(headers As %DynamicObject) As %Status +{ + set ..RequestDefaultHeaders = headers + return $$$OK +} + +Method GetRequestDefaultHeaders() As %DynamicObject [ CodeMode = expression ] +{ +$select($isobject(..RequestDefaultHeaders) : ..RequestDefaultHeaders, 1: {}) +} + +Method SetRequestDefaultCookies(headers As %DynamicArray) As %Status +{ + set ..RequestDefaultCookies = headers + return $$$OK +} + +Method GetRequestDefaultCookies() As %DynamicObject [ CodeMode = expression ] +{ +$select($isobject(..RequestDefaultCookies) : ..RequestDefaultCookies, 1: {}) +} + +Method Validate() As %Status +{ + set status = $$$OK + + if ..WebApplication = "" set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Web application name is required.")) + if '$isobject(..DispatchHandler) set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Dispatch handler is required.")) + if '$isobject(..RequestDefaultHeaders) || '..RequestDefaultHeaders.%Extends("%DynamicObject") { + set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Request default headers must be an instance of %DynamicObject if provided.")) + } + if '$isobject(..RequestDefaultCookies) || '..RequestDefaultCookies.%Extends("%DynamicArray") { + set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Request default cookies must be an instance of %DynamicArrayObject if provided.")) + } + + if $$$ISERR(status) { + set status = $$$EMBEDSC($$$ERROR($$$GeneralError, "Configuration validation error: One or more settings are missing or wrong:"), status) + } + + return status +} + +} diff --git a/cls/Forgery/FormData.cls b/cls/Forgery/FormData.cls new file mode 100644 index 0000000..3e29119 --- /dev/null +++ b/cls/Forgery/FormData.cls @@ -0,0 +1,24 @@ +Class Forgery.FormData Extends (%RegisteredObject, Forgery.IFormData) +{ + +/// An in-memory multiDimensional containing the indexed data to be sent. +Property Data As %DynamicArray [ Private ]; + +/// Appends the data to the matching key. +Method Append(key As %String, value As %Any) +{ + set entry = { + "name": (key), + "value": (value) + } + + do ..Data.%Push(entry) +} + +/// Exposes the current indexed data to consumers. +Method GetEntryIterator() As Forgery.Internal.FormDataEntryIterator +{ + return ##class(Forgery.Internal.FormDataEntryIterator).%New(..Data) +} + +} diff --git a/cls/Forgery/IAgent.cls b/cls/Forgery/IAgent.cls new file mode 100644 index 0000000..b2701d6 --- /dev/null +++ b/cls/Forgery/IAgent.cls @@ -0,0 +1,64 @@ +Class Forgery.IAgent [ Abstract ] +{ + +/// Sends a HTTP POST request to a web application resource. +Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Post")) +} + +/// Sends a HTTP GET request to a web application resource. +Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Get")) +} + +/// Sends a HTTP PUT request to a web application resource. +Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Put")) +} + +/// Sends a HTTP DELETE request to a web application resource. +Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Delete")) +} + +/// Sends a HTTP HEAD request to a web application resource. +Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Head")) +} + +/// Sends a HTTP PATCH request to a web application resource. +Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Patch")) +} + +/// Shortcut method for retrieving the last operation context, on which an operation stands for the composition of three stateful CSP objects: request, response and session. +/// +/// Please note that the behavior of these objects may slightly differ from their real implementations. +Method GetLastContext() As Forgery.CSP.Context +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "GetLastContext")) +} + +/// Retrieves the last reply sent back by the dispatch class. +/// +/// This is what you'd expect to receive in the browser and truthfully so, should be used for test assertions. +Method GetLastReply() As %Stream.Object +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "GetLastReply")) +} + +/// Creates a new FormData object. +/// +/// This is required for simulating the dispatch of multipart/form-data payloads. +Method NewFormData() As Forgery.IFormData +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "NewFormData")) +} + +} diff --git a/cls/Forgery/IDispatchHandler.cls b/cls/Forgery/IDispatchHandler.cls new file mode 100644 index 0000000..22e6629 --- /dev/null +++ b/cls/Forgery/IDispatchHandler.cls @@ -0,0 +1,14 @@ +Class Forgery.IDispatchHandler [ Abstract ] +{ + +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, context As Forgery.CSP.Context, interceptor As Forgery.IO.DeviceInterceptor) As %Status [ Abstract ] +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "OnDispatch")) +} + +Method OnDispose() As %Status +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "OnDispose")) +} + +} diff --git a/cls/Forgery/IFormData.cls b/cls/Forgery/IFormData.cls new file mode 100644 index 0000000..d21aa6e --- /dev/null +++ b/cls/Forgery/IFormData.cls @@ -0,0 +1,17 @@ +/// Interface class for creating FormData-like objects which can be provided as payload to the agent. +Class Forgery.IFormData [ Abstract ] +{ + +/// Appends the data to the matching key. +Method Append(key As %String, value As %Any) [ Abstract ] +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "Append")) +} + +/// Exposes an object for iterating over every entry. +Method GetEntryIterator() As Forgery.Internal.FormDataEntryIterator [ Abstract ] +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "GetEntryIterator")) +} + +} diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls new file mode 100644 index 0000000..c06b853 --- /dev/null +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -0,0 +1,99 @@ +Class Forgery.IO.DeviceInterceptor Extends %RegisteredObject +{ + +Property DeviceContent As %Stream.FileCharacter [ Private ]; + +Property OriginalDevice As %String [ InitialExpression = {$io}, Private ]; + +Property DeviceMnemonic As %String [ InitialExpression = {##class(%Device).GetMnemonicRoutine()}, Private ]; + +Property DevicePreviouslyRedirected As %Boolean [ InitialExpression = {##class(%Device).ReDirectIO()}, Private ]; + +Property TranslateTable As %String [ Private ]; + +Property LineTerminator As %String [ InitialExpression = {$c(10, 13)}, Private ]; + +Method %OnNew(charset As %String = "", lineTerminator As %String = "") As %Status +{ + set ..DeviceContent = ##class(%Stream.FileCharacter).%New() + if charset '= "" do ..ChangeTranslateTable(charset) + if lineTerminator '= "" do ..ChangeLineTerminator(lineTerminator) + return $$$OK +} + +Method %OnClose() As %Status +{ + return ..EndInterception() +} + +Method ChangeTranslateTable(charset As %String) [ Private ] +{ + set ..DeviceContent.TranslateTable = ##class(%Net.Charset).GetTranslateTable(charset) +} + +Method ChangeLineTerminator(lineTerminator As %String) [ Private ] +{ + set ..DeviceContent.LineTerminator = lineTerminator +} + +Method StartInterception() As %Status [ ProcedureBlock = 0 ] +{ + new status + + $$$QuitOnError(..DeviceContent.Clear()) + + set status = $$$OK + set %forgeryDeviceInterceptedStream = ..DeviceContent + + use ..OriginalDevice::("^"_$zname) + do ##class(%Device).ReDirectIO($$$YES) + + return $$$OK +} + +ClassMethod redirections() [ Internal, Private, ProcedureBlock = 0 ] +{ + #define WriteIfStreamExists(%ch) do:$data(%forgeryDeviceInterceptedStream) %forgeryDeviceInterceptedStream.Write(%ch) + + quit + +wstr(s) $$$WriteIfStreamExists(s) Quit +wchr(a) $$$WriteIfStreamExists($char(a)) Quit +wnl $$$WriteIfStreamExists($char(13,10)) Quit +wff $$$WriteIfStreamExists($char(13,10,13,10)) Quit +wtab(n) $$$WriteIfStreamExists($c(9)) Quit +rstr(len,time) Quit "" +rchr(time) Quit "" +} + +Method EndInterception() As %Status [ ProcedureBlock = 0 ] +{ + if ..DeviceMnemonic '= "" && (..DeviceMnemonic '= "%X364") { + use ..OriginalDevice::("^"_..DeviceMnemonic) + } else { + use ..OriginalDevice + } + + kill %forgeryDeviceInterceptedStream + + if $isobject(..DeviceContent) do ..DeviceContent.Rewind() + if ..DevicePreviouslyRedirected '= 1 do ##class(%Device).ReDirectIO($$$NO) + + return $$$OK +} + +Method GetInterceptedContent() As %Stream.FileCharacter +{ + return ..DeviceContent +} + +Method Flush() As %Status +{ + if '$data(%forgeryDeviceInterceptedStream) || '$isobject(%forgeryDeviceInterceptedStream) { + return $$$OK + } + + return %forgeryDeviceInterceptedStream.Clear() +} + +} diff --git a/cls/Forgery/Internal/BaseAgent.cls b/cls/Forgery/Internal/BaseAgent.cls new file mode 100644 index 0000000..0ca0158 --- /dev/null +++ b/cls/Forgery/Internal/BaseAgent.cls @@ -0,0 +1,63 @@ +/// Base class for creating agents. +/// +/// It also provides methods that can be used to quickstart requests. +Class Forgery.Internal.BaseAgent Extends Forgery.IAgent +{ + +Parameter HTTPPOSTMETHOD = "POST"; + +Parameter HTTPGETMETHOD = "GET"; + +Parameter HTTPPUTMETHOD = "PUT"; + +Parameter HTTPDELETEMETHOD = "DELETE"; + +Parameter HTTPPATCHMETHOD = "PATCH"; + +Parameter HTTPOPTIONSMETHOD = "OPTIONS"; + +Parameter HTTPHEADMETHOD = "HEAD"; + +/// The object containing the configuration used by the agent. +Property Configuration As Forgery.Configuration [ Private ]; + +/// The helper that handles the communication with REST dispatch classes. +Property RequestDispatcher As Forgery.Internal.RequestDispatcher [ Private ]; + +Method %OnNew(configuration As Forgery.Configuration, requestDispatcherFactory As Forgery.Internal.RequestDispatcherFactory) As %Status +{ + set ..RequestDispatcher = requestDispatcherFactory.CreateUsingConfiguration(configuration) + return $$$OK +} + +/// Shortcut method for concrete agents to dispatch requests without exposing the underlaying dispatcher helper. +Method DoRequest(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Private ] +{ + return ..RequestDispatcher.Dispatch(resource, httpMethod, data, overrides) +} + +/// Shortcut method for retrieving the last operation context, on which operation stands for three CSP objects: request, response and session. +/// +/// Please note that the behavior of these objects may slightly differ from their real implementations. +Method GetLastContext() As Forgery.CSP.Context +{ + return ..RequestDispatcher.GetLastContext() +} + +/// Retrieves the last reply sent back by the dispatch class. +/// +/// This is what you'd expect to receive in the browser and truthfully so, should be used for test assertions. +Method GetLastReply() As %Stream.Object +{ + return ..RequestDispatcher.GetLastReply() +} + +/// Creates a new FormData object. +/// +/// This is required for simulating the dispatch of multipart/form-data payloads. +Method NewFormData() As Forgery.IFormData +{ + return ##class(Forgery.FormData).%New() +} + +} diff --git a/cls/Forgery/Internal/ConfigurationMerger.cls b/cls/Forgery/Internal/ConfigurationMerger.cls new file mode 100644 index 0000000..a9f76c7 --- /dev/null +++ b/cls/Forgery/Internal/ConfigurationMerger.cls @@ -0,0 +1,33 @@ +/// Utility class used for merging multiple plain configuration-like objects. +/// +/// The first object takes precedence over the next ones. +/// This gives the caller control over what properties should remain in the final result. +Class Forgery.Internal.ConfigurationMerger Extends %RegisteredObject +{ + +/// Merges n+1 objects into n and returns an aggregated result. +ClassMethod Merge(configurations... As %DynamicObject) As %DynamicObject +{ + #dim result As %DynamicObject + + set result = {} + + for i=1:1:configurations { + set iterator = configurations(i).%GetIterator() + while iterator.%GetNext(.property, .value) { + set target = $property(result, property) + if $isobject(value) && value.%IsA("%DynamicObject") && $isobject(target) { + set isSameSize = (target.%Size() = value.%Size()) + set hasSameProperty = $property(target, property) = $property(value, property) + if '(isSameSize && hasSameProperty) { + set $property(result, property) = ..Merge(target, value) + } + } elseif 'result.%IsDefined(property) { + set $property(result, property) = value + } + } + } + return result +} + +} diff --git a/cls/Forgery/Agent/CookieJar.cls b/cls/Forgery/Internal/CookieJar.cls similarity index 61% rename from cls/Forgery/Agent/CookieJar.cls rename to cls/Forgery/Internal/CookieJar.cls index f8db34d..0aa329c 100644 --- a/cls/Forgery/Agent/CookieJar.cls +++ b/cls/Forgery/Internal/CookieJar.cls @@ -1,8 +1,14 @@ -Class Forgery.Agent.CookieJar Extends %RegisteredObject +/// Utility class for transporting cookie data from/to response and request objects respectively. +/// +/// A stateful agent allows for two-phase operations to flow without manual intrusion from the caller. +/// Like sending a request for auth and a second request with the cookie set for consuming data.. +Class Forgery.Internal.CookieJar Extends %RegisteredObject { +/// The list that holds the cookies with the current operation context. Property Cookies As %String [ MultiDimensional ]; +/// Puts cookies from a response object into a request. Method PutCookiesFromResponse(response As %CSP.Response) As %Status { set index = "" @@ -21,7 +27,7 @@ Method PutCookiesFromResponse(response As %CSP.Response) As %Status } } -Method PutCookiesInRequest(request As Forgery.Request) As %Status +Method PutCookiesInRequest(request As Forgery.CSP.Request) As %Status { set name = "" set index = "" @@ -51,4 +57,3 @@ Method Empty() } } - diff --git a/cls/Forgery/Internal/FormDataEntryIterator.cls b/cls/Forgery/Internal/FormDataEntryIterator.cls new file mode 100644 index 0000000..1cde956 --- /dev/null +++ b/cls/Forgery/Internal/FormDataEntryIterator.cls @@ -0,0 +1,31 @@ +/// A helper for iterating over each FormData entry. +Class Forgery.Internal.FormDataEntryIterator Extends %RegisteredObject +{ + +/// The list being interated over. +Property Iteratee As %DynamicArray [ Private ]; + +/// The index indicating the current iteration position in the list. +Property Index As %Integer [ InitialExpression = 0, Private ]; + +Method %OnNew(iteratee As %DynamicArray) As %Status +{ + set ..Iteratee = iteratee + return $$$OK +} + +/// Retrieves the next entry and returns 1 if there's a next one. +/// When the list reaches the end, returns 0. +Method NextEntry(Output entry As %DynamicObject = "") As %Boolean +{ + if ..Index > (..Iteratee.%Size() - 1) { + return 0 + } + + set entry = ..Iteratee.%Get(..Index) + set ..Index = ..Index + 1 + + return 1 +} + +} diff --git a/cls/Forgery/Internal/RESTClassResolver.cls b/cls/Forgery/Internal/RESTClassResolver.cls new file mode 100644 index 0000000..9d60009 --- /dev/null +++ b/cls/Forgery/Internal/RESTClassResolver.cls @@ -0,0 +1,53 @@ +/// Utility class for REST dispatch class discovery. +Class Forgery.Internal.RESTClassResolver Extends %RegisteredObject +{ + +/// The provided web application name. +Property WebApplication As %String [ Private ]; + +Property WebApplicationInfoCache As %DynamicObject [ Private ]; + +Method %OnNew(webApplication As %String, cache As %DynamicObject = {{}}) As %Status +{ + set ..WebApplication = webApplication + set ..WebApplicationInfoCache = cache + + return $$$OK +} + +Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status +{ + if ..WebApplicationInfoCache.%Size() '= 0 { + return ..WebApplicationInfoCache + } + + new $namespace + set $namespace = "%SYS" + + set baseUrl = ..WebApplication + + if $extract(baseUrl, *) = "/" { + set baseUrl = $extract(baseUrl, 1, *-1) + } + + set prefixedResource = baseUrl_$select($extract(url) '= "/" : "/"_url, 1: url) + + // Reverts the ordering to match longer names first. + set rows = ##class(%SQL.Statement).%ExecDirect(, "SELECT TOP 1 Name, DispatchClass, Path FROM SECURITY.APPLICATIONS WHERE ? %STARTSWITH Name AND DispatchClass IS NOT NULL ORDER BY LEN(Name) DESC", prefixedResource) + if rows.%Next() { + set name = rows.%Get("Name") + set ..WebApplicationInfoCache.Name = rows.%Get("Name") + set ..WebApplicationInfoCache.DispatchClass = rows.%Get("DispatchClass") + set ..WebApplicationInfoCache.Path = rows.%Get("Path") + set ..WebApplicationInfoCache.AppUrl = name_$select($extract(name, *) '= "/" : "/", 1: "") + } + + if ..WebApplicationInfoCache.%Size() = 0 { + return $$$ERROR($$$GeneralError, $$$FormatText("Web application resolver error: No application matching the url '%1' has been found.", prefixedResource)) + } + + set info = ..WebApplicationInfoCache + return $$$OK +} + +} diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls new file mode 100644 index 0000000..97a42cd --- /dev/null +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -0,0 +1,91 @@ +/// Core class that manages the communication between the agent and the REST web application. +/// +/// It takes care of all the aspects required before and after reaching the dispatch class. +Class Forgery.Internal.RequestDispatcher Extends %RegisteredObject +{ + +/// The configuration object provided to the agent, which is actually consumed here. +Property Configuration As Forgery.Configuration [ Private ]; + +/// A property that indicates from which the request was originated. This is required because web applications can be run in different namespaces. +Property InitialNamespace As %String [ InitialExpression = {$namespace}, Private ]; + +/// A factory helper for creating CSP context objects. +Property ContextFactory As Forgery.CSP.ContextFactory [ Private ]; + +/// The last context stored for assertions. Can be read by agent callers. +Property LastContext As Forgery.CSP.Context [ Private ]; + +/// A helper for discovering the dispatch class. +Property RESTClassResolver As Forgery.Internal.RESTClassResolver [ Private ]; + +/// A helper for handling output redirection due to explicit content writes made by dispatch classes. +Property DeviceInterceptor As Forgery.IO.DeviceInterceptor [ Private ]; + +/// A helper for consolidating configuration received through the builder and by-request arguments. +Property ConfigurationMerger As Forgery.Internal.ConfigurationMerger [ Private ]; + +/// A helper used to transport cookies received from/to CSP objects request and response between multiple requests. +Property CookieJar As Forgery.Internal.CookieJar [ Private ]; + +Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Internal.RESTClassResolver, interceptor As Forgery.IO.DeviceInterceptor, jar As Forgery.Internal.CookieJar, cspContextFactory As Forgery.CSP.ContextFactory, configurationMerger As Forgery.Internal.ConfigurationMerger) As %Status +{ + set ..Configuration = configuration + set ..RESTClassResolver = resolver + set ..DeviceInterceptor = interceptor + set ..CookieJar = jar + set ..ContextFactory = cspContextFactory + set ..ConfigurationMerger = configurationMerger + + return $$$OK +} + +/// Discovers the dispatch class and fires a request to it using the by-request config and data provided. +/// +/// Returns an status object indicating if the request suceeded or not. +Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + #define SafeOverrides $select($isobject(overrides) : overrides, 1: {}) + + $$$QuitOnError(..RESTClassResolver.Resolve(resource, .appInfo)) + + set mergedOverrides = ..ConfigurationMerger.Merge( + $$$SafeOverrides, + { "headers": (..Configuration.GetRequestDefaultHeaders()) }, + { "cookies": (..Configuration.GetRequestDefaultCookies()) } + ) + set ..LastContext = ..ContextFactory.Create(appInfo.AppUrl, resource, httpMethod, data, mergedOverrides) + + set status = $$$OK + set dispatchHandler = ..Configuration.GetDispatchHandler() + + try { + do ..LastContext.Request.PickFromJar(..CookieJar) + do ..DeviceInterceptor.StartInterception() + $$$ThrowOnError(dispatchHandler.OnDispatch(..LastContext.Request.Resource, httpMethod, appInfo.DispatchClass, ..LastContext, ..DeviceInterceptor)) + do ..CookieJar.PutCookiesFromResponse(..LastContext.Response) + } catch ex { + set status = ex.AsStatus() + } + + do ..DeviceInterceptor.EndInterception() + $$$QuitOnError(dispatchHandler.OnDispose()) + + // Ensure we are back to the original namespace after the dispatch. + set $namespace = ..InitialNamespace + return status +} + +/// Exposes the last context generated for the last request made. +Method GetLastContext() As Forgery.CSP.Context +{ + return ..LastContext +} + +/// Exposes the last reply received for the last request made. +Method GetLastReply() As %Stream.FileCharacter +{ + return ..DeviceInterceptor.GetInterceptedContent() +} + +} diff --git a/cls/Forgery/Internal/RequestDispatcherFactory.cls b/cls/Forgery/Internal/RequestDispatcherFactory.cls new file mode 100644 index 0000000..9bb4e0b --- /dev/null +++ b/cls/Forgery/Internal/RequestDispatcherFactory.cls @@ -0,0 +1,24 @@ +/// Factory helper for aggregating service dependencies. +Class Forgery.Internal.RequestDispatcherFactory Extends %RegisteredObject +{ + +/// Creates a new request dispatcher by consuming an agent configuration. +ClassMethod CreateUsingConfiguration(configuration As Forgery.Configuration) As Forgery.Internal.RequestDispatcher +{ + set resolver = ##class(Forgery.Internal.RESTClassResolver).%New(configuration.GetWebApplication()) + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New(configuration.GetDeviceCharset(), configuration.GetDeviceLineTerminator()) + set jar = ##class(Forgery.Internal.CookieJar).%New() + set cspContextFactory = ##class(Forgery.CSP.ContextFactory).%New() + set merger = ##class(Forgery.Internal.ConfigurationMerger).%New() + + return ##class(Forgery.Internal.RequestDispatcher).%New( + configuration, + resolver, + interceptor, + jar, + cspContextFactory, + merger + ) +} + +} diff --git a/cls/Forgery/Internal/RequestPreparer.cls b/cls/Forgery/Internal/RequestPreparer.cls new file mode 100644 index 0000000..a1dbe22 --- /dev/null +++ b/cls/Forgery/Internal/RequestPreparer.cls @@ -0,0 +1,120 @@ +/// An utility helper for heavy-lifiting the many approaches a request object can be filled. +Class Forgery.Internal.RequestPreparer Extends %RegisteredObject +{ + +/// Takes a configuration, data object and mutates the request by filling it accordingly. +Method Prepare(request As Forgery.CSP.Request, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject) +{ + + do ..AppendToRequest(request, "headers", overrides.headers) + do ..AppendToRequest(request, "cookies", overrides.cookies) + + do ..ConsumeAuthorizationHeader(request, overrides) + + $$$QuitOnError(..ConsumeData(request, data, overrides)) + + return $$$OK +} + +Method ConsumeAuthorizationHeader(request As Forgery.CSP.Request, overrides As %DynamicObject) [ Private ] +{ + if overrides.%IsDefined("headers") { + if overrides.headers.%IsDefined("Authorization") { + set request.Authorization = overrides.headers.Authorization + } + } +} + +Method ConsumeData(request As Forgery.CSP.Request, data As %RegisteredObject, overrides As %DynamicObject) As %Status [ Private ] +{ + #define MethodAcceptsPayload $lf($lb("PUT", "POST", "PATCH"), request.Method) + + if '$isobject(data) { + return $$$OK + } + + if data.%Extends("Forgery.IFormData") { + do ConsumeFormData(data) + do request.SetHeader("Content-Type", "multipart/form-data") + } elseif data.%IsA("%Stream.Object") { + if data.%IsA("%Stream.FileCharacter") || data.%IsA("%Stream.FileBinary") { + set fileExtension = $piece(data.Filename, ".", *) + } else { + set fileExtension = "bin" + } + + do ##class(%CSP.StreamServer).FileClassify(fileExtension, .type) + + do request.SetHeader("Content-Type", type) + do request.SetHeader("Content-Length", ..CalculateContentLength(data, type)) + + if data.IsCharacter() set content = ##class(%CSP.CharacterStream).%New() + else set content = ##class(%CSP.BinaryStream).%New() + do content.CopyFrom(data) + set request.Content = content + } elseif data.%Extends("%DynamicAbstractObject") && $$$MethodAcceptsPayload { + if overrides.headers.%Get("Content-Type") '[ "application/json" { + do request.SetHeader("Content-Type", "application/json") + } + set content = ##class(%CSP.CharacterStream).%New() + do data.%ToJSON(.content) + set request.Content = content + } else { + do ..AppendToRequest(request, "queryparams", data) + } + + return $$$OK + +ConsumeFormData(formData) + set iterator = formData.GetEntryIterator() + while iterator.NextEntry(.entry) { + do request.InsertMimeData(entry.name, entry.value) + } +} + +Method CalculateContentLength(stream As %Stream.FileCharacter, type As %String) As %String [ Private ] +{ + + if type = "application/octet-stream" { + return "chunked" + } + + if stream.IsCharacter() { + set table = stream.TranslateTable + + return $select( + table = "RAW" : stream.Size, + table = "UnicodeBig" : stream.Size * 2, + table = "UnicodeLittle" : stream.Size * 2, + $extract(table, 1, 5) = "Latin" : stream.Size, + $extract(table, 1, 2) = "CP" : stream.Size, + 1: "" + ) + } + + return "" +} + +Method AppendToRequest(request As Forgery.CSP.Request, settingName As %String, settingData As %Any, parentKey = "", depth As %Integer = 1) [ Private ] +{ + if '$isobject(settingData) quit + set iterator = settingData.%GetIterator() + + while iterator.%GetNext(.key, .val) { + set appendToKeyName = key + if $isobject(val) { + if val.%IsA("%DynamicObject") { + do ..AppendToRequest(request, settingName, val) + } elseif val.%IsA("%DynamicArray") && (depth = 1) { + do ..AppendToRequest(request, settingName, val, key, depth + 1) + } + } elseif parentKey '= "" { + set appendToKeyName = parentKey + } + if settingName = "headers" { do request.SetHeader(appendToKeyName, val) } + elseif settingName = "cookies" { do request.InsertCookie(appendToKeyName, val) } + elseif settingName = "queryparams" { do request.Insert(appendToKeyName, val) } + } +} + +} diff --git a/cls/Forgery/OutputCapturer.cls b/cls/Forgery/OutputCapturer.cls deleted file mode 100644 index 24142b1..0000000 --- a/cls/Forgery/OutputCapturer.cls +++ /dev/null @@ -1,56 +0,0 @@ -Class Forgery.OutputCapturer [ Abstract ] -{ - -ClassMethod Capture(dispatcherClass As %String, url As %String, httpMethod As %String, Output str As %Stream.Object) As %Status [ Internal, ProcedureBlock = 0 ] -{ - - new %frontier - - if ##class(%Dictionary.CompiledClass).%ExistsId("Frontier.Context") { - set %frontier = ##class(Frontier.Context).%New(%session, %request, %response) - } - - new oldMnemonic, alreadyRedirected, sc - - set sc = $$$OK - set isRedirected = 0 - - set str = ##class(%Stream.GlobalCharacter).%New() - set alreadyRedirected = ##class(%Device).ReDirectIO() - set oldMnemonic = ##class(%Device).GetMnemonicRoutine() - set initIO = $io - - try { - do ##class(%Device).ReDirectIO(1) - use $io::("^"_$zname) - set isRedirected = 1 - set sc = $classmethod(dispatcherClass, "DispatchRequest", url, httpMethod) - do str.Rewind() - } catch ex { - set str = "" - set sc = ex.AsStatus() - set %response.OutputSessionToken = 0 - do $classmethod(dispatcherClass, "Http500", ##class(%Exception.StatusException).CreateFromStatus(sc)) - } - - if oldMnemonic '= "" { - use initIO::("^"_oldMnemonic) - } else { - use initIO::"" - } - - do ##class(%Device).ReDirectIO(alreadyRedirected) - - return sc - -wstr(s) Do str.Write(s) Quit -wchr(a) Do str.Write($char(a)) Quit -wnl Do str.Write($char(13,10)) Quit -wff Do str.Write($char(13,10,13,10)) Quit -wtab(n) Do str.Write($c(9)) Quit -rstr(len,time) Quit "" -rchr(time) Quit "" -} - -} - diff --git a/tests/_setup/Parameters.cls b/tests/_setup/Parameters.cls new file mode 100644 index 0000000..f2844b8 --- /dev/null +++ b/tests/_setup/Parameters.cls @@ -0,0 +1,8 @@ +Class Test.Forgery.Setup.Parameters +{ + +Parameter TESTBASEURL = "/test/forgery/api/"; + +Parameter TESTDISPATCHCLASS = "Test.Forgery.Setup.FakeRouter"; + +} diff --git a/tests/_setup/WebAppCreator.cls b/tests/_setup/WebAppCreator.cls new file mode 100644 index 0000000..3ac1732 --- /dev/null +++ b/tests/_setup/WebAppCreator.cls @@ -0,0 +1,44 @@ +Class Test.Forgery.Setup.WebAppCreator Extends %Projection.AbstractProjection +{ + +Parameter TESTBASEURL = "/test/forgery/api"; + +Parameter TESTDISPATCHCLASS = "Test.Forgery.Setup.FakeRouter"; + +Projection WebAppProjection As Test.Forgery.Setup.WebAppCreator; + +ClassMethod CreateProjection() As %Status +{ + + new $namespace + set $namespace = "%SYS" + + if ##class(Security.Applications).%ExistsId(..#TESTBASEURL) { + return $$$OK + } + + set webApp = ##class(Security.Applications).%New(..#TESTBASEURL) + set webApp.Name = ..#TESTBASEURL + set webApp.DispatchClass = ..#TESTDISPATCHCLASS + set webApp.CookiePath = ..#TESTBASEURL + set webApp.NameSpace = "DEV" + set webApp.UseCookies = 2 + set webApp.Recurse = 1 + set webApp.Type = 2 + set webApp.InbndWebServicesEnabled = 1 + set webApp.AutoCompile = 1 + + return webApp.%Save() +} + +ClassMethod RemoveProjection() As %Status +{ + + new $namespace + set $namespace = "%SYS" + + do ##class(Security.Applications).%DeleteId(..#TESTBASEURL) + return $$$OK +} + +} diff --git a/tests/_setup/extensions/AssertiveStatus.cls b/tests/_setup/extensions/AssertiveStatus.cls new file mode 100644 index 0000000..f56b3e5 --- /dev/null +++ b/tests/_setup/extensions/AssertiveStatus.cls @@ -0,0 +1,29 @@ +Class Test.Forgery.Extensions.AssertiveStatus [ Abstract ] +{ + +Method MatchStatus(assertingStatus As %Status, expectedMessage... As %String) [ Private ] +{ + set statusCode = $System.Status.GetErrorCodes(assertingStatus) + set statusCount = $length(statusCode, ",") + set statusMessage = $lfs($System.Status.GetErrorText(assertingStatus), $c(13,10)) + + set errorIdRegexp = ##class(%Regex.Matcher).%New("^(?:\s){0,}(ERROR)\s(.*)$") + + for i=1:1:statusCount { + set errorIdRegexp.Text = $listget(statusMessage, i) + set messageWithoutCode = errorIdRegexp.ReplaceAll("$2") + set formattedMessage = ..FormatMessage(messageWithoutCode) + if formattedMessage '= $get(expectedMessage(i)) { + return $$$FormatText("Status assertion error: expected status message '%1' but received '%2' instead.", expectedMessage(i), formattedMessage) + } + } + + return $$$OK +} + +Method FormatMessage(message As %String) As %String +{ + return message +} + +} diff --git a/tests/_setup/fakes/FakeRouter.cls b/tests/_setup/fakes/FakeRouter.cls new file mode 100644 index 0000000..7bdcb5a --- /dev/null +++ b/tests/_setup/fakes/FakeRouter.cls @@ -0,0 +1,17 @@ +Class Test.Forgery.Setup.FakeRouter Extends %CSP.REST +{ + +XData UrlMap +{ + + + +} + +ClassMethod SayHello() +{ + write { "message": (%request.Get("message")) }.%ToJSON() + return $$$OK +} + +} diff --git a/tests/_setup/fakes/FrontierDispatchHandler.cls b/tests/_setup/fakes/FrontierDispatchHandler.cls new file mode 100644 index 0000000..a6fc586 --- /dev/null +++ b/tests/_setup/fakes/FrontierDispatchHandler.cls @@ -0,0 +1,22 @@ +Class Test.Forgery.Setup.FrontierDispatchHandler Extends (%RegisteredObject, Forgery.IDispatchHandler) +{ + +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, context As Forgery.CSP.Context) As %Status +{ + set %request = context.Request + set %response = context.Response + set %session = context.Session + + set %frontier = ##class(Frontier.Context).%New(%session, %request, %response) + + $$$ThrowOnError($classmethod(restDispatchClass, "HandleRequest", resource, httpMethod)) + return $$$OK +} + +Method OnDispose() As %Status +{ + kill %request, %session, %request + return $$$OK +} + +} diff --git a/tests/_setup/fakes/SimpleRESTDispatchHandler.cls b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls new file mode 100644 index 0000000..05612e7 --- /dev/null +++ b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls @@ -0,0 +1,21 @@ +Class Test.Forgery.Setup.SimpleRESTDispatchHandler Extends (%RegisteredObject, Forgery.IDispatchHandler) +{ + +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, cspContext As Forgery.CSP.Context, interceptor As Forgery.IO.DeviceInterceptor) As %Status +{ + // Must publish these variables because of how %CSP.Page works. + set %request = cspContext.Request + set %response = cspContext.Response + set %session = cspContext.Session + + $$$ThrowOnError($classmethod(restDispatchClass, "DispatchRequest", resource, httpMethod)) + return $$$OK +} + +Method OnDispose() As %Status +{ + kill %request, %session, %request + return $$$OK +} + +} diff --git a/tests/_setup/fakes/StubbedDispatchHandler.cls b/tests/_setup/fakes/StubbedDispatchHandler.cls new file mode 100644 index 0000000..3f2896b --- /dev/null +++ b/tests/_setup/fakes/StubbedDispatchHandler.cls @@ -0,0 +1,46 @@ +Class Test.Forgery.Setup.StubbedDispatchHandler Extends (Forgery.IDispatchHandler, %RegisteredObject) +{ + +Property Message As %String; + +Method %OnNew(message As %String = "") As %Status +{ + set ..Message = message + + return $$$OK +} + +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, cspContext As Forgery.CSP.Context, interceptor As Forgery.IO.DeviceInterceptor) As %Status +{ + + // Must publish these variables because of how %CSP.Page works. + set %request = cspContext.Request + set %response = cspContext.Response + set %session = cspContext.Session + + if ..Message '= "" { + write ..Message + return $$$OK + } + + set reply = "" + + if httpMethod = "GET" { + write { "message": (%request.Get("message")) }.%ToJSON() + } elseif (httpMethod = "POST") || (httpMethod = "PATCH") || (httpMethod = "PUT") { + do %request.Content.Rewind() + do %request.Content.OutputToDevice() + } elseif httpMethod = "HEAD" { + write "" + } + + return $$$OK +} + +Method OnDispose() As %Status +{ + kill %request, %session, %request + return $$$OK +} + +} diff --git a/tests/unit/ConfigurationMergingTest.cls b/tests/unit/ConfigurationMergingTest.cls new file mode 100644 index 0000000..70b8ad2 --- /dev/null +++ b/tests/unit/ConfigurationMergingTest.cls @@ -0,0 +1,54 @@ +Class Test.Forgery.ConfigurationMergingTest Extends %UnitTest.TestCase +{ + +Property Merger As Forgery.Internal.ConfigurationMerger; + +Method OnBeforeOneTest() As %Status +{ + set ..Merger = ##class(Forgery.Internal.ConfigurationMerger).%New() + return $$$OK +} + +Method TestMergeTwoProperties() +{ + set source1 = { "a": "b" } + set source2 = { "c": "d" } + + set result = ..Merger.Merge(source1, source2) + + do $$$AssertEquals(result.a, "b") + do $$$AssertEquals(result.c, "d") +} + +Method TestDontMergeArrays() +{ + set source1 = [1,2,3] + set source2 = [4,5,6] + + set result = ..Merger.Merge(source1, source2) + + do $$$AssertEquals(result.%Size(), 3) +} + +Method TestDontOverwriteKeys() +{ + set source1 = { "a": "b" } + set source2 = { "a": "c" } + + set result = ..Merger.Merge(source1, source2) + + do $$$AssertEquals(result.a, "b") +} + +Method TestMergeNestedObjects() +{ + set source1 = { "a": { "b": "c" } } + set source2 = { "a": { "b": "c", "d": { "e": 1 } }} + + set result = ..Merger.Merge(source1, source2) + do $$$AssertTrue($isobject(result.a)) + do $$$AssertEquals(result.a.b, "c") + do $$$AssertEquals(result.a.d.e, 1) +} + +} diff --git a/tests/unit/ConfigurationTest.cls b/tests/unit/ConfigurationTest.cls new file mode 100644 index 0000000..b6b404b --- /dev/null +++ b/tests/unit/ConfigurationTest.cls @@ -0,0 +1,84 @@ +Class Test.Forgery.ConfigurationTest Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) +{ + +Method TestValidateRequiredConfigs() +{ + set configuration = ##class(Forgery.Configuration).%New() + do $$$AssertEquals(..MatchStatus(configuration.Validate(), "#5001: Configuration validation error: One or more settings are missing or wrong:", "#5001: WebApplication configuration is required.", "#5001: Dispatch handler is required."), 1) +} + +Method TestCanSetAndGetWebApplication() +{ + set expectedUrl = "/testing/api" + set config = ##class(Forgery.Configuration).%New() + + do $$$AssertStatusOK(config.SetWebApplication(expectedUrl)) + do $$$AssertEquals(config.GetWebApplication(), expectedUrl) +} + +Method TestSetAndGetDispatchHandler() +{ + set expectedDispatcher = ##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("") + set config = ##class(Forgery.Configuration).%New() + + do $$$AssertStatusOK(config.SetDispatchHandler(expectedDispatcher)) + do $$$AssertEquals(config.GetDispatchHandler(), expectedDispatcher) +} + +Method TestCanSetAndGetDeviceInterceptorLineTerminator() +{ + set config = ##class(Forgery.Configuration).%New() + set expectedLineTerminator = $char(9) + + do $$$AssertStatusOK(config.SetDeviceLineTerminator(expectedLineTerminator)) + do $$$AssertEquals(config.GetDeviceLineTerminator(), expectedLineTerminator) +} + +Method TestCanSetAndGetDeviceCharsetTerminator() +{ + set config = ##class(Forgery.Configuration).%New() + set expectedCharset = "iso-8859-1" + + do $$$AssertStatusOK(config.SetDeviceCharset(expectedCharset)) + do $$$AssertEquals(config.GetDeviceCharset(), expectedCharset) +} + +Method TestCanSetAndGetRequestDefaultHeaders() +{ + set config = ##class(Forgery.Configuration).%New() + set headers = { "X-Authorization": "Basic blahblahblah" } + + do $$$AssertStatusOK(config.SetRequestDefaultHeaders(headers)) + do $$$AssertEquals(config.GetRequestDefaultHeaders(), headers) +} + +Method TestCanSetAndGetDefaultCookies() +{ + set config = ##class(Forgery.Configuration).%New() + set cookies = [{"key": "test_cookies", "value": "blah" }] + + do $$$AssertStatusOK(config.SetRequestDefaultCookies(cookies)) + do $$$AssertEquals(config.GetRequestDefaultCookies(), cookies) +} + +Method TestValidateInvalidHeaders() +{ + set config = ##class(Forgery.Configuration).%New() + do config.SetWebApplication("/whatever") + do config.SetDispatchHandler(##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("whatever")) + + do $$$AssertStatusOK(config.SetRequestDefaultHeaders([])) + do $$$AssertEquals(..MatchStatus(config.Validate(), "#5001: Configuration validation error: One or more settings are missing or wrong:", "#5001: Request default headers must be an instance of %DynamicObject if provided."), 1) +} + +Method TestValidateInvalidCookies() +{ + set config = ##class(Forgery.Configuration).%New() + do config.SetWebApplication("/whatever") + do config.SetDispatchHandler(##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("whatever")) + + do $$$AssertStatusOK(config.SetRequestDefaultCookies({})) + do $$$AssertEquals(..MatchStatus(config.Validate(), "#5001: Configuration validation error: One or more settings are missing or wrong:", "#5001: Request default cookies must be an instance of %DynamicArray if provided."), 1) +} + +} diff --git a/tests/unit/DeviceInterceptionTest.cls b/tests/unit/DeviceInterceptionTest.cls new file mode 100644 index 0000000..906f47b --- /dev/null +++ b/tests/unit/DeviceInterceptionTest.cls @@ -0,0 +1,52 @@ +Class Test.Forgery.DeviceInterception Extends %UnitTest.TestCase +{ + +Method TestInterceptionCycle() +{ + set expectedContent = "intercepted messsage" + + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() + + set interceptionStatus1 = interceptor.StartInterception() + write expectedContent + set interceptionStatus2 = interceptor.EndInterception() + + do $$$AssertStatusOK(interceptionStatus1) + do $$$AssertStatusOK(interceptionStatus2) + + do $$$AssertEquals(interceptor.GetInterceptedContent().Read(), expectedContent) +} + +Method TestVariableLifetime() +{ + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() + set publicVarPreExistsPostConstrutor = $data(%forgeryDeviceInterceptedStream) + + do interceptor.StartInterception() + set publicVarExistsPostStartedInterception = $data(%forgeryDeviceInterceptedStream) + + set interceptor = "" // calls EndInterception on %OnClose. + + set publicVarExistsPostEndedInterception = $data(%forgeryDeviceInterceptedStream) + + do $$$AssertNotTrue(publicVarPreExistsPostConstrutor) + do $$$AssertTrue(publicVarExistsPostStartedInterception) + do $$$AssertNotTrue(publicVarExistsPostEndedInterception) +} + +Method TestCanChangeCharset() +{ + + set interceptor1 = ##class(Forgery.IO.DeviceInterceptor).%New("utf-8") + set expectedTable1 = ##class(%Net.Charset).GetTranslateTable("utf-8") + + set interceptor2 = ##class(Forgery.IO.DeviceInterceptor).%New("iso-8859-1") + set expectedTable2 = ##class(%Net.Charset).GetTranslateTable("iso-8859-1") + + write interceptor1.GetInterceptedContent().TranslateTable, " ", interceptor2.GetInterceptedContent().TranslateTable + + do $$$AssertEquals(interceptor1.GetInterceptedContent().TranslateTable, expectedTable1) + do $$$AssertEquals(interceptor2.GetInterceptedContent().TranslateTable, expectedTable2) +} + +} diff --git a/tests/unit/FormDataTest.cls b/tests/unit/FormDataTest.cls new file mode 100644 index 0000000..a5afcac --- /dev/null +++ b/tests/unit/FormDataTest.cls @@ -0,0 +1,24 @@ +Class Test.Forgery.FormDataTest Extends %UnitTest.TestCase +{ + +Method TestFormDataAcceptRepeatedKeysAndCanIterateOver() +{ + set formData = ##class(Forgery.FormData).%New() + do formData.Append("key", "value1") + do formData.Append("key", "value2") + + set iterator = formData.GetEntryIterator() + set entries = [] + + while iterator.NextEntry(.entry) { + do entries.%Push(entry) + } + + do $$$AssertEquals(entries.%Get(0).name, "key") + do $$$AssertEquals(entries.%Get(1).name, "key") + + do $$$AssertEquals(entries.%Get(0).value, "value1") + do $$$AssertEquals(entries.%Get(1).value, "value2") +} + +} diff --git a/tests/unit/RESTClassResolvingTest.cls b/tests/unit/RESTClassResolvingTest.cls new file mode 100644 index 0000000..6d9f9e4 --- /dev/null +++ b/tests/unit/RESTClassResolvingTest.cls @@ -0,0 +1,27 @@ +Class Test.Forgery.RESTClassResolvingTest Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) +{ + +Property BaseURL As %String [ InitialExpression = {##class(Test.Forgery.Setup.Parameters).#TESTBASEURL} ]; + +Property DispatchClass As %String [ InitialExpression = {##class(Test.Forgery.Setup.Parameters).#TESTDISPATCHCLASS} ]; + +Method WithBaseURL(resource As %String) +{ + return $$$FormatText("%1/%2", ..BaseURL, resource) +} + +Method TestResolveAPIThroughUrl() +{ + set resolver = ##class(Forgery.Internal.RESTClassResolver).%New(..BaseURL) + + do $$$AssertStatusOK(resolver.Resolve(..WithBaseURL("/hello"), .info)) + do $$$AssertEquals(info.DispatchClass, ..DispatchClass) +} + +Method TestGetErrorIfWebAppDoesntExist() +{ + set resolver = ##class(Forgery.Internal.RESTClassResolver).%New("/invalid/app") + do $$$AssertEquals(..MatchStatus(resolver.Resolve("/doesnt/exist", .info), "#5001: Web application resolver error: No application matching the url '/invalid/app/doesnt/exist' has been found."), 1) +} + +} diff --git a/tests/unit/RequestDataHandlingTest.cls b/tests/unit/RequestDataHandlingTest.cls new file mode 100644 index 0000000..6ec7901 --- /dev/null +++ b/tests/unit/RequestDataHandlingTest.cls @@ -0,0 +1,100 @@ +Class Test.Forgery.RequestDataHandlingTest Extends %UnitTest.TestCase +{ + +Method CreateRequestWithDefaults(data As %Any, method As %String = "GET", resource As %String = "", overrides = {{ "headers": {}, "cookies": [] }}) As Forgery.CSP.Request +{ + set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL + set appInfo = { "AppUrl": (baseUrl) } + + set preparer = ##class(Forgery.Internal.RequestPreparer).%New() + set request = ##class(Forgery.CSP.Request).%New( + baseUrl, + resource, + method + ) + + do preparer.Prepare(request, data, overrides) + + return request +} + +Method TestTransformDynamicObjectToContentStream() +{ + set payload = { "message": "hello from dynamic object!" } + set request = ..CreateRequestWithDefaults(payload, "POST") + + do $$$AssertTrue($isobject(request.Content) && request.Content.%IsA("%CSP.CharacterStream")) + do $$$AssertEquals(request.Content.Read(), payload.%ToJSON()) +} + +Method TestConsumeQueryParametersFromUrl() +{ + set request = ..CreateRequestWithDefaults("", "GET", "/query-params?q1=consume&q2=this&q3=message") + + do $$$AssertEquals(request.Get("q1"), "consume") + do $$$AssertEquals(request.Get("q2"), "this") + do $$$AssertEquals(request.Get("q3"), "message") + do $$$AssertEquals(request.URL, "/test/forgery/api/query-params") +} + +Method TestConsumeQueryParametersFromDataWhenHTTPGET() +{ + set payload = { "q1": "consume", "q2": "this", "q3": "message" } + set request = ..CreateRequestWithDefaults(payload, "GET") + + do $$$AssertEquals(request.Get("q1"), "consume") + do $$$AssertEquals(request.Get("q2"), "this") + do $$$AssertEquals(request.Get("q3"), "message") +} + +Method TestConsumeHeadersFromOverrides() +{ + set request = ..CreateRequestWithDefaults("", "GET", "/", { "headers": { "X-Add-This-Header": "here" } }) + + do $$$AssertEquals(request.CgiEnvs("HTTP_X_ADD_THIS_HEADER"), "here") +} + +Method TestConsumeAuthorizationHeaderFromOverrides() +{ + set request = ..CreateRequestWithDefaults("", "GET", "/", { "headers": { "Authorization": "Basic blah blah blah" } }) + + do $$$AssertEquals(request.Authorization, "Basic blah blah blah") +} + +Method TestContentTypeIsSetAccordingToPayload() +{ + set payload1 = ##class(Forgery.FormData).%New() + set payload2 = ##class(%Stream.GlobalCharacter).%New() + + set request1 = ..CreateRequestWithDefaults(payload1, "POST", "/") + set request2 = ..CreateRequestWithDefaults(payload2, "POST", "/") + + do $$$AssertEquals(request1.ContentType, "multipart/form-data") + do $$$AssertEquals(request2.ContentType, "application/octet-stream") +} + +Method TestHandleFormDataAsMimeData() +{ + set payload = ##class(Forgery.FormData).%New() + + do payload.Append("key1", "value1") + do payload.Append("key2", "value2") + + set request = ..CreateRequestWithDefaults(payload, "POST", "/") + + do $$$AssertEquals(request.GetMimeData("key1"), "value1") + do $$$AssertEquals(request.GetMimeData("key2"), "value2") +} + +Method TestConsumeBinaryPayloadAsData() +{ + set payload = ##class(%Stream.FileBinary).%New() + do payload.LinkToFile("/usr/bin/file") + + set request = ..CreateRequestWithDefaults(payload, "POST", "/") + + do $$$AssertTrue(request.Content.%IsA("%CSP.BinaryStream")) + do $$$AssertNotTrue(request.Content.IsCharacter()) +} + +} diff --git a/tests/unit/RequestDispatchingTest.cls b/tests/unit/RequestDispatchingTest.cls new file mode 100644 index 0000000..67a2a77 --- /dev/null +++ b/tests/unit/RequestDispatchingTest.cls @@ -0,0 +1,35 @@ +Class Test.Forgery.RequestDispatchingTest Extends %UnitTest.TestCase +{ + +Property Configuration As Forgery.Configuration; + +Property Dispatcher As Forgery.Internal.RequestDispatcher; + +Method OnBeforeOneTest() As %Status +{ + set ..Configuration = ##class(Forgery.Configuration).%New() + do ..Configuration.SetWebApplication(##class(Test.Forgery.Setup.Parameters).#TESTBASEURL) + do ..Configuration.SetDispatchHandler(##class(Test.Forgery.Setup.StubbedDispatchHandler).%New()) + + set ..Dispatcher = ##class(Forgery.Internal.RequestDispatcherFactory).CreateUsingConfiguration(..Configuration) + return $$$OK +} + +Method TestCanDispatchWithQueryParams() +{ + do $$$AssertStatusOK(..Dispatcher.Dispatch("GET", "/hello?message=testing message")) + + do $$$AssertEquals(..Dispatcher.GetLastReply().Read(), { "message": "testing message"}.%ToJSON()) + do $$$AssertEquals(..Dispatcher.GetLastContext().Request.Method, "GET") +} + +Method TestCanDispatchWithPayload() +{ + set payload = { "message": "Hello from POST dispatch!" } + do $$$AssertStatusOK(..Dispatcher.Dispatch("POST", "/hello", payload)) + + do $$$AssertEquals(..Dispatcher.GetLastReply().Read(), payload.%ToJSON()) + do $$$AssertEquals(..Dispatcher.GetLastContext().Request.Method, "POST") +} + +}