From fc35660a78251f818f17cc4a57fc187782cd95cb Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 11:50:21 -0300 Subject: [PATCH 01/58] create assertive status extension --- tests/_setup/extensions/AssertiveStatus.cls | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/_setup/extensions/AssertiveStatus.cls 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 +} + +} From 3b35b4ef4986053d6548bfea682949ce3eb952f1 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 11:50:58 -0300 Subject: [PATCH 02/58] refactor web app resolver --- .../Internal/WebApplicationResolver.cls | 44 +++++++++++++++++ tests/WebApplicationResolvingTest.cls | 49 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 cls/Forgery/Internal/WebApplicationResolver.cls create mode 100644 tests/WebApplicationResolvingTest.cls diff --git a/cls/Forgery/Internal/WebApplicationResolver.cls b/cls/Forgery/Internal/WebApplicationResolver.cls new file mode 100644 index 0000000..7336a29 --- /dev/null +++ b/cls/Forgery/Internal/WebApplicationResolver.cls @@ -0,0 +1,44 @@ +Class Forgery.Internal.WebApplicationResolver Extends %RegisteredObject +{ + +Property URLMapCache As %DynamicObject [ Private ]; + +Method %OnNew() As %Status +{ + set ..URLMapCache = {} + return $$$OK +} + +Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status +{ + if ..URLMapCache.%IsDefined(url) { + set info = ..URLMapCache.%Get(url) + return $$$OK + } + + do ..URLMapCache.%Set(url, {}) + set info = ..URLMapCache.%Get(url) + + set $namespace = "%SYS" + + set result = {} + set name = "" + set urlWithInitialSlash = $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 ORDER BY LEN(Name) DESC", urlWithInitialSlash) + if rows.%Next() { + set info.Name = rows.%Get("Name") + set info.DispatchClass = rows.%Get("DispatchClass") + set info.Path = rows.%Get("Path") + set info.AppUrl = name_$select($extract(name, *) '= "/" : "/", 1: "") + } + + if info.%Size() = 0 { + return $$$ERROR($$$GeneralError, $$$FormatText("Web application resolver error: No application matching the url '%1' has been found.", url)) + } + + return $$$OK +} + +} diff --git a/tests/WebApplicationResolvingTest.cls b/tests/WebApplicationResolvingTest.cls new file mode 100644 index 0000000..cd5dc7e --- /dev/null +++ b/tests/WebApplicationResolvingTest.cls @@ -0,0 +1,49 @@ +Class Test.Forgery.WebApplicationResolving Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) +{ + +Parameter TESTBASEURL = "/test/integration/forgery/api"; + +Method OnBeforeAllTests() As %Status +{ + new $namespace + set $namespace = "%SYS" + + set app = ##class(Security.Applications).%New() + set app.Name = ..#TESTBASEURL + set app.DispatchClass = "Test.Forgery.FakeAPI" + set app.MatchRoles = "%:All" + set app.InbndWebServicesEnabled = 1 + set app.Enabled = 1 + set app.NameSpace = "USER" + + return app.%Save() +} + +Method OnAfterAllTests() As %Status +{ + new $namespace + set $namespace = "%SYS" + + return ##class(Security.Applications).%DeleteId(..#TESTBASEURL) +} + +Method WithBaseURL(resource As %String) +{ + return $$$FormatText("%1/%2", ..#TESTBASEURL, resource) +} + +Method TestResolveAPIThroughUrl() +{ + set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New() + + do $$$AssertStatusOK(resolver.Resolve(..WithBaseURL("/hello"), .info)) + do $$$AssertEquals(info.DispatchClass, "Test.Forgery.FakeAPI") +} + +Method TestGetErrorIfWebAppDoesntExist() +{ + set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New() + do $$$AssertEquals(1, ..MatchStatus(resolver.Resolve("/doesnt/exist", .info), "#5001: Web application resolver error: No application matching the url '/doesnt/exist' has been found.")) +} + +} From 988f8cce0fc357d2756ea2b1dda602d2901a6149 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 11:52:17 -0300 Subject: [PATCH 03/58] create dispatch handler interface --- cls/Forgery/IDispatchHandler.cls | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 cls/Forgery/IDispatchHandler.cls diff --git a/cls/Forgery/IDispatchHandler.cls b/cls/Forgery/IDispatchHandler.cls new file mode 100644 index 0000000..759e407 --- /dev/null +++ b/cls/Forgery/IDispatchHandler.cls @@ -0,0 +1,9 @@ +Class Forgery.IDispatchHandler [ Abstract ] +{ + +Method Dispatch() As %Status [ Abstract ] +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "Dispatch")) +} + +} From ca94bccfd253dcd99b3d659a6c4958419b0d8ca2 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 11:51:44 -0300 Subject: [PATCH 04/58] create configuration dto --- cls/Forgery/Configuration.cls | 70 +++++++++++++++++++++++++++++++++++ tests/ConfigurationTest.cls | 49 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 cls/Forgery/Configuration.cls create mode 100644 tests/ConfigurationTest.cls diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls new file mode 100644 index 0000000..3024e98 --- /dev/null +++ b/cls/Forgery/Configuration.cls @@ -0,0 +1,70 @@ +Class Forgery.Configuration Extends %RegisteredObject +{ + +Property BaseURL 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 ]; + +Method SetBaseURL(baseURL As %String) As %Status +{ + set ..BaseURL = baseURL + return $$$OK +} + +Method GetBaseURL() As %String [ CodeMode = expression ] +{ +..BaseURL +} + +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 Validate() As %Status +{ + set status = $$$OK + + if ..BaseURL = "" set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Base URL is required.")) + if '$isobject(..DispatchHandler) set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Dispatch handler is required.")) + + if $$$ISERR(status) { + set status = $$$EMBEDSC($$$ERROR($$$GeneralError, "Configuration validation error: One or more settings are missing:"), status) + } + + return status +} + +} diff --git a/tests/ConfigurationTest.cls b/tests/ConfigurationTest.cls new file mode 100644 index 0000000..9b234a9 --- /dev/null +++ b/tests/ConfigurationTest.cls @@ -0,0 +1,49 @@ +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:", "#5001: BaseURL configuration is required.", "#5001: Dispatch handler is required."), 1) +} + +Method TestCanSetAndGetBaseUrl() +{ + set expectedUrl = "/testing/api" + set config = ##class(Forgery.Configuration).%New() + do config.SetBaseURL(expectedUrl) + + do $$$AssertEquals(config.GetBaseURL(), expectedUrl) +} + +Method TestSetAndGetDispatchHandler() +{ + set expectedDispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("") + set config = ##class(Forgery.Configuration).%New() + + do config.SetDispatchHandler(expectedDispatcher) + + do $$$AssertEquals(config.GetDispatchHandler(), expectedDispatcher) +} + +Method TestCanSetAndGetDeviceInterceptorLineTerminator() +{ + set config = ##class(Forgery.Configuration).%New() + set expectedLineTerminator = $char(9) + + do config.SetDeviceLineTerminator(expectedLineTerminator) + + do $$$AssertEquals(config.GetDeviceLineTerminator(), expectedLineTerminator) +} + +Method TestCanSetAndGetDeviceCharsetTerminator() +{ + set config = ##class(Forgery.Configuration).%New() + set expectedCharset = "iso-8859-1" + + do config.SetDeviceCharset(expectedCharset) + + do $$$AssertEquals(config.GetDeviceCharset(), expectedCharset) +} + +} From a9bf60b26dc8b60642e24d95095c8db7d455abe8 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 11:56:44 -0300 Subject: [PATCH 05/58] refactor device interception --- cls/Forgery/IO/DeviceInterceptor.cls | 104 +++++++++++++++++++++ tests/DeviceInterceptionTest.cls | 41 ++++++++ tests/_setup/fakes/FakeRouteDispatcher.cls | 18 ++++ 3 files changed, 163 insertions(+) create mode 100644 cls/Forgery/IO/DeviceInterceptor.cls create mode 100644 tests/DeviceInterceptionTest.cls create mode 100644 tests/_setup/fakes/FakeRouteDispatcher.cls diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls new file mode 100644 index 0000000..1d7f509 --- /dev/null +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -0,0 +1,104 @@ +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 DeviceRedirected 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 Intercept(handler As Forgery.IDispatchHandler) As %Status +{ + set status = $$$OK + + $$$QuitOnError(..StartInterception()) + + try { + do handler.Dispatch() + } catch ex { + set status = ex.AsStatus() + } + + do ..EndInterception() + return status +} + +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 [ Private, ProcedureBlock = 0 ] +{ + new status + + set status = $$$OK + set %forgeryDeviceInterceptedStream = ..DeviceContent + + use $io::("^"_$zname) + do ##class(%Device).ReDirectIO(1) + + set ..DeviceRedirected = 1 + + return $$$OK +} + +ClassMethod redirections() [ Internal, Private, ProcedureBlock = 0 ] +{ + quit + +wstr(s) do %forgeryDeviceInterceptedStream.Write(s) Quit +wchr(a) do %forgeryDeviceInterceptedStream.Write($char(a)) Quit +wnl do %forgeryDeviceInterceptedStream.Write($char(13,10)) Quit +wff do %forgeryDeviceInterceptedStream.Write($char(13,10,13,10)) Quit +wtab(n) do %forgeryDeviceInterceptedStream.Write($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() + do ##class(%Device).ReDirectIO(..DeviceRedirected) + + return $$$OK +} + +Method GetInterceptedContent() As %Stream.FileCharacter +{ + return ..DeviceContent +} + +} diff --git a/tests/DeviceInterceptionTest.cls b/tests/DeviceInterceptionTest.cls new file mode 100644 index 0000000..25d6a80 --- /dev/null +++ b/tests/DeviceInterceptionTest.cls @@ -0,0 +1,41 @@ +Class Test.Forgery.DeviceInterception Extends %UnitTest.TestCase +{ + +Method TestInterceptRouteDispatcherWrites() +{ + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() + set dispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("reply with this") + + do $$$AssertStatusOK(interceptor.Intercept(dispatcher)) + do $$$AssertEquals(interceptor.GetInterceptedContent().Read(), "reply with this") +} + +Method TestVariableLifetime() +{ + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() + set dispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("reply with this") + + set publicVarPreExistsPostConstrutor = $data(%forgeryDeviceInterceptedStream) + do interceptor.Intercept(dispatcher) + set publicVarPostExistsPostCall = $data(%forgeryDeviceInterceptedStream) + + do $$$AssertNotTrue(publicVarPreExistsPostConstrutor) + do $$$AssertNotTrue(publicVarPostExistsPostCall) +} + +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/_setup/fakes/FakeRouteDispatcher.cls b/tests/_setup/fakes/FakeRouteDispatcher.cls new file mode 100644 index 0000000..be26846 --- /dev/null +++ b/tests/_setup/fakes/FakeRouteDispatcher.cls @@ -0,0 +1,18 @@ +Class Test.Forgery.FakeRouteDispatcher Extends (Forgery.IDispatchHandler, %RegisteredObject) +{ + +Property Message As %String; + +Method %OnNew(message As %String) As %Status +{ + set ..Message = message + + return $$$OK +} + +Method Dispatch() As %Status +{ + write ..Message +} + +} From f6c8bc4e84a280dd0e39c7e2330c710835adb1b4 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 15:26:12 -0300 Subject: [PATCH 06/58] add support for default headers --- cls/Forgery/Configuration.cls | 16 ++++++++++++++++ tests/ConfigurationTest.cls | 22 ++++++++++++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls index 3024e98..be9e8e4 100644 --- a/cls/Forgery/Configuration.cls +++ b/cls/Forgery/Configuration.cls @@ -9,6 +9,8 @@ Property DeviceLineTerminator As %String [ InitialExpression = {$char(13,10)}, P Property DeviceCharset As %String [ InitialExpression = "utf-8", Private ]; +Property DefaultRequestHeaders As %DynamicObject [ InitialExpression = {{}}, Private ]; + Method SetBaseURL(baseURL As %String) As %Status { set ..BaseURL = baseURL @@ -53,12 +55,26 @@ Method GetDeviceCharset() As %String [ CodeMode = expression ] ..DeviceCharset } +Method SetDefaultRequestHeaders(headers As %DynamicObject) As %Status +{ + set ..DefaultRequestHeaders = headers + return $$$OK +} + +Method GetDefaultRequestHeaders() As %DynamicObject [ CodeMode = expression ] +{ +..DefaultRequestHeaders +} + Method Validate() As %Status { set status = $$$OK if ..BaseURL = "" set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Base URL is required.")) if '$isobject(..DispatchHandler) set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Dispatch handler is required.")) + if '$isobject(..DefaultRequestHeaders) || ..DefaultRequestHeaders.%Extends("%DynamicObject") { + set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Default request headers must be an instance of %DynamicObject if provided.")) + } if $$$ISERR(status) { set status = $$$EMBEDSC($$$ERROR($$$GeneralError, "Configuration validation error: One or more settings are missing:"), status) diff --git a/tests/ConfigurationTest.cls b/tests/ConfigurationTest.cls index 9b234a9..a5f1bea 100644 --- a/tests/ConfigurationTest.cls +++ b/tests/ConfigurationTest.cls @@ -11,8 +11,8 @@ Method TestCanSetAndGetBaseUrl() { set expectedUrl = "/testing/api" set config = ##class(Forgery.Configuration).%New() - do config.SetBaseURL(expectedUrl) - + + do $$$AssertStatusOK(config.SetBaseURL(expectedUrl)) do $$$AssertEquals(config.GetBaseURL(), expectedUrl) } @@ -21,8 +21,7 @@ Method TestSetAndGetDispatchHandler() set expectedDispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("") set config = ##class(Forgery.Configuration).%New() - do config.SetDispatchHandler(expectedDispatcher) - + do $$$AssertStatusOK(config.SetDispatchHandler(expectedDispatcher)) do $$$AssertEquals(config.GetDispatchHandler(), expectedDispatcher) } @@ -31,8 +30,7 @@ Method TestCanSetAndGetDeviceInterceptorLineTerminator() set config = ##class(Forgery.Configuration).%New() set expectedLineTerminator = $char(9) - do config.SetDeviceLineTerminator(expectedLineTerminator) - + do $$$AssertStatusOK(config.SetDeviceLineTerminator(expectedLineTerminator)) do $$$AssertEquals(config.GetDeviceLineTerminator(), expectedLineTerminator) } @@ -41,9 +39,17 @@ Method TestCanSetAndGetDeviceCharsetTerminator() set config = ##class(Forgery.Configuration).%New() set expectedCharset = "iso-8859-1" - do config.SetDeviceCharset(expectedCharset) - + do $$$AssertStatusOK(config.SetDeviceCharset(expectedCharset)) do $$$AssertEquals(config.GetDeviceCharset(), expectedCharset) } +Method TestCanSetAndGetDefaultRequestHeaders() +{ + set config = ##class(Forgery.Configuration).%New() + set headers = { "X-Authorization": "Basic blahblahblah" } + + do $$$AssertStatusOK(config.SetDefaultRequestHeaders(headers)) + do $$$AssertEquals(config.GetDefaultRequestHeaders(), headers) +} + } From 5f1f86fa954390d942685a5a97fecbaf31007f99 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 18:00:08 -0300 Subject: [PATCH 07/58] change naming and add invalid cookies test --- cls/Forgery/Configuration.cls | 32 ++++++++++++++++++++++-------- tests/ConfigurationTest.cls | 37 +++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls index be9e8e4..429a63e 100644 --- a/cls/Forgery/Configuration.cls +++ b/cls/Forgery/Configuration.cls @@ -9,7 +9,9 @@ Property DeviceLineTerminator As %String [ InitialExpression = {$char(13,10)}, P Property DeviceCharset As %String [ InitialExpression = "utf-8", Private ]; -Property DefaultRequestHeaders As %DynamicObject [ InitialExpression = {{}}, Private ]; +Property RequestDefaultHeaders As %DynamicObject [ InitialExpression = {{}}, Private ]; + +Property RequestDefaultCookies As %DynamicArray [ InitialExpression = {[]}, Private ]; Method SetBaseURL(baseURL As %String) As %Status { @@ -55,15 +57,26 @@ Method GetDeviceCharset() As %String [ CodeMode = expression ] ..DeviceCharset } -Method SetDefaultRequestHeaders(headers As %DynamicObject) As %Status +Method SetRequestDefaultHeaders(headers As %DynamicObject) As %Status +{ + set ..RequestDefaultHeaders = headers + return $$$OK +} + +Method GetRequestDefaultHeaders() As %DynamicObject [ CodeMode = expression ] +{ +..RequestDefaultHeaders +} + +Method SetRequestDefaultCookies(headers As %DynamicObject) As %Status { - set ..DefaultRequestHeaders = headers + set ..RequestDefaultCookies = headers return $$$OK } -Method GetDefaultRequestHeaders() As %DynamicObject [ CodeMode = expression ] +Method GetRequestDefaultCookies() As %DynamicObject [ CodeMode = expression ] { -..DefaultRequestHeaders +..RequestDefaultCookies } Method Validate() As %Status @@ -72,12 +85,15 @@ Method Validate() As %Status if ..BaseURL = "" set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Base URL is required.")) if '$isobject(..DispatchHandler) set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Dispatch handler is required.")) - if '$isobject(..DefaultRequestHeaders) || ..DefaultRequestHeaders.%Extends("%DynamicObject") { - set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Default request headers must be an instance of %DynamicObject if provided.")) + 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:"), status) + set status = $$$EMBEDSC($$$ERROR($$$GeneralError, "Configuration validation error: One or more settings are missing or wrong:"), status) } return status diff --git a/tests/ConfigurationTest.cls b/tests/ConfigurationTest.cls index a5f1bea..23a85cc 100644 --- a/tests/ConfigurationTest.cls +++ b/tests/ConfigurationTest.cls @@ -4,7 +4,7 @@ Class Test.Forgery.ConfigurationTest Extends (%UnitTest.TestCase, Test.Forgery.E Method TestValidateRequiredConfigs() { set configuration = ##class(Forgery.Configuration).%New() - do $$$AssertEquals(..MatchStatus(configuration.Validate(), "#5001: Configuration validation error: One or more settings are missing:", "#5001: BaseURL configuration is required.", "#5001: Dispatch handler is required."), 1) + do $$$AssertEquals(..MatchStatus(configuration.Validate(), "#5001: Configuration validation error: One or more settings are missing or wrong:", "#5001: BaseURL configuration is required.", "#5001: Dispatch handler is required."), 1) } Method TestCanSetAndGetBaseUrl() @@ -43,13 +43,42 @@ Method TestCanSetAndGetDeviceCharsetTerminator() do $$$AssertEquals(config.GetDeviceCharset(), expectedCharset) } -Method TestCanSetAndGetDefaultRequestHeaders() +Method TestCanSetAndGetRequestDefaultHeaders() { set config = ##class(Forgery.Configuration).%New() set headers = { "X-Authorization": "Basic blahblahblah" } - do $$$AssertStatusOK(config.SetDefaultRequestHeaders(headers)) - do $$$AssertEquals(config.GetDefaultRequestHeaders(), headers) + 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.SetBaseURL("/whatever") + do config.SetDispatchHandler(##class(Test.Forgery.FakeRouteDispatcher).%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.SetBaseURL("/whatever") + do config.SetDispatchHandler(##class(Test.Forgery.FakeRouteDispatcher).%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 %DynamicArrayObject if provided."), 1) } } From f97bad92943c9504fcdbe4ac80d2af6dec9128c8 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 18:00:32 -0300 Subject: [PATCH 08/58] restore redirection flag on finish --- cls/Forgery/IO/DeviceInterceptor.cls | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls index 1d7f509..b475075 100644 --- a/cls/Forgery/IO/DeviceInterceptor.cls +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -93,6 +93,8 @@ Method EndInterception() As %Status [ ProcedureBlock = 0 ] if $isobject(..DeviceContent) do ..DeviceContent.Rewind() do ##class(%Device).ReDirectIO(..DeviceRedirected) + set ..DeviceRedirected = 0 + return $$$OK } From 57d92872f624c0e1e25a3e8bcc0fde1012251f58 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 7 Feb 2025 18:02:34 -0300 Subject: [PATCH 09/58] change method signature --- cls/Forgery/Configuration.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls index 429a63e..14422e9 100644 --- a/cls/Forgery/Configuration.cls +++ b/cls/Forgery/Configuration.cls @@ -68,7 +68,7 @@ Method GetRequestDefaultHeaders() As %DynamicObject [ CodeMode = expression ] ..RequestDefaultHeaders } -Method SetRequestDefaultCookies(headers As %DynamicObject) As %Status +Method SetRequestDefaultCookies(headers As %DynamicArray) As %Status { set ..RequestDefaultCookies = headers return $$$OK From a994aef16b8c364a0ec39a46236ceb290826909d Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 10 Feb 2025 10:46:14 -0300 Subject: [PATCH 10/58] create parameter setup class --- tests/_setup/Parameters.cls | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/_setup/Parameters.cls diff --git a/tests/_setup/Parameters.cls b/tests/_setup/Parameters.cls new file mode 100644 index 0000000..96d55cf --- /dev/null +++ b/tests/_setup/Parameters.cls @@ -0,0 +1,6 @@ +Class Test.Forgery.Setup.Parameters +{ + +Parameter TESTBASEURL = "/test/forgery/api"; + +} From 176e1fb8931579ebec5bb730c0b444ea53061699 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 10 Feb 2025 10:46:28 -0300 Subject: [PATCH 11/58] create configuration merger --- cls/Forgery/Internal/ConfigurationMerger.cls | 28 ++++++++++ tests/ConfigurationMergingTest.cls | 54 ++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 cls/Forgery/Internal/ConfigurationMerger.cls create mode 100644 tests/ConfigurationMergingTest.cls diff --git a/cls/Forgery/Internal/ConfigurationMerger.cls b/cls/Forgery/Internal/ConfigurationMerger.cls new file mode 100644 index 0000000..e13194c --- /dev/null +++ b/cls/Forgery/Internal/ConfigurationMerger.cls @@ -0,0 +1,28 @@ +Class Forgery.Internal.ConfigurationMerger Extends %RegisteredObject +{ + +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/tests/ConfigurationMergingTest.cls b/tests/ConfigurationMergingTest.cls new file mode 100644 index 0000000..1415a81 --- /dev/null +++ b/tests/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 TestDoMergeNestedObjects() +{ + 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) +} + +} From b45cd34da98942bd738bd4eda17e552b5381f05a Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 10 Feb 2025 10:47:07 -0300 Subject: [PATCH 12/58] clear stream after interception --- cls/Forgery/IO/DeviceInterceptor.cls | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls index b475075..7de161a 100644 --- a/cls/Forgery/IO/DeviceInterceptor.cls +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -26,15 +26,16 @@ Method %OnClose() As %Status return ..EndInterception() } -Method Intercept(handler As Forgery.IDispatchHandler) As %Status +Method Intercept(dispatchHandler As Forgery.IDispatchHandler) As %Status { set status = $$$OK $$$QuitOnError(..StartInterception()) try { - do handler.Dispatch() + do dispatchHandler.Handle() } catch ex { + do dispatchHandler.PostHandle() set status = ex.AsStatus() } @@ -56,6 +57,8 @@ Method StartInterception() As %Status [ Private, ProcedureBlock = 0 ] { new status + $$$QuitOnError(..DeviceContent.Clear()) + set status = $$$OK set %forgeryDeviceInterceptedStream = ..DeviceContent From 3896205f7b8b648a3d638c7a5e0c09ac7516bad6 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 10 Feb 2025 10:47:42 -0300 Subject: [PATCH 13/58] fix agent interface and finish base agent --- cls/Forgery/IAgent.cls | 44 +++++++++++++++++++ cls/Forgery/Internal/BaseAgent.cls | 68 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 cls/Forgery/IAgent.cls create mode 100644 cls/Forgery/Internal/BaseAgent.cls diff --git a/cls/Forgery/IAgent.cls b/cls/Forgery/IAgent.cls new file mode 100644 index 0000000..f2cbe3d --- /dev/null +++ b/cls/Forgery/IAgent.cls @@ -0,0 +1,44 @@ +Class Forgery.IAgent [ Abstract ] +{ + +Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Post")) +} + +Method Get(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Get")) +} + +Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Put")) +} + +Method Delete(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Delete")) +} + +Method Head(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Head")) +} + +Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Patch")) +} + +Method GetLastContext() As Forgery.CSP.Context +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "GetLastContext")) +} + +Method GetLastReply() As %Stream.Object +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "GetLastReply")) +} + +} diff --git a/cls/Forgery/Internal/BaseAgent.cls b/cls/Forgery/Internal/BaseAgent.cls new file mode 100644 index 0000000..b75946c --- /dev/null +++ b/cls/Forgery/Internal/BaseAgent.cls @@ -0,0 +1,68 @@ +Class Forgery.Internal.BaseAgent Extends (%RegisteredObject, Forgery.IAgent) +{ + +Parameter HTTPPOSTMETHOD = "POST"; + +Parameter HTTPGETMETHOD = "GET"; + +Parameter HTTPPUTMETHOD = "PUT"; + +Parameter HTTPDELETEMETHOD = "DELETE"; + +Parameter HTTPPATCHMETHOD = "PATCH"; + +Parameter HTTPOPTIONSMETHOD = "OPTIONS"; + +Parameter HTTPHEADMETHOD = "HEAD"; + +Property Configuration As Forgery.Configuration [ Private ]; + +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 +} + +Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(resource, ..#HTTPPOSTMETHOD, data, overrides) +} + +Method Get(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(resource, ..#HTTPGETMETHOD,, overrides) +} + +Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(resource, ..#HTTPPUTMETHOD, data, overrides) +} + +Method Delete(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(resource, ..#HTTPDELETEMETHOD, overrides) +} + +Method Head(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(resource, ..#HTTPHEADMETHOD, overrides) +} + +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) +} + +Method GetLastContext() As Forgery.CSP.Context +{ + return ..RequestDispatcher.GetLastContext() +} + +Method GetLastReply() As %Stream.Object +{ + return ..RequestDispatcher.GetLastReply() +} + +} From 4fd53e1c274451dac7941834087ca71e9f77bb61 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 10 Feb 2025 10:48:12 -0300 Subject: [PATCH 14/58] refactor package naming --- cls/Forgery/{Agent => Internal}/CookieJar.cls | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename cls/Forgery/{Agent => Internal}/CookieJar.cls (94%) diff --git a/cls/Forgery/Agent/CookieJar.cls b/cls/Forgery/Internal/CookieJar.cls similarity index 94% rename from cls/Forgery/Agent/CookieJar.cls rename to cls/Forgery/Internal/CookieJar.cls index f8db34d..4a50f24 100644 --- a/cls/Forgery/Agent/CookieJar.cls +++ b/cls/Forgery/Internal/CookieJar.cls @@ -1,4 +1,4 @@ -Class Forgery.Agent.CookieJar Extends %RegisteredObject +Class Forgery.Internal.CookieJar Extends %RegisteredObject { Property Cookies As %String [ MultiDimensional ]; @@ -51,4 +51,3 @@ Method Empty() } } - From 97eb63d4aba7025e8c236041e2cb2794c521daac Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 15:22:48 -0300 Subject: [PATCH 15/58] update interface contract --- cls/Forgery/IDispatchHandler.cls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cls/Forgery/IDispatchHandler.cls b/cls/Forgery/IDispatchHandler.cls index 759e407..286d82c 100644 --- a/cls/Forgery/IDispatchHandler.cls +++ b/cls/Forgery/IDispatchHandler.cls @@ -1,9 +1,9 @@ Class Forgery.IDispatchHandler [ Abstract ] { -Method Dispatch() As %Status [ Abstract ] +Method Handle(resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status [ Abstract ] { - $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "Dispatch")) + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "Handle")) } } From 05f4595d2dccf7873291cf16aa0d7ef812b0f1b4 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 15:23:22 -0300 Subject: [PATCH 16/58] provide required data to the dispatch handler --- cls/Forgery/IO/DeviceInterceptor.cls | 5 +-- tests/DeviceInterceptionTest.cls | 14 ++++-- tests/_setup/Parameters.cls | 2 + tests/_setup/WebAppCreator.cls | 43 +++++++++++++++++++ tests/_setup/fakes/FakeRouteDispatcher.cls | 18 -------- .../fakes/SimpleRESTDispatchHandler.cls | 10 +++++ tests/_setup/fakes/StubbedDispatchHandler.cls | 34 +++++++++++++++ 7 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 tests/_setup/WebAppCreator.cls delete mode 100644 tests/_setup/fakes/FakeRouteDispatcher.cls create mode 100644 tests/_setup/fakes/SimpleRESTDispatchHandler.cls create mode 100644 tests/_setup/fakes/StubbedDispatchHandler.cls diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls index 7de161a..127803a 100644 --- a/cls/Forgery/IO/DeviceInterceptor.cls +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -26,16 +26,15 @@ Method %OnClose() As %Status return ..EndInterception() } -Method Intercept(dispatchHandler As Forgery.IDispatchHandler) As %Status +Method Intercept(dispatchHandler As Forgery.IDispatchHandler, resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status { set status = $$$OK $$$QuitOnError(..StartInterception()) try { - do dispatchHandler.Handle() + do dispatchHandler.Handle(resource, httpMethod, restDispatchClass) } catch ex { - do dispatchHandler.PostHandle() set status = ex.AsStatus() } diff --git a/tests/DeviceInterceptionTest.cls b/tests/DeviceInterceptionTest.cls index 25d6a80..8c9d1df 100644 --- a/tests/DeviceInterceptionTest.cls +++ b/tests/DeviceInterceptionTest.cls @@ -1,22 +1,28 @@ Class Test.Forgery.DeviceInterception Extends %UnitTest.TestCase { +Method OnAfterAllTests() As %Status +{ + do ##class(%Device).ReDirectIO(1) + return $$$OK +} + Method TestInterceptRouteDispatcherWrites() { set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() - set dispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("reply with this") + set dispatcher = ##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("reply with this") - do $$$AssertStatusOK(interceptor.Intercept(dispatcher)) + do $$$AssertStatusOK(interceptor.Intercept(dispatcher, "", "", "")) do $$$AssertEquals(interceptor.GetInterceptedContent().Read(), "reply with this") } Method TestVariableLifetime() { set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() - set dispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("reply with this") + set dispatcher = ##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("reply with this") set publicVarPreExistsPostConstrutor = $data(%forgeryDeviceInterceptedStream) - do interceptor.Intercept(dispatcher) + do $$$AssertStatusOK(interceptor.Intercept(dispatcher, "", "", "")) set publicVarPostExistsPostCall = $data(%forgeryDeviceInterceptedStream) do $$$AssertNotTrue(publicVarPreExistsPostConstrutor) diff --git a/tests/_setup/Parameters.cls b/tests/_setup/Parameters.cls index 96d55cf..742baf8 100644 --- a/tests/_setup/Parameters.cls +++ b/tests/_setup/Parameters.cls @@ -3,4 +3,6 @@ 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..4629f9d --- /dev/null +++ b/tests/_setup/WebAppCreator.cls @@ -0,0 +1,43 @@ +Class Test.Forgery.Setup.WebAppCreator Extends %Projection.AbstractProjection [ CompileAfter = Test.Forgery.Setup.Parameters ] +{ + +Projection CreatorProjection As Test.Forgery.Setup.WebAppCreator; + +ClassMethod CreateProjection() As %Status +{ + set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL + set dispatchClass = ##class(Test.Forgery.Setup.Parameters).#TESTDISPATCHCLASS + + new $namespace + set $namespace = "%SYS" + + if ##class(Security.Applications).%ExistsId(baseUrl) { + return $$$OK + } + + set webApp = ##class(Security.Applications).%New(baseUrl) + set webApp.Name = baseUrl + set webApp.DispatchClass = dispatchClass + set webApp.CookiePath = baseUrl + 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 +{ + set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL + + new $namespace + set $namespace = "%SYS" + + do ##class(Security.Applications).%DeleteId(baseUrl) + return $$$OK +} + +} diff --git a/tests/_setup/fakes/FakeRouteDispatcher.cls b/tests/_setup/fakes/FakeRouteDispatcher.cls deleted file mode 100644 index be26846..0000000 --- a/tests/_setup/fakes/FakeRouteDispatcher.cls +++ /dev/null @@ -1,18 +0,0 @@ -Class Test.Forgery.FakeRouteDispatcher Extends (Forgery.IDispatchHandler, %RegisteredObject) -{ - -Property Message As %String; - -Method %OnNew(message As %String) As %Status -{ - set ..Message = message - - return $$$OK -} - -Method Dispatch() As %Status -{ - write ..Message -} - -} diff --git a/tests/_setup/fakes/SimpleRESTDispatchHandler.cls b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls new file mode 100644 index 0000000..639dd70 --- /dev/null +++ b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls @@ -0,0 +1,10 @@ +Class Test.Forgery.Setup.SimpleRESTDispatchHandler Extends (%RegisteredObject, Forgery.IDispatchHandler) +{ + +Method Handle(resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status +{ + $$$ThrowOnError($classmethod(restDispatchClass, "DispatchRequest", resource, httpMethod)) + return $$$OK +} + +} diff --git a/tests/_setup/fakes/StubbedDispatchHandler.cls b/tests/_setup/fakes/StubbedDispatchHandler.cls new file mode 100644 index 0000000..ce6bb9f --- /dev/null +++ b/tests/_setup/fakes/StubbedDispatchHandler.cls @@ -0,0 +1,34 @@ +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 Handle(resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status +{ + 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 +} + +} From 5f7f0543d4258d14e27c4e8619a64f1681bd83be Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 15:26:51 -0300 Subject: [PATCH 17/58] refactored request object --- cls/Forgery/CSP/Request.cls | 99 +++++++++++++++++++ .../Internal/WebApplicationResolver.cls | 18 +++- tests/RequestDataHandlingTest.cls | 70 +++++++++++++ tests/_setup/fakes/FakeRouter.cls | 16 +++ 4 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 cls/Forgery/CSP/Request.cls create mode 100644 tests/RequestDataHandlingTest.cls create mode 100644 tests/_setup/fakes/FakeRouter.cls diff --git a/cls/Forgery/CSP/Request.cls b/cls/Forgery/CSP/Request.cls new file mode 100644 index 0000000..b504303 --- /dev/null +++ b/cls/Forgery/CSP/Request.cls @@ -0,0 +1,99 @@ +Class Forgery.CSP.Request Extends (%RegisteredObject, Forgery.CSP.AbstractRequestLike) +{ + +Method %OnNew(url As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, info As Forgery.Agent.ApplicationInfo, overrides As %DynamicObject = {{}}) As %Status [ Private ] +{ + set ..URL = url + set ..Method = httpMethod + set ..Content = ##class(%CSP.CharacterStream).%New() + set ..Application = info.AppUrl + do ..LoadDefaultCgiEnvs() + do ..Prepare(data, overrides) + return $$$OK +} + +Method Prepare(data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject) [ Private ] +{ + #define MethodAcceptsPayload $lf($lb("PUT", "POST", "PATCH"), ..Method) + + do ..AppendToRequest("headers", overrides.headers) + do ..AppendToRequest("cookies", overrides.cookies) + + if overrides.%IsDefined("headers") { + if overrides.headers.%IsDefined("Authorization") { + set ..Authorization = overrides.headers.Authorization + } + } + + if ..URL [ "?" { + set queryParts = $replace(..URL, "?", "&") + 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) + } + } + + if $isobject(data) { + if overrides.headers.%Get("Content-Type") [ "multipart/form-data" { + do ..AppendToRequest("mimedata", data) + } elseif data.%IsA("%Stream.Object") { + if data.IsCharacter() set content = ##class(%CSP.CharacterStream).%New() + else set content = ##class(%CSP.BinaryStream).%New() + do content.CopyFrom(data) + } elseif data.%Extends("%DynamicAbstractObject") && $$$MethodAcceptsPayload { + if overrides.headers.%Get("Content-Type") '[ "application/json" { + do ..SetHeader("Content-Type", "application/json; charset=utf-8") + } + set content = ##class(%CSP.CharacterStream).%New() + do data.%ToJSON(.content) + set ..Content = content + } else { + do ..AppendToRequest("queryparams", data) + } + } +} + +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 LoadDefaultCgiEnvs() [ Private ] +{ + do ##class(%Net.URLParser).Decompose(..URL, .components) + + 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") = 80 + set i%CgiEnvs("SERVER_PROTOCOL") = "HTTP/1.1" + set i%CgiEnvs("REMOTE_ADDR") = "localhost" +} + +} diff --git a/cls/Forgery/Internal/WebApplicationResolver.cls b/cls/Forgery/Internal/WebApplicationResolver.cls index 7336a29..496bb14 100644 --- a/cls/Forgery/Internal/WebApplicationResolver.cls +++ b/cls/Forgery/Internal/WebApplicationResolver.cls @@ -1,10 +1,13 @@ Class Forgery.Internal.WebApplicationResolver Extends %RegisteredObject { +Property BaseURL As %String [ Private ]; + Property URLMapCache As %DynamicObject [ Private ]; -Method %OnNew() As %Status +Method %OnNew(baseUrl As %String) As %Status { + set ..BaseURL = baseUrl set ..URLMapCache = {} return $$$OK } @@ -19,14 +22,21 @@ Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status do ..URLMapCache.%Set(url, {}) set info = ..URLMapCache.%Get(url) + new $namespace set $namespace = "%SYS" + set baseUrl = ..BaseURL + + if $extract(baseUrl, *) = "/" { + set baseUrl = $extract(baseUrl, 1, *-1) + } + set result = {} set name = "" - set urlWithInitialSlash = $select($extract(url) '= "/" : "/"_url, 1: url) + 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 ORDER BY LEN(Name) DESC", urlWithInitialSlash) + set rows = ##class(%SQL.Statement).%ExecDirect(, "SELECT TOP 1 Name, DispatchClass, Path FROM SECURITY.APPLICATIONS WHERE ? %STARTSWITH Name ORDER BY LEN(Name) DESC", prefixedResource) if rows.%Next() { set info.Name = rows.%Get("Name") set info.DispatchClass = rows.%Get("DispatchClass") @@ -35,7 +45,7 @@ Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status } if info.%Size() = 0 { - return $$$ERROR($$$GeneralError, $$$FormatText("Web application resolver error: No application matching the url '%1' has been found.", url)) + return $$$ERROR($$$GeneralError, $$$FormatText("Web application resolver error: No application matching the url '%1' has been found.", prefixedResource)) } return $$$OK diff --git a/tests/RequestDataHandlingTest.cls b/tests/RequestDataHandlingTest.cls new file mode 100644 index 0000000..475b839 --- /dev/null +++ b/tests/RequestDataHandlingTest.cls @@ -0,0 +1,70 @@ +Class Test.Forgery.RequestDispatchingTest 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) } + + return ##class(Forgery.CSP.Request).%New( + baseUrl_"/"_$select($extract(resource) = "/" : $extract(resource, 2, *), 1 : resource), + method, + data, + appInfo, + overrides + ) +} + +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", "?q1=consume&q2=this&q3=message") + + do $$$AssertEquals(request.Get("q1"), "consume") + do $$$AssertEquals(request.Get("q2"), "this") + do $$$AssertEquals(request.Get("q3"), "message") +} + +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 TestConsumePayloadAsMimeDataIfHeaderIsSet() +{ + set payload = [{ "key": "value1" }, { "key": "value2" }, { "key": "value3" }] + set request = ..CreateRequestWithDefaults(payload, "POST", "/", { "headers": { "Content-Type": "multipart/form-data" } }) + + do $$$AssertEquals(request.MimeData("key", 1), "value1") + do $$$AssertEquals(request.MimeData("key", 2), "value2") + do $$$AssertEquals(request.MimeData("key", 3), "value3") +} + +} diff --git a/tests/_setup/fakes/FakeRouter.cls b/tests/_setup/fakes/FakeRouter.cls new file mode 100644 index 0000000..3e7b120 --- /dev/null +++ b/tests/_setup/fakes/FakeRouter.cls @@ -0,0 +1,16 @@ +Class Test.Forgery.Setup.FakeRouter Extends %CSP.REST +{ + +XData UrlMap +{ + + + +} + +ClassMethod SayHello(message As %String) +{ + write { "message": (message) }.%ToJSON() +} + +} From ec3ee0ab36f336d8698b7a44eda1825cb4f1b2be Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 16:01:42 -0300 Subject: [PATCH 18/58] fix broken test --- tests/WebApplicationResolvingTest.cls | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/WebApplicationResolvingTest.cls b/tests/WebApplicationResolvingTest.cls index cd5dc7e..3c4701a 100644 --- a/tests/WebApplicationResolvingTest.cls +++ b/tests/WebApplicationResolvingTest.cls @@ -1,16 +1,21 @@ Class Test.Forgery.WebApplicationResolving Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) { -Parameter TESTBASEURL = "/test/integration/forgery/api"; +Property BaseURL As %String; + +Property DispatchClass As %String; Method OnBeforeAllTests() As %Status { + set ..BaseURL = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL + set ..DispatchClass = ##class(Test.Forgery.Setup.Parameters).#TESTDISPATCHCLASS + new $namespace set $namespace = "%SYS" set app = ##class(Security.Applications).%New() - set app.Name = ..#TESTBASEURL - set app.DispatchClass = "Test.Forgery.FakeAPI" + set app.Name = ..BaseURL + set app.DispatchClass = ..DispatchClass set app.MatchRoles = "%:All" set app.InbndWebServicesEnabled = 1 set app.Enabled = 1 @@ -21,29 +26,31 @@ Method OnBeforeAllTests() As %Status Method OnAfterAllTests() As %Status { + set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL + new $namespace set $namespace = "%SYS" - return ##class(Security.Applications).%DeleteId(..#TESTBASEURL) + return ##class(Security.Applications).%DeleteId(baseUrl) } Method WithBaseURL(resource As %String) { - return $$$FormatText("%1/%2", ..#TESTBASEURL, resource) + return $$$FormatText("%1/%2", ..BaseURL, resource) } Method TestResolveAPIThroughUrl() { - set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New() + set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New(..BaseURL) do $$$AssertStatusOK(resolver.Resolve(..WithBaseURL("/hello"), .info)) - do $$$AssertEquals(info.DispatchClass, "Test.Forgery.FakeAPI") + do $$$AssertEquals(info.DispatchClass, ..DispatchClass) } Method TestGetErrorIfWebAppDoesntExist() { - set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New() - do $$$AssertEquals(1, ..MatchStatus(resolver.Resolve("/doesnt/exist", .info), "#5001: Web application resolver error: No application matching the url '/doesnt/exist' has been found.")) + set resolver = ##class(Forgery.Internal.WebApplicationResolver).%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) } } From 4642920972d95d95b7103df2ff9e567a1c44319c Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 16:02:31 -0300 Subject: [PATCH 19/58] add abstract request class --- cls/Forgery/CSP/AbstractRequestLike.cls | 240 ++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 cls/Forgery/CSP/AbstractRequestLike.cls diff --git a/cls/Forgery/CSP/AbstractRequestLike.cls b/cls/Forgery/CSP/AbstractRequestLike.cls new file mode 100644 index 0000000..0938dd1 --- /dev/null +++ b/cls/Forgery/CSP/AbstractRequestLike.cls @@ -0,0 +1,240 @@ +Class Forgery.CSP.AbstractRequestLike [ Abstract ] +{ + +Property URL As %String; + +Property Method As %String; + +Property Application As %String; + +Property Content As %CSP.Stream; + +Property Cookies As %String [ MultiDimensional ]; + +Property MimeData As %String [ MultiDimensional ]; + +Property ContentType As %String; + +Property ContentLength As %String; + +Property Authorization As %String; + +Property Protocol As %String [ InitialExpression = "HTTP/1.1" ]; + +Property CgiEnvs As %String [ MultiDimensional ]; + +Property Data As %String [ MultiDimensional ]; + +Method AuthorizationSet(value As %String) As %Status [ Final ] +{ + set i%CgiEnvs("HTTP_AUTHORIZATION") = value + set i%Authorization = value + return $$$OK +} + +Method ContentTypeSet(value As %String) As %Status [ Private ] +{ + set i%CgiEnvs("HTTP_CONTENT_TYPE") = value + set i%ContentType = value + return $$$OK +} + +Method ContentLengthSet(value As %String) As %Status [ Private ] +{ + set i%CgiEvs("CONTENT_LENGTH") = value + set i%ContentLength = value + return $$$OK +} + +// 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. + +/// Retrieves the named cookie +Method GetCookie(name As %String, default As %String = "", index As %Integer = 1) As %String [ CodeMode = expression, Final ] +{ +$get(i%Cookies(name,index),default) +} + +/// Inserts a cookie name/value pair. +Method InsertCookie(name As %String, value As %String) [ Final, Internal ] +{ + If name="" Quit $$$OK + do ..SetCgiEnv("HTTP_"_$$$ucase($replace(name, "-", "_")), value) + Set i%Cookies(name,$order(i%Cookies(name,""),-1)+1)=value + Quit +} + +/// Returns true if the named cookie exists in the cookie collection, false otherwise. +Method IsDefinedCookie(name As %String, index As %Integer = 1) As %Boolean [ CodeMode = expression, Final ] +{ +$data(i%Cookies(name,index)) +} + +/// Retrieves the named multipart MIME stream. +Method GetMimeData(name As %String, default As %Stream.Object = "", index As %Integer = 1) As %Stream.Object [ CodeMode = expression, Final ] +{ +$get(i%MimeData(name,index),default) +} + +/// Inserts a multipart MIME stream by name into the collection. +Method InsertMimeData(name As %String, value As %Stream.Object) [ Final, Internal ] +{ + If value="" Quit + Set i%MimeData(name,$order(i%MimeData(name,""),-1)+1)=value + Quit +} + +/// Returns true if the named multipart MIME stream exists in the collection, false otherwise. +Method IsDefinedMimeData(name As %String, index As %Integer = 1) As %Boolean [ CodeMode = expression, Final ] +{ +$data(i%MimeData(name,index)) +} + +/// Returns the count of multipart MIME streams with this name. +Method CountMimeData(name As %String) As %Integer [ Final ] +{ + #Dim count,i + + Quit:'$data(i%MimeData(name)) 0 + Set count=0 Set i="" For Set i=$order(i%MimeData(name,i)) Quit:i="" Set count=count+1 + Quit count +} + +/// Retrieves name of the next multipart MIME stream stored in the request object. +Method NextMimeData(name As %String) As %String [ CodeMode = expression, Final ] +{ +$order(i%MimeData(name)) +} + +/// Return the index number of the next multipart MIME stream stored in the request object. +Method NextMimeDataIndex(name As %String, index As %Integer = "") As %String [ CodeMode = expression, Final ] +{ +$order(i%MimeData(name,index)) +} + +/// Removes this multipart MIME stream from the collection. Returns the number +/// of nodes it has removed. If name is not defined then it will +/// remove the entire set of MimeData, if name is defined but index +/// is not then it will remove all items stored under name. +Method DeleteMimeData(name As %String = "", index As %Integer = "") As %Integer [ Final, Internal ] +{ + #Dim defined + If name="" { + Set defined=0 + Set name=$order(i%MimeData("")) + While name'="" { + Set index=$order(i%MimeData(name,"")) + While index'="" { Set defined=defined+1,index=$order(i%MimeData(name,index)) } + Set name=$Order(i%MimeData(name)) + } + Kill i%MimeData + Quit defined + } ElseIf index="" { + Set defined=0 + Set index=$order(i%MimeData(name,"")) + While index'="" { Set defined=defined+1,index=$order(i%MimeData(name,index)) } + Kill i%MimeData(name) + Quit defined + } ElseIf $Data(i%MimeData(name,index)) { + Kill i%MimeData(name,index) + Quit 1 + } + Quit 0 +} + +Method SetCgiEnv(key As %String, value As %String) As %Status +{ + if '$data(i%CgiEnvs(key)) set i%CgiEnvs(key) = value + return $$$OK +} + +/// Inserts a CGI environment variable by name into the collection. +Method InsertCgiEnv(name As %String, value As %String) [ Final, Internal ] +{ + do ..SetCgiEnv(name, value) +} + +/// Retrieves the named CGI environment variable. +Method GetCgiEnv(name As %String, default As %String = "") As %String [ CodeMode = expression, Final ] +{ +$get(i%CgiEnvs(name),default) +} + +/// Returns true if the named CGI environment variable exists in the collection, false otherwise. +Method IsDefinedCgiEnv(name As %String) As %Boolean [ CodeMode = expression, Final ] +{ +$data(i%CgiEnvs(name)) +} + +/// Retrieves the next CGI environment variable name in the sequence +Method NextCgiEnv(name As %String) As %String [ CodeMode = expression, Final ] +{ +$order(i%CgiEnvs(name)) +} + +/// Removes this CGI environment variable from the collection, returns true if the item +/// was defined and false if it was never defined. +Method DeleteCgiEnv(name As %String) As %Boolean [ Final, Internal ] +{ + If $data(i%CgiEnvs(name)) Kill i%CgiEnvs(name) Quit 1 + Quit 0 +} + +Method SetHeader(key As %String, value As %String) As %Status +{ + if $$$lcase(key) = "content-type" set ..ContentType = value + if $$$lcase(key) = "content-length" set ..ContentLength = value + + do ..SetCgiEnv("HTTP_"_$$$ucase($replace(key, "-", "_")), value) + return $$$OK +} + +Method Get(name As %String, default As %String = "", index As %Integer = 1) As %String [ CodeMode = expression, Final ] +{ +$get(i%Data(name,index),default) +} + +Method Set(name As %String, value As %String, index As %Integer = 1) [ Final, Internal ] +{ + If $length(name)>254 Quit + Set i%Data(name,index)=value + QUIT +} + +Method Insert(name As %String, value As %String) [ Final ] +{ + If $length(name)>254 Quit + Set i%Data(name,$order(i%Data(name,""),-1)+1)=value + Quit +} + +Method IsDefined(name As %String, index As %Integer = 1) As %Boolean [ CodeMode = expression, Final ] +{ +$data(i%Data(name,index)) +} + +Method Count(name As %String) As %Integer [ Final ] +{ + #Dim count,i + Quit:'$data(i%Data(name)) 0 + Set count=0 Set i="" For Set i=$order(i%Data(name,i)) Quit:i="" Set count=count+1 + Quit count +} + +Method Find(name As %String, value As %String) As %Integer [ Final ] +{ + #Dim i + Set i=$order(i%Data(name,"")) + While (i'="")&&(i%Data(name,i)'=value) { Set i=$order(i%Data(name,i)) } + Quit i +} + +Method NextIndex(name As %String, ByRef index As %Integer = "") As %String [ Final ] +{ + Set index=$order(i%Data(name,index)) + Quit:index="" "" + Quit i%Data(name,index) +} + +} From cce07ee9581bf4f1e75367ef5be7c76c46668251 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 17:24:21 -0300 Subject: [PATCH 20/58] create context and factory --- cls/Forgery/CSP/Context.cls | 19 +++++++++++++++++++ cls/Forgery/CSP/ContextFactory.cls | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 cls/Forgery/CSP/Context.cls create mode 100644 cls/Forgery/CSP/ContextFactory.cls diff --git a/cls/Forgery/CSP/Context.cls b/cls/Forgery/CSP/Context.cls new file mode 100644 index 0000000..dfc2af7 --- /dev/null +++ b/cls/Forgery/CSP/Context.cls @@ -0,0 +1,19 @@ +Class Forgery.CSP.Context Extends %RegisteredObject +{ + +Property Request As Forgery.CSP.Request [ ReadOnly ]; + +Property Response As %CSP.Response [ ReadOnly ]; + +Property Session As %CSP.Session [ ReadOnly ]; + +Method %OnNew(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, appInfo As %DynamicObject, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + set i%Request = ##class(Forgery.CSP.Request).%New(resource, httpMethod, data, appInfo, overrides) + set i%Response = ##class(%CSP.Response).%New() + set i%Session = ##class(%CSP.Session).%New(-1, 0) + + return $$$OK +} + +} diff --git a/cls/Forgery/CSP/ContextFactory.cls b/cls/Forgery/CSP/ContextFactory.cls new file mode 100644 index 0000000..d4525f2 --- /dev/null +++ b/cls/Forgery/CSP/ContextFactory.cls @@ -0,0 +1,9 @@ +Class Forgery.CSP.ContextFactory Extends %RegisteredObject +{ + +Method Create(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, appInfo As %DynamicObject, overrides As %DynamicObject = {$$$NULLOREF}) +{ + return ##class(Forgery.CSP.Context).%New(resource, httpMethod, data, appInfo, overrides) +} + +} From cf2ae4c01e97cf1e7e164bff4d65fe15268fdf37 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 11 Feb 2025 17:24:43 -0300 Subject: [PATCH 21/58] create request dispatcher --- cls/Forgery/Internal/RequestDispatcher.cls | 82 +++++++++++++++++++ .../Internal/RequestDispatcherFactory.cls | 27 ++++++ tests/RequestDispatchingTest.cls | 41 ++++++++++ 3 files changed, 150 insertions(+) create mode 100644 cls/Forgery/Internal/RequestDispatcher.cls create mode 100644 cls/Forgery/Internal/RequestDispatcherFactory.cls create mode 100644 tests/RequestDispatchingTest.cls diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls new file mode 100644 index 0000000..7843484 --- /dev/null +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -0,0 +1,82 @@ +Class Forgery.Internal.RequestDispatcher Extends %RegisteredObject +{ + +Property InitialNamespace As %String [ InitialExpression = {$namespace}, Private ]; + +Property DispatchHandler As Forgery.IDispatchHandler [ Private ]; + +Property ContextFactory As Forgery.CSP.ContextFactory [ Private ]; + +Property LastContext As Forgery.CSP.Context [ Private ]; + +Property WebApplicationResolver As Forgery.Internal.WebApplicationResolver [ Private ]; + +Property DeviceInterceptor As Forgery.IO.DeviceInterceptor [ Private ]; + +Property ConfigurationMerger As Forgery.Internal.ConfigurationMerger [ Private ]; + +Property CookieJar As Forgery.Internal.CookieJar [ Private ]; + +Property RequestDefaultHeaders As %DynamicObject [ InitialExpression = {{}}, Private ]; + +Property RequestDefaultCookies As %DynamicArray [ InitialExpression = {[]}, Private ]; + +Method %OnNew(resolver As Forgery.Internal.WebApplicationResolver, handler As Forgery.IDispatchHandler, interceptor As Forgery.IO.DeviceInterceptor, jar As Forgery.Internal.CookieJar, cspContextFactory As Forgery.CSP.ContextFactory, configurationMerger As Forgery.Internal.ConfigurationMerger, requestDefaultHeaders As %DynamicObject = {{}}, requestDefaultCookies As %DynamicArray = {[]}) As %Status +{ + set ..WebApplicationResolver = resolver + set ..DispatchHandler = handler + set ..DeviceInterceptor = interceptor + set ..CookieJar = jar + set ..ContextFactory = cspContextFactory + set ..RequestDefaultHeaders = $select($isobject(requestDefaultHeaders) : requestDefaultHeaders, 1: {}) + set ..RequestDefaultCookies = $select($isobject(requestDefaultCookies) : requestDefaultCookies, 1: []) + set ..ConfigurationMerger = configurationMerger + + return $$$OK +} + +Method Dispatch(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + $$$QuitOnError(..WebApplicationResolver.Resolve(resource, .appInfo)) + + set safeOverrides = $select($isobject(overrides) : overrides, 1: {}) + set mergedOverrides = ..ConfigurationMerger.Merge( + ..RequestDefaultHeaders, + ..RequestDefaultCookies, + safeOverrides, + { "headers": {} }, + { "cookies": [] } + ) + set ..LastContext = ..ContextFactory.Create(resource, httpMethod, data, appInfo, mergedOverrides) + + // Must publish these variables because of how %CSP.Page works. + set %request = ..LastContext.Request + set %response = ..LastContext.Response + set %session = ..LastContext.Session + + set status = $$$OK + + try { + do ..LastContext.Request.PickFromJar(..CookieJar) + $$$ThrowOnError(..DeviceInterceptor.Intercept(..DispatchHandler, resource, httpMethod, appInfo.DispatchClass)) + do ..CookieJar.PutCookiesFromResponse(..LastContext.Response) + } catch ex { + set status = ex.AsStatus() + } + + // Ensure we are back to the original namespace after the dispatch. + set $namespace = ..InitialNamespace + return status +} + +Method GetLastContext() As Forgery.CSP.Context +{ + return ..LastContext +} + +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..dd73f62 --- /dev/null +++ b/cls/Forgery/Internal/RequestDispatcherFactory.cls @@ -0,0 +1,27 @@ +Class Forgery.Internal.RequestDispatcherFactory Extends %RegisteredObject +{ + +ClassMethod CreateUsingConfiguration(configuration As Forgery.Configuration) As Forgery.Internal.RequestDispatcher +{ + set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New(configuration.GetBaseURL()) + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New(configuration.GetDeviceCharset(), configuration.GetDeviceLineTerminator()) + set handler = configuration.GetDispatchHandler() + set jar = ##class(Forgery.Internal.CookieJar).%New() + set cspContextFactory = ##class(Forgery.CSP.ContextFactory).%New() + set merger = ##class(Forgery.Internal.ConfigurationMerger).%New() + set requestDefaultHeaders = configuration.GetRequestDefaultHeaders() + set requestDefaultCookies = configuration.GetRequestDefaultCookies() + + return ##class(Forgery.Internal.RequestDispatcher).%New( + resolver, + handler, + interceptor, + jar, + cspContextFactory, + merger, + requestDefaultHeaders, + requestDefaultCookies + ) +} + +} diff --git a/tests/RequestDispatchingTest.cls b/tests/RequestDispatchingTest.cls new file mode 100644 index 0000000..8a875bb --- /dev/null +++ b/tests/RequestDispatchingTest.cls @@ -0,0 +1,41 @@ +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.SetBaseURL(##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 OnAfterAllTests() As %Status +{ + do ##class(%Device).ReDirectIO(1) + return $$$OK +} + +Method TestCanDispatchWithQueryParams() +{ + do $$$AssertStatusOK(..Dispatcher.Dispatch("/hello?message=testing message", "GET")) + + 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("/hello", "POST", payload)) + + do $$$AssertEquals(..Dispatcher.GetLastReply().Read(), payload.%ToJSON()) + do $$$AssertEquals(..Dispatcher.GetLastContext().Request.Method, "POST") +} + +} From e3185abbac14f6e99bd2f5770e318ee6bb81b66c Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:21:39 -0300 Subject: [PATCH 22/58] simplify device interceptor redirection flag --- cls/Forgery/IO/DeviceInterceptor.cls | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls index 127803a..9215084 100644 --- a/cls/Forgery/IO/DeviceInterceptor.cls +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -7,7 +7,7 @@ Property OriginalDevice As %String [ InitialExpression = {$io}, Private ]; Property DeviceMnemonic As %String [ InitialExpression = {##class(%Device).GetMnemonicRoutine()}, Private ]; -Property DeviceRedirected As %Boolean [ InitialExpression = {##class(%Device).ReDirectIO()}, Private ]; +Property DevicePreviouslyRedirected As %Boolean [ InitialExpression = {##class(%Device).ReDirectIO()}, Private ]; Property TranslateTable As %String [ Private ]; @@ -61,23 +61,23 @@ Method StartInterception() As %Status [ Private, ProcedureBlock = 0 ] set status = $$$OK set %forgeryDeviceInterceptedStream = ..DeviceContent - use $io::("^"_$zname) - do ##class(%Device).ReDirectIO(1) - - set ..DeviceRedirected = 1 + 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) do %forgeryDeviceInterceptedStream.Write(s) Quit -wchr(a) do %forgeryDeviceInterceptedStream.Write($char(a)) Quit -wnl do %forgeryDeviceInterceptedStream.Write($char(13,10)) Quit -wff do %forgeryDeviceInterceptedStream.Write($char(13,10,13,10)) Quit -wtab(n) do %forgeryDeviceInterceptedStream.Write($c(9)) 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 "" } @@ -87,15 +87,13 @@ Method EndInterception() As %Status [ ProcedureBlock = 0 ] if ..DeviceMnemonic '= "" && (..DeviceMnemonic '= "%X364") { use ..OriginalDevice::("^"_..DeviceMnemonic) } else { - use ..OriginalDevice::"" + use ..OriginalDevice } kill %forgeryDeviceInterceptedStream if $isobject(..DeviceContent) do ..DeviceContent.Rewind() - do ##class(%Device).ReDirectIO(..DeviceRedirected) - - set ..DeviceRedirected = 0 + if ..DevicePreviouslyRedirected '= 1 do ##class(%Device).ReDirectIO($$$NO) return $$$OK } From 7241f1af3aeb6e10178793d12c65f879c4f50049 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:22:11 -0300 Subject: [PATCH 23/58] make properties editable due to a strange session behavior --- cls/Forgery/CSP/Context.cls | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cls/Forgery/CSP/Context.cls b/cls/Forgery/CSP/Context.cls index dfc2af7..fbb7ee1 100644 --- a/cls/Forgery/CSP/Context.cls +++ b/cls/Forgery/CSP/Context.cls @@ -1,17 +1,17 @@ Class Forgery.CSP.Context Extends %RegisteredObject { -Property Request As Forgery.CSP.Request [ ReadOnly ]; +Property Request As Forgery.CSP.Request; -Property Response As %CSP.Response [ ReadOnly ]; +Property Response As %CSP.Response; -Property Session As %CSP.Session [ ReadOnly ]; +Property Session As %CSP.Session; Method %OnNew(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, appInfo As %DynamicObject, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - set i%Request = ##class(Forgery.CSP.Request).%New(resource, httpMethod, data, appInfo, overrides) - set i%Response = ##class(%CSP.Response).%New() - set i%Session = ##class(%CSP.Session).%New(-1, 0) + set ..Request = ##class(Forgery.CSP.Request).%New(resource, httpMethod, data, appInfo, overrides) + set ..Response = ##class(%CSP.Response).%New() + set ..Session = ##class(%CSP.Session).%New($System.Encryption.GenCryptToken(), 0) return $$$OK } From 24dd3b3a3ae18496320edfcae193ca37e79ee6d6 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:24:33 -0300 Subject: [PATCH 24/58] improve separation of concerns --- cls/Forgery/Agent.cls | 44 ++++++++---------------------- cls/Forgery/IAgent.cls | 6 ++-- cls/Forgery/Internal/BaseAgent.cls | 23 ++++++++++------ 3 files changed, 28 insertions(+), 45 deletions(-) diff --git a/cls/Forgery/Agent.cls b/cls/Forgery/Agent.cls index 6a76f25..1037403 100644 --- a/cls/Forgery/Agent.cls +++ b/cls/Forgery/Agent.cls @@ -1,51 +1,29 @@ -Class Forgery.Agent Extends Forgery.Agent.Core +Class Forgery.Agent Extends (%RegisteredObject, Forgery.Internal.BaseAgent) { -Method Post(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) As %Status +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(resource, ..#HTTPPOSTMETHOD, data, overrides) } -Method Get(settings As %DynamicObject = "", response As %Stream.Object, outputToDevice As %Boolean = 0) As %Status +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(resource, ..#HTTPGETMETHOD, queryParams, overrides) } -Method Put(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +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(resource, ..#HTTPPUTMETHOD, data, overrides) } -Method Delete(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +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(resource, ..#HTTPDELETEMETHOD, queryParams, overrides) } -Method Head(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) +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) -} - -Method Patch(settings As %DynamicObject, response As %Stream.Object, outputToDevice As %Boolean = 0) -{ - 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(resource, ..#HTTPHEADMETHOD, queryParams, overrides) } } diff --git a/cls/Forgery/IAgent.cls b/cls/Forgery/IAgent.cls index f2cbe3d..f6950e0 100644 --- a/cls/Forgery/IAgent.cls +++ b/cls/Forgery/IAgent.cls @@ -6,7 +6,7 @@ Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Post")) } -Method Get(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Get")) } @@ -16,12 +16,12 @@ Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Put")) } -Method Delete(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Delete")) } -Method Head(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Head")) } diff --git a/cls/Forgery/Internal/BaseAgent.cls b/cls/Forgery/Internal/BaseAgent.cls index b75946c..f99d8e9 100644 --- a/cls/Forgery/Internal/BaseAgent.cls +++ b/cls/Forgery/Internal/BaseAgent.cls @@ -1,4 +1,4 @@ -Class Forgery.Internal.BaseAgent Extends (%RegisteredObject, Forgery.IAgent) +Class Forgery.Internal.BaseAgent Extends Forgery.IAgent { Parameter HTTPPOSTMETHOD = "POST"; @@ -27,27 +27,32 @@ Method %OnNew(configuration As Forgery.Configuration, requestDispatcherFactory A Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPPOSTMETHOD, data, overrides) + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Post")) } -Method Get(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPGETMETHOD,, overrides) + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Get")) } Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPPUTMETHOD, data, overrides) + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Put")) } -Method Delete(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPDELETEMETHOD, overrides) + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Delete")) } -Method Head(resource As %String, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPHEADMETHOD, overrides) + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Head")) +} + +Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Patch")) } Method DoRequest(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Private ] From 72e4c1e28a55c2e4e223cd901324ebe31c9f67e9 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:28:45 -0300 Subject: [PATCH 25/58] kill public variables --- cls/Forgery/Internal/RequestDispatcher.cls | 2 ++ tests/RequestDispatchingTest.cls | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index 7843484..ac69059 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -64,6 +64,8 @@ Method Dispatch(resource As %String, httpMethod As %String, data As %DynamicAbst set status = ex.AsStatus() } + kill %request, %response, %session + // Ensure we are back to the original namespace after the dispatch. set $namespace = ..InitialNamespace return status diff --git a/tests/RequestDispatchingTest.cls b/tests/RequestDispatchingTest.cls index 8a875bb..5c18c29 100644 --- a/tests/RequestDispatchingTest.cls +++ b/tests/RequestDispatchingTest.cls @@ -15,12 +15,6 @@ Method OnBeforeOneTest() As %Status return $$$OK } -Method OnAfterAllTests() As %Status -{ - do ##class(%Device).ReDirectIO(1) - return $$$OK -} - Method TestCanDispatchWithQueryParams() { do $$$AssertStatusOK(..Dispatcher.Dispatch("/hello?message=testing message", "GET")) From 3197053d43027d968030f19e5f890bef61fe55cf Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:29:27 -0300 Subject: [PATCH 26/58] use setup class for web app creation --- tests/WebApplicationResolvingTest.cls | 33 ++------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/tests/WebApplicationResolvingTest.cls b/tests/WebApplicationResolvingTest.cls index 3c4701a..b72db9d 100644 --- a/tests/WebApplicationResolvingTest.cls +++ b/tests/WebApplicationResolvingTest.cls @@ -1,38 +1,9 @@ Class Test.Forgery.WebApplicationResolving Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) { -Property BaseURL As %String; +Property BaseURL As %String [ InitialExpression = {##class(Test.Forgery.Setup.Parameters).#TESTBASEURL} ]; -Property DispatchClass As %String; - -Method OnBeforeAllTests() As %Status -{ - set ..BaseURL = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL - set ..DispatchClass = ##class(Test.Forgery.Setup.Parameters).#TESTDISPATCHCLASS - - new $namespace - set $namespace = "%SYS" - - set app = ##class(Security.Applications).%New() - set app.Name = ..BaseURL - set app.DispatchClass = ..DispatchClass - set app.MatchRoles = "%:All" - set app.InbndWebServicesEnabled = 1 - set app.Enabled = 1 - set app.NameSpace = "USER" - - return app.%Save() -} - -Method OnAfterAllTests() As %Status -{ - set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL - - new $namespace - set $namespace = "%SYS" - - return ##class(Security.Applications).%DeleteId(baseUrl) -} +Property DispatchClass As %String [ InitialExpression = {##class(Test.Forgery.Setup.Parameters).#TESTDISPATCHCLASS} ]; Method WithBaseURL(resource As %String) { From a2aa6fb32d75e40de07ad0b21ec83295b42fcb69 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:29:44 -0300 Subject: [PATCH 27/58] fix fake router --- tests/_setup/fakes/FakeRouter.cls | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/_setup/fakes/FakeRouter.cls b/tests/_setup/fakes/FakeRouter.cls index 3e7b120..7bdcb5a 100644 --- a/tests/_setup/fakes/FakeRouter.cls +++ b/tests/_setup/fakes/FakeRouter.cls @@ -8,9 +8,10 @@ XData UrlMap } -ClassMethod SayHello(message As %String) +ClassMethod SayHello() { - write { "message": (message) }.%ToJSON() + write { "message": (%request.Get("message")) }.%ToJSON() + return $$$OK } } From 45051d5cabe01dae6b07a0d434584889f4222a6b Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:30:09 -0300 Subject: [PATCH 28/58] add agent builder --- cls/Forgery/AgentBuilder.cls | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 cls/Forgery/AgentBuilder.cls diff --git a/cls/Forgery/AgentBuilder.cls b/cls/Forgery/AgentBuilder.cls new file mode 100644 index 0000000..4ca0662 --- /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 SetBaseURL(url As %String) As Forgery.AgentBuilder +{ + do ..Configuration.SetBaseURL(url) + 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 %Status +{ + do ..Configuration.SetRequestDefaultHeaders(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 +} + +} From b1c82a01e4ad0523b292d6f08b9dee4aa15ec388 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Wed, 12 Feb 2025 17:32:54 -0300 Subject: [PATCH 29/58] purge obsolete folder structure --- cls/Forgery/Agent/ApplicationInfo.cls | 13 - cls/Forgery/Agent/Core.cls | 197 -------------- cls/Forgery/OutputCapturer.cls | 56 ---- cls/Forgery/Request.cls | 360 -------------------------- 4 files changed, 626 deletions(-) delete mode 100644 cls/Forgery/Agent/ApplicationInfo.cls delete mode 100644 cls/Forgery/Agent/Core.cls delete mode 100644 cls/Forgery/OutputCapturer.cls delete mode 100644 cls/Forgery/Request.cls 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/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/cls/Forgery/Request.cls b/cls/Forgery/Request.cls deleted file mode 100644 index 31488bd..0000000 --- a/cls/Forgery/Request.cls +++ /dev/null @@ -1,360 +0,0 @@ -Class Forgery.Request Extends %RegisteredObject -{ - -Property URL As %String; - -Property Method As %String; - -Property Application As %String; - -Property Content As %CSP.Stream; - -Property Cookies As %String [ MultiDimensional ]; - -Property MimeData As %String [ MultiDimensional ]; - -Property ContentType As %String; - -Property ContentLength As %String; - -Property Authorization As %String; - -Property Protocol As %String [ InitialExpression = "HTTP/1.1" ]; - -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 - set i%Authorization = value - return $$$OK -} - -Method ContentTypeSet(value As %String) As %Status [ Private ] -{ - set i%CgiEnvs("HTTP_CONTENT_TYPE") = value - set i%ContentType = value - return $$$OK -} - -Method ContentLengthSet(value As %String) As %Status [ Private ] -{ - set i%CgiEvs("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. - -/// Retrieves the named cookie -Method GetCookie(name As %String, default As %String = "", index As %Integer = 1) As %String [ CodeMode = expression, Final ] -{ -$get(i%Cookies(name,index),default) -} - -/// Inserts a cookie name/value pair. -Method InsertCookie(name As %String, value As %String) [ Final, Internal ] -{ - If name="" Quit $$$OK - do ..SetCgiEnv("HTTP_"_$$$ucase($replace(name, "-", "_")), value) - Set i%Cookies(name,$order(i%Cookies(name,""),-1)+1)=value - Quit -} - -/// Returns true if the named cookie exists in the cookie collection, false otherwise. -Method IsDefinedCookie(name As %String, index As %Integer = 1) As %Boolean [ CodeMode = expression, Final ] -{ -$data(i%Cookies(name,index)) -} - -/// Retrieves the named multipart MIME stream. -Method GetMimeData(name As %String, default As %Stream.Object = "", index As %Integer = 1) As %Stream.Object [ CodeMode = expression, Final ] -{ -$get(i%MimeData(name,index),default) -} - -/// Inserts a multipart MIME stream by name into the collection. -Method InsertMimeData(name As %String, value As %Stream.Object) [ Final, Internal ] -{ - If value="" Quit - Set i%MimeData(name,$order(i%MimeData(name,""),-1)+1)=value - Quit -} - -/// Returns true if the named multipart MIME stream exists in the collection, false otherwise. -Method IsDefinedMimeData(name As %String, index As %Integer = 1) As %Boolean [ CodeMode = expression, Final ] -{ -$data(i%MimeData(name,index)) -} - -/// Returns the count of multipart MIME streams with this name. -Method CountMimeData(name As %String) As %Integer [ Final ] -{ - #Dim count,i - - Quit:'$data(i%MimeData(name)) 0 - Set count=0 Set i="" For Set i=$order(i%MimeData(name,i)) Quit:i="" Set count=count+1 - Quit count -} - -/// Retrieves name of the next multipart MIME stream stored in the request object. -Method NextMimeData(name As %String) As %String [ CodeMode = expression, Final ] -{ -$order(i%MimeData(name)) -} - -/// Return the index number of the next multipart MIME stream stored in the request object. -Method NextMimeDataIndex(name As %String, index As %Integer = "") As %String [ CodeMode = expression, Final ] -{ -$order(i%MimeData(name,index)) -} - -/// Removes this multipart MIME stream from the collection. Returns the number -/// of nodes it has removed. If name is not defined then it will -/// remove the entire set of MimeData, if name is defined but index -/// is not then it will remove all items stored under name. -Method DeleteMimeData(name As %String = "", index As %Integer = "") As %Integer [ Final, Internal ] -{ - #Dim defined - If name="" { - Set defined=0 - Set name=$order(i%MimeData("")) - While name'="" { - Set index=$order(i%MimeData(name,"")) - While index'="" { Set defined=defined+1,index=$order(i%MimeData(name,index)) } - Set name=$Order(i%MimeData(name)) - } - Kill i%MimeData - Quit defined - } ElseIf index="" { - Set defined=0 - Set index=$order(i%MimeData(name,"")) - While index'="" { Set defined=defined+1,index=$order(i%MimeData(name,index)) } - Kill i%MimeData(name) - Quit defined - } ElseIf $Data(i%MimeData(name,index)) { - Kill i%MimeData(name,index) - Quit 1 - } - Quit 0 -} - -Method SetCgiEnv(key As %String, value As %String) As %Status -{ - if '$data(i%CgiEnvs(key)) set i%CgiEnvs(key) = value - return $$$OK -} - -/// Inserts a CGI environment variable by name into the collection. -Method InsertCgiEnv(name As %String, value As %String) [ Final, Internal ] -{ - do ..SetCgiEnv(name, value) -} - -/// Retrieves the named CGI environment variable. -Method GetCgiEnv(name As %String, default As %String = "") As %String [ CodeMode = expression, Final ] -{ -$get(i%CgiEnvs(name),default) -} - -/// Returns true if the named CGI environment variable exists in the collection, false otherwise. -Method IsDefinedCgiEnv(name As %String) As %Boolean [ CodeMode = expression, Final ] -{ -$data(i%CgiEnvs(name)) -} - -/// Retrieves the next CGI environment variable name in the sequence -Method NextCgiEnv(name As %String) As %String [ CodeMode = expression, Final ] -{ -$order(i%CgiEnvs(name)) -} - -/// Removes this CGI environment variable from the collection, returns true if the item -/// was defined and false if it was never defined. -Method DeleteCgiEnv(name As %String) As %Boolean [ Final, Internal ] -{ - If $data(i%CgiEnvs(name)) Kill i%CgiEnvs(name) Quit 1 - Quit 0 -} - -Method SetHeader(key As %String, value As %String) As %Status -{ - if $$$lcase(key) = "content-type" set ..ContentType = value - if $$$lcase(key) = "content-length" set ..ContentLength = value - - do ..SetCgiEnv("HTTP_"_$$$ucase($replace(key, "-", "_")), value) - return $$$OK -} - -Method Get(name As %String, default As %String = "", index As %Integer = 1) As %String [ CodeMode = expression, Final ] -{ -$get(i%Data(name,index),default) -} - -Method Set(name As %String, value As %String, index As %Integer = 1) [ Final, Internal ] -{ - If $length(name)>254 Quit - Set i%Data(name,index)=value - QUIT -} - -Method Insert(name As %String, value As %String) [ Final ] -{ - If $length(name)>254 Quit - Set i%Data(name,$order(i%Data(name,""),-1)+1)=value - Quit -} - -Method IsDefined(name As %String, index As %Integer = 1) As %Boolean [ CodeMode = expression, Final ] -{ -$data(i%Data(name,index)) -} - -Method Count(name As %String) As %Integer [ Final ] -{ - #Dim count,i - Quit:'$data(i%Data(name)) 0 - Set count=0 Set i="" For Set i=$order(i%Data(name,i)) Quit:i="" Set count=count+1 - Quit count -} - -Method Find(name As %String, value As %String) As %Integer [ Final ] -{ - #Dim i - Set i=$order(i%Data(name,"")) - While (i'="")&&(i%Data(name,i)'=value) { Set i=$order(i%Data(name,i)) } - Quit i -} - -Method NextIndex(name As %String, ByRef index As %Integer = "") As %String [ Final ] -{ - Set index=$order(i%Data(name,index)) - Quit:index="" "" - Quit i%Data(name,index) -} - -} - From 8d0ed0cded11e9d6e15e54f5dd9310d0549afa40 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 18 Feb 2025 14:54:03 -0300 Subject: [PATCH 30/58] keep methods abstract --- cls/Forgery/Internal/BaseAgent.cls | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cls/Forgery/Internal/BaseAgent.cls b/cls/Forgery/Internal/BaseAgent.cls index f99d8e9..7be4779 100644 --- a/cls/Forgery/Internal/BaseAgent.cls +++ b/cls/Forgery/Internal/BaseAgent.cls @@ -25,32 +25,32 @@ Method %OnNew(configuration As Forgery.Configuration, requestDispatcherFactory A return $$$OK } -Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Post")) } -Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Get")) } -Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Put")) } -Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Delete")) } -Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Head")) } -Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) +Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) [ Abstract ] { $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Patch")) } From 747468c42fa58bfa3f07501c188c7a6da69ec0dc Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 25 Feb 2025 15:25:56 -0300 Subject: [PATCH 31/58] refactoring * Reviewed dispatcher handler interface. * Segregated request manipulation. * Made reuse of configuration in key points. --- cls/Forgery/Agent.cls | 15 ++- cls/Forgery/AgentBuilder.cls | 2 +- cls/Forgery/CSP/AbstractRequestLike.cls | 2 +- cls/Forgery/CSP/Context.cls | 8 +- cls/Forgery/CSP/ContextFactory.cls | 21 ++- cls/Forgery/CSP/Request.cls | 78 +---------- cls/Forgery/IDispatchHandler.cls | 7 +- cls/Forgery/IO/DeviceInterceptor.cls | 18 +-- cls/Forgery/Internal/RequestDispatcher.cls | 34 ++--- .../Internal/RequestDispatcherFactory.cls | 7 +- cls/Forgery/Internal/RequestPreparer.cls | 121 ++++++++++++++++++ tests/ConfigurationMergingTest.cls | 2 +- tests/ConfigurationTest.cls | 8 +- tests/DeviceInterceptionTest.cls | 40 ++++-- tests/RequestDataHandlingTest.cls | 32 +++-- tests/RequestDispatchingTest.cls | 4 +- tests/_setup/WebAppCreator.cls | 25 ++-- .../fakes/SimpleRESTDispatchHandler.cls | 13 +- tests/_setup/fakes/StubbedDispatchHandler.cls | 14 +- 19 files changed, 280 insertions(+), 171 deletions(-) create mode 100644 cls/Forgery/Internal/RequestPreparer.cls diff --git a/cls/Forgery/Agent.cls b/cls/Forgery/Agent.cls index 1037403..ce4057e 100644 --- a/cls/Forgery/Agent.cls +++ b/cls/Forgery/Agent.cls @@ -3,27 +3,32 @@ Class Forgery.Agent Extends (%RegisteredObject, Forgery.Internal.BaseAgent) Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPPOSTMETHOD, data, overrides) + return ..DoRequest(..#HTTPPOSTMETHOD, resource, data, overrides) } Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPGETMETHOD, queryParams, overrides) + return ..DoRequest(..#HTTPGETMETHOD, resource, queryParams, overrides) } Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPPUTMETHOD, data, overrides) + return ..DoRequest(..#HTTPPUTMETHOD, resource, data, overrides) } Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPDELETEMETHOD, queryParams, overrides) + return ..DoRequest(..#HTTPDELETEMETHOD, resource, queryParams, overrides) } Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { - return ..DoRequest(resource, ..#HTTPHEADMETHOD, queryParams, overrides) + return ..DoRequest(..#HTTPHEADMETHOD, resource, queryParams, overrides) +} + +Method SendMultipart(resource, formData As %DynamicArray, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(..#HTTPPOSTMETHOD, resource, formData, { "headers": { "Content-Type": "multipart/form-data" } }) } } diff --git a/cls/Forgery/AgentBuilder.cls b/cls/Forgery/AgentBuilder.cls index 4ca0662..bd1b0f0 100644 --- a/cls/Forgery/AgentBuilder.cls +++ b/cls/Forgery/AgentBuilder.cls @@ -29,7 +29,7 @@ Method WithHeaders(headers As %DynamicObject = {{}}) As Forgery.AgentBuilder Method WithCookies(cookies As %DynamicArray) As %Status { - do ..Configuration.SetRequestDefaultHeaders(cookies) + do ..Configuration.SetRequestDefaultCookies(cookies) return $this } diff --git a/cls/Forgery/CSP/AbstractRequestLike.cls b/cls/Forgery/CSP/AbstractRequestLike.cls index 0938dd1..d3efe34 100644 --- a/cls/Forgery/CSP/AbstractRequestLike.cls +++ b/cls/Forgery/CSP/AbstractRequestLike.cls @@ -41,7 +41,7 @@ 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 } diff --git a/cls/Forgery/CSP/Context.cls b/cls/Forgery/CSP/Context.cls index fbb7ee1..79bb724 100644 --- a/cls/Forgery/CSP/Context.cls +++ b/cls/Forgery/CSP/Context.cls @@ -7,11 +7,11 @@ Property Response As %CSP.Response; Property Session As %CSP.Session; -Method %OnNew(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, appInfo As %DynamicObject, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method %OnNew(session As %CSP.Session, request As Forgery.CSP.Request, response As %CSP.Response) As %Status { - set ..Request = ##class(Forgery.CSP.Request).%New(resource, httpMethod, data, appInfo, overrides) - set ..Response = ##class(%CSP.Response).%New() - set ..Session = ##class(%CSP.Session).%New($System.Encryption.GenCryptToken(), 0) + 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 index d4525f2..867d098 100644 --- a/cls/Forgery/CSP/ContextFactory.cls +++ b/cls/Forgery/CSP/ContextFactory.cls @@ -1,9 +1,26 @@ Class Forgery.CSP.ContextFactory Extends %RegisteredObject { -Method Create(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, appInfo As %DynamicObject, overrides As %DynamicObject = {$$$NULLOREF}) +Property Preparer As Forgery.Internal.RequestPreparer [ Private ]; + +Property BaseUrl As %String [ Private ]; + +Method %OnNew() As %Status { - return ##class(Forgery.CSP.Context).%New(resource, httpMethod, data, appInfo, overrides) + 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, session) } } diff --git a/cls/Forgery/CSP/Request.cls b/cls/Forgery/CSP/Request.cls index b504303..c097a9f 100644 --- a/cls/Forgery/CSP/Request.cls +++ b/cls/Forgery/CSP/Request.cls @@ -1,95 +1,25 @@ Class Forgery.CSP.Request Extends (%RegisteredObject, Forgery.CSP.AbstractRequestLike) { -Method %OnNew(url As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, info As Forgery.Agent.ApplicationInfo, overrides As %DynamicObject = {{}}) As %Status [ Private ] +Method %OnNew(application As %String, resource As %String, httpMethod As %String) As %Status [ Private ] { - set ..URL = url + set ..URL = resource set ..Method = httpMethod - set ..Content = ##class(%CSP.CharacterStream).%New() - set ..Application = info.AppUrl + set ..Application = application do ..LoadDefaultCgiEnvs() - do ..Prepare(data, overrides) return $$$OK } -Method Prepare(data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject) [ Private ] -{ - #define MethodAcceptsPayload $lf($lb("PUT", "POST", "PATCH"), ..Method) - - do ..AppendToRequest("headers", overrides.headers) - do ..AppendToRequest("cookies", overrides.cookies) - - if overrides.%IsDefined("headers") { - if overrides.headers.%IsDefined("Authorization") { - set ..Authorization = overrides.headers.Authorization - } - } - - if ..URL [ "?" { - set queryParts = $replace(..URL, "?", "&") - 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) - } - } - - if $isobject(data) { - if overrides.headers.%Get("Content-Type") [ "multipart/form-data" { - do ..AppendToRequest("mimedata", data) - } elseif data.%IsA("%Stream.Object") { - if data.IsCharacter() set content = ##class(%CSP.CharacterStream).%New() - else set content = ##class(%CSP.BinaryStream).%New() - do content.CopyFrom(data) - } elseif data.%Extends("%DynamicAbstractObject") && $$$MethodAcceptsPayload { - if overrides.headers.%Get("Content-Type") '[ "application/json" { - do ..SetHeader("Content-Type", "application/json; charset=utf-8") - } - set content = ##class(%CSP.CharacterStream).%New() - do data.%ToJSON(.content) - set ..Content = content - } else { - do ..AppendToRequest("queryparams", data) - } - } -} - 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 LoadDefaultCgiEnvs() [ Private ] { - do ##class(%Net.URLParser).Decompose(..URL, .components) - set i%CgiEnvs("REQUEST_METHOD") = $$$ucase(..Method) set i%CgiEnvs("REQUEST_SCHEME") = "http" - set i%CgiEnvs("REQUEST_URI") = components("path") + 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" diff --git a/cls/Forgery/IDispatchHandler.cls b/cls/Forgery/IDispatchHandler.cls index 286d82c..df61efe 100644 --- a/cls/Forgery/IDispatchHandler.cls +++ b/cls/Forgery/IDispatchHandler.cls @@ -1,9 +1,14 @@ Class Forgery.IDispatchHandler [ Abstract ] { -Method Handle(resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status [ Abstract ] +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, context As Forgery.CSP.Context) As %Status [ Abstract ] { $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "Handle")) } +Method OnDispose() As %Status +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "AfterHandle")) +} + } diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls index 9215084..9920d08 100644 --- a/cls/Forgery/IO/DeviceInterceptor.cls +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -26,22 +26,6 @@ Method %OnClose() As %Status return ..EndInterception() } -Method Intercept(dispatchHandler As Forgery.IDispatchHandler, resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status -{ - set status = $$$OK - - $$$QuitOnError(..StartInterception()) - - try { - do dispatchHandler.Handle(resource, httpMethod, restDispatchClass) - } catch ex { - set status = ex.AsStatus() - } - - do ..EndInterception() - return status -} - Method ChangeTranslateTable(charset As %String) [ Private ] { set ..DeviceContent.TranslateTable = ##class(%Net.Charset).GetTranslateTable(charset) @@ -52,7 +36,7 @@ Method ChangeLineTerminator(lineTerminator As %String) [ Private ] set ..DeviceContent.LineTerminator = lineTerminator } -Method StartInterception() As %Status [ Private, ProcedureBlock = 0 ] +Method StartInterception() As %Status [ ProcedureBlock = 0 ] { new status diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index ac69059..5da862a 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -1,6 +1,8 @@ Class Forgery.Internal.RequestDispatcher Extends %RegisteredObject { +Property Configuration As Forgery.Configuration [ Private ]; + Property InitialNamespace As %String [ InitialExpression = {$namespace}, Private ]; Property DispatchHandler As Forgery.IDispatchHandler [ Private ]; @@ -17,54 +19,44 @@ Property ConfigurationMerger As Forgery.Internal.ConfigurationMerger [ Private ] Property CookieJar As Forgery.Internal.CookieJar [ Private ]; -Property RequestDefaultHeaders As %DynamicObject [ InitialExpression = {{}}, Private ]; - -Property RequestDefaultCookies As %DynamicArray [ InitialExpression = {[]}, Private ]; - -Method %OnNew(resolver As Forgery.Internal.WebApplicationResolver, handler As Forgery.IDispatchHandler, interceptor As Forgery.IO.DeviceInterceptor, jar As Forgery.Internal.CookieJar, cspContextFactory As Forgery.CSP.ContextFactory, configurationMerger As Forgery.Internal.ConfigurationMerger, requestDefaultHeaders As %DynamicObject = {{}}, requestDefaultCookies As %DynamicArray = {[]}) As %Status +Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Internal.WebApplicationResolver, handler As Forgery.IDispatchHandler, interceptor As Forgery.IO.DeviceInterceptor, jar As Forgery.Internal.CookieJar, cspContextFactory As Forgery.CSP.ContextFactory, configurationMerger As Forgery.Internal.ConfigurationMerger) As %Status { set ..WebApplicationResolver = resolver set ..DispatchHandler = handler set ..DeviceInterceptor = interceptor set ..CookieJar = jar set ..ContextFactory = cspContextFactory - set ..RequestDefaultHeaders = $select($isobject(requestDefaultHeaders) : requestDefaultHeaders, 1: {}) - set ..RequestDefaultCookies = $select($isobject(requestDefaultCookies) : requestDefaultCookies, 1: []) set ..ConfigurationMerger = configurationMerger return $$$OK } -Method Dispatch(resource As %String, httpMethod As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status { $$$QuitOnError(..WebApplicationResolver.Resolve(resource, .appInfo)) set safeOverrides = $select($isobject(overrides) : overrides, 1: {}) set mergedOverrides = ..ConfigurationMerger.Merge( - ..RequestDefaultHeaders, - ..RequestDefaultCookies, - safeOverrides, - { "headers": {} }, - { "cookies": [] } + safeOverrides, + { "headers": (..Configuration.GetRequestDefaultHeaders()) }, + { "cookies": (..Configuration.GetRequestDefaultCookies()) } ) - set ..LastContext = ..ContextFactory.Create(resource, httpMethod, data, appInfo, mergedOverrides) - - // Must publish these variables because of how %CSP.Page works. - set %request = ..LastContext.Request - set %response = ..LastContext.Response - set %session = ..LastContext.Session + set ..LastContext = ..ContextFactory.Create(appInfo.AppUrl, resource, httpMethod, data, mergedOverrides) set status = $$$OK try { do ..LastContext.Request.PickFromJar(..CookieJar) - $$$ThrowOnError(..DeviceInterceptor.Intercept(..DispatchHandler, resource, httpMethod, appInfo.DispatchClass)) + do ..DeviceInterceptor.StartInterception() + $$$ThrowOnError(..DispatchHandler.OnDispatch(resource, httpMethod, appInfo.DispatchClass, ..LastContext)) do ..CookieJar.PutCookiesFromResponse(..LastContext.Response) } catch ex { set status = ex.AsStatus() } - kill %request, %response, %session + $$$QuitOnError(..DispatchHandler.OnDispose()) + + do ..DeviceInterceptor.EndInterception() // Ensure we are back to the original namespace after the dispatch. set $namespace = ..InitialNamespace diff --git a/cls/Forgery/Internal/RequestDispatcherFactory.cls b/cls/Forgery/Internal/RequestDispatcherFactory.cls index dd73f62..6b94655 100644 --- a/cls/Forgery/Internal/RequestDispatcherFactory.cls +++ b/cls/Forgery/Internal/RequestDispatcherFactory.cls @@ -9,18 +9,15 @@ ClassMethod CreateUsingConfiguration(configuration As Forgery.Configuration) As set jar = ##class(Forgery.Internal.CookieJar).%New() set cspContextFactory = ##class(Forgery.CSP.ContextFactory).%New() set merger = ##class(Forgery.Internal.ConfigurationMerger).%New() - set requestDefaultHeaders = configuration.GetRequestDefaultHeaders() - set requestDefaultCookies = configuration.GetRequestDefaultCookies() return ##class(Forgery.Internal.RequestDispatcher).%New( + configuration, resolver, handler, interceptor, jar, cspContextFactory, - merger, - requestDefaultHeaders, - requestDefaultCookies + merger ) } diff --git a/cls/Forgery/Internal/RequestPreparer.cls b/cls/Forgery/Internal/RequestPreparer.cls new file mode 100644 index 0000000..a776372 --- /dev/null +++ b/cls/Forgery/Internal/RequestPreparer.cls @@ -0,0 +1,121 @@ +Class Forgery.Internal.RequestPreparer Extends %RegisteredObject +{ + +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 ..ConsumeQueryParameters(request) + 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 ConsumeQueryParameters(request As Forgery.CSP.Request) [ Private ] +{ + if request.URL '[ "?" return + + set queryParts = $replace(request.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) + } +} + +Method ConsumeData(request As Forgery.CSP.Request, data As %Any, overrides As %DynamicObject) As %Status [ Private ] +{ + #define MethodAcceptsPayload $lf($lb("PUT", "POST", "PATCH"), request.Method) + + if '$isobject(data) { + return $$$OK + } + + if overrides.headers.%Get("Content-Type") [ "multipart/form-data" { + do ..AppendToRequest(request, "mimedata", data) + } elseif data.%IsA("%Stream.Object") { + set fileExtension = $piece(data.Filename, ".", *) + 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 +} + +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 = "") [ 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") { + do ..AppendToRequest(request, settingName, val, key) + } + } elseif parentKey '= "" { + set appendToKeyName = parentKey + } + if settingName = "headers" { do request.SetHeader(appendToKeyName, val) } + elseif settingName = "cookies" { do request.InsertCookie(appendToKeyName, val) } + elseif settingName = "mimedata" { do request.InsertMimeData(appendToKeyName, val) } + elseif settingName = "queryparams" { do request.Insert(appendToKeyName, val) } + } +} + +} diff --git a/tests/ConfigurationMergingTest.cls b/tests/ConfigurationMergingTest.cls index 1415a81..70b8ad2 100644 --- a/tests/ConfigurationMergingTest.cls +++ b/tests/ConfigurationMergingTest.cls @@ -40,7 +40,7 @@ Method TestDontOverwriteKeys() do $$$AssertEquals(result.a, "b") } -Method TestDoMergeNestedObjects() +Method TestMergeNestedObjects() { set source1 = { "a": { "b": "c" } } set source2 = { "a": { "b": "c", "d": { "e": 1 } }} diff --git a/tests/ConfigurationTest.cls b/tests/ConfigurationTest.cls index 23a85cc..8f6c9b2 100644 --- a/tests/ConfigurationTest.cls +++ b/tests/ConfigurationTest.cls @@ -18,7 +18,7 @@ Method TestCanSetAndGetBaseUrl() Method TestSetAndGetDispatchHandler() { - set expectedDispatcher = ##class(Test.Forgery.FakeRouteDispatcher).%New("") + set expectedDispatcher = ##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("") set config = ##class(Forgery.Configuration).%New() do $$$AssertStatusOK(config.SetDispatchHandler(expectedDispatcher)) @@ -65,7 +65,7 @@ Method TestValidateInvalidHeaders() { set config = ##class(Forgery.Configuration).%New() do config.SetBaseURL("/whatever") - do config.SetDispatchHandler(##class(Test.Forgery.FakeRouteDispatcher).%New("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) @@ -75,10 +75,10 @@ Method TestValidateInvalidCookies() { set config = ##class(Forgery.Configuration).%New() do config.SetBaseURL("/whatever") - do config.SetDispatchHandler(##class(Test.Forgery.FakeRouteDispatcher).%New("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 %DynamicArrayObject if provided."), 1) + 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/DeviceInterceptionTest.cls b/tests/DeviceInterceptionTest.cls index 8c9d1df..f29fb80 100644 --- a/tests/DeviceInterceptionTest.cls +++ b/tests/DeviceInterceptionTest.cls @@ -1,32 +1,50 @@ Class Test.Forgery.DeviceInterception Extends %UnitTest.TestCase { -Method OnAfterAllTests() As %Status +Property Context As %DynamicObject [ Private ]; + +Method OnBeforeOneTest() As %Status { - do ##class(%Device).ReDirectIO(1) + set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL + set ..Context = { + "Request": (##class(Forgery.CSP.Request).%New(baseUrl, "GET",, { "AppUrl": (baseUrl) })), + "Response": (##class(%CSP.Response).%New()) + } + return $$$OK } -Method TestInterceptRouteDispatcherWrites() +Method TestInterceptionCycle() { + set expectedContent = "intercepted messsage" + set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New() - set dispatcher = ##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("reply with this") - do $$$AssertStatusOK(interceptor.Intercept(dispatcher, "", "", "")) - do $$$AssertEquals(interceptor.GetInterceptedContent().Read(), "reply with this") + 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 dispatcher = ##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("reply with this") - set publicVarPreExistsPostConstrutor = $data(%forgeryDeviceInterceptedStream) - do $$$AssertStatusOK(interceptor.Intercept(dispatcher, "", "", "")) - set publicVarPostExistsPostCall = $data(%forgeryDeviceInterceptedStream) + + do interceptor.StartInterception() + set publicVarExistsPostStartedInterception = $data(%forgeryDeviceInterceptedStream) + + set interceptor = "" // calls EndInterception on %OnClose. + + set publicVarExistsPostEndedInterception = $data(%forgeryDeviceInterceptedStream) do $$$AssertNotTrue(publicVarPreExistsPostConstrutor) - do $$$AssertNotTrue(publicVarPostExistsPostCall) + do $$$AssertTrue(publicVarExistsPostStartedInterception) + do $$$AssertNotTrue(publicVarExistsPostEndedInterception) } Method TestCanChangeCharset() diff --git a/tests/RequestDataHandlingTest.cls b/tests/RequestDataHandlingTest.cls index 475b839..86e1aa0 100644 --- a/tests/RequestDataHandlingTest.cls +++ b/tests/RequestDataHandlingTest.cls @@ -1,18 +1,21 @@ -Class Test.Forgery.RequestDispatchingTest Extends %UnitTest.TestCase +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) } - - return ##class(Forgery.CSP.Request).%New( - baseUrl_"/"_$select($extract(resource) = "/" : $extract(resource, 2, *), 1 : resource), - method, - data, - appInfo, - overrides + + 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() @@ -67,4 +70,17 @@ Method TestConsumePayloadAsMimeDataIfHeaderIsSet() do $$$AssertEquals(request.MimeData("key", 3), "value3") } +Method TestConsumeBinaryPayloadAsData() +{ + set binary = ##class(%Stream.FileBinary).%New() + do binary.LinkToFile("/usr/bin/file") + + set payload = binary + + set request = ..CreateRequestWithDefaults(payload, "POST", "/") + + do $$$AssertTrue(request.Content.%IsA("%CSP.BinaryStream")) + do $$$AssertNotTrue(request.Content.IsCharacter()) +} + } diff --git a/tests/RequestDispatchingTest.cls b/tests/RequestDispatchingTest.cls index 5c18c29..7ea394f 100644 --- a/tests/RequestDispatchingTest.cls +++ b/tests/RequestDispatchingTest.cls @@ -17,7 +17,7 @@ Method OnBeforeOneTest() As %Status Method TestCanDispatchWithQueryParams() { - do $$$AssertStatusOK(..Dispatcher.Dispatch("/hello?message=testing message", "GET")) + 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") @@ -26,7 +26,7 @@ Method TestCanDispatchWithQueryParams() Method TestCanDispatchWithPayload() { set payload = { "message": "Hello from POST dispatch!" } - do $$$AssertStatusOK(..Dispatcher.Dispatch("/hello", "POST", payload)) + do $$$AssertStatusOK(..Dispatcher.Dispatch("POST", "/hello", payload)) do $$$AssertEquals(..Dispatcher.GetLastReply().Read(), payload.%ToJSON()) do $$$AssertEquals(..Dispatcher.GetLastContext().Request.Method, "POST") diff --git a/tests/_setup/WebAppCreator.cls b/tests/_setup/WebAppCreator.cls index 4629f9d..3ac1732 100644 --- a/tests/_setup/WebAppCreator.cls +++ b/tests/_setup/WebAppCreator.cls @@ -1,24 +1,26 @@ -Class Test.Forgery.Setup.WebAppCreator Extends %Projection.AbstractProjection [ CompileAfter = Test.Forgery.Setup.Parameters ] +Class Test.Forgery.Setup.WebAppCreator Extends %Projection.AbstractProjection { -Projection CreatorProjection As Test.Forgery.Setup.WebAppCreator; +Parameter TESTBASEURL = "/test/forgery/api"; + +Parameter TESTDISPATCHCLASS = "Test.Forgery.Setup.FakeRouter"; + +Projection WebAppProjection As Test.Forgery.Setup.WebAppCreator; ClassMethod CreateProjection() As %Status { - set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL - set dispatchClass = ##class(Test.Forgery.Setup.Parameters).#TESTDISPATCHCLASS new $namespace set $namespace = "%SYS" - if ##class(Security.Applications).%ExistsId(baseUrl) { + if ##class(Security.Applications).%ExistsId(..#TESTBASEURL) { return $$$OK } - set webApp = ##class(Security.Applications).%New(baseUrl) - set webApp.Name = baseUrl - set webApp.DispatchClass = dispatchClass - set webApp.CookiePath = baseUrl + 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 @@ -31,12 +33,11 @@ ClassMethod CreateProjection() As %Status ClassMethod RemoveProjection() As %Status { - set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL - + new $namespace set $namespace = "%SYS" - do ##class(Security.Applications).%DeleteId(baseUrl) + do ##class(Security.Applications).%DeleteId(..#TESTBASEURL) return $$$OK } diff --git a/tests/_setup/fakes/SimpleRESTDispatchHandler.cls b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls index 639dd70..8050ade 100644 --- a/tests/_setup/fakes/SimpleRESTDispatchHandler.cls +++ b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls @@ -1,10 +1,21 @@ Class Test.Forgery.Setup.SimpleRESTDispatchHandler Extends (%RegisteredObject, Forgery.IDispatchHandler) { -Method Handle(resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, cspContext As Forgery.CSP.Context) 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 index ce6bb9f..dd3a9c0 100644 --- a/tests/_setup/fakes/StubbedDispatchHandler.cls +++ b/tests/_setup/fakes/StubbedDispatchHandler.cls @@ -10,8 +10,14 @@ Method %OnNew(message As %String = "") As %Status return $$$OK } -Method Handle(resource As %String, httpMethod As %String, restDispatchClass As %String) As %Status +Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, cspContext As Forgery.CSP.Context) 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 @@ -31,4 +37,10 @@ Method Handle(resource As %String, httpMethod As %String, restDispatchClass As % return $$$OK } +Method OnDispose() As %Status +{ + kill %request, %session, %request + return $$$OK +} + } From 9d888acfde4962ed41454be0706982737baef90c Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 25 Feb 2025 16:06:28 -0300 Subject: [PATCH 32/58] define config defaults --- cls/Forgery/Configuration.cls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls index 14422e9..277d893 100644 --- a/cls/Forgery/Configuration.cls +++ b/cls/Forgery/Configuration.cls @@ -65,7 +65,7 @@ Method SetRequestDefaultHeaders(headers As %DynamicObject) As %Status Method GetRequestDefaultHeaders() As %DynamicObject [ CodeMode = expression ] { -..RequestDefaultHeaders +$select($isobject(..RequestDefaultHeaders) : ..RequestDefaultHeaders, 1: {}) } Method SetRequestDefaultCookies(headers As %DynamicArray) As %Status @@ -76,7 +76,7 @@ Method SetRequestDefaultCookies(headers As %DynamicArray) As %Status Method GetRequestDefaultCookies() As %DynamicObject [ CodeMode = expression ] { -..RequestDefaultCookies +$select($isobject(..RequestDefaultCookies) : ..RequestDefaultCookies, 1: {}) } Method Validate() As %Status From dd6bd5f4a469f18206c14eb055feaf862241ba59 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 25 Feb 2025 16:06:42 -0300 Subject: [PATCH 33/58] remove unused initializer --- tests/DeviceInterceptionTest.cls | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/DeviceInterceptionTest.cls b/tests/DeviceInterceptionTest.cls index f29fb80..906f47b 100644 --- a/tests/DeviceInterceptionTest.cls +++ b/tests/DeviceInterceptionTest.cls @@ -1,19 +1,6 @@ Class Test.Forgery.DeviceInterception Extends %UnitTest.TestCase { -Property Context As %DynamicObject [ Private ]; - -Method OnBeforeOneTest() As %Status -{ - set baseUrl = ##class(Test.Forgery.Setup.Parameters).#TESTBASEURL - set ..Context = { - "Request": (##class(Forgery.CSP.Request).%New(baseUrl, "GET",, { "AppUrl": (baseUrl) })), - "Response": (##class(%CSP.Response).%New()) - } - - return $$$OK -} - Method TestInterceptionCycle() { set expectedContent = "intercepted messsage" From dfc5a01e3edb6bfabe9f0c63d2760f2e1e53db54 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 25 Feb 2025 16:07:39 -0300 Subject: [PATCH 34/58] modify dispatcher to depend on config --- cls/Forgery/Internal/RequestDispatcher.cls | 16 ++++++++-------- .../Internal/RequestDispatcherFactory.cls | 2 -- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index 5da862a..cad5110 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -5,8 +5,6 @@ Property Configuration As Forgery.Configuration [ Private ]; Property InitialNamespace As %String [ InitialExpression = {$namespace}, Private ]; -Property DispatchHandler As Forgery.IDispatchHandler [ Private ]; - Property ContextFactory As Forgery.CSP.ContextFactory [ Private ]; Property LastContext As Forgery.CSP.Context [ Private ]; @@ -19,10 +17,10 @@ Property ConfigurationMerger As Forgery.Internal.ConfigurationMerger [ Private ] Property CookieJar As Forgery.Internal.CookieJar [ Private ]; -Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Internal.WebApplicationResolver, handler As Forgery.IDispatchHandler, interceptor As Forgery.IO.DeviceInterceptor, jar As Forgery.Internal.CookieJar, cspContextFactory As Forgery.CSP.ContextFactory, configurationMerger As Forgery.Internal.ConfigurationMerger) As %Status +Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Internal.WebApplicationResolver, 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 ..WebApplicationResolver = resolver - set ..DispatchHandler = handler set ..DeviceInterceptor = interceptor set ..CookieJar = jar set ..ContextFactory = cspContextFactory @@ -33,28 +31,30 @@ Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Intern 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(..WebApplicationResolver.Resolve(resource, .appInfo)) - set safeOverrides = $select($isobject(overrides) : overrides, 1: {}) set mergedOverrides = ..ConfigurationMerger.Merge( - safeOverrides, + $$$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(resource, httpMethod, appInfo.DispatchClass, ..LastContext)) + $$$ThrowOnError(dispatchHandler.OnDispatch(resource, httpMethod, appInfo.DispatchClass, ..LastContext)) do ..CookieJar.PutCookiesFromResponse(..LastContext.Response) } catch ex { set status = ex.AsStatus() } - $$$QuitOnError(..DispatchHandler.OnDispose()) + $$$QuitOnError(dispatchHandler.OnDispose()) do ..DeviceInterceptor.EndInterception() diff --git a/cls/Forgery/Internal/RequestDispatcherFactory.cls b/cls/Forgery/Internal/RequestDispatcherFactory.cls index 6b94655..c151803 100644 --- a/cls/Forgery/Internal/RequestDispatcherFactory.cls +++ b/cls/Forgery/Internal/RequestDispatcherFactory.cls @@ -5,7 +5,6 @@ ClassMethod CreateUsingConfiguration(configuration As Forgery.Configuration) As { set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New(configuration.GetBaseURL()) set interceptor = ##class(Forgery.IO.DeviceInterceptor).%New(configuration.GetDeviceCharset(), configuration.GetDeviceLineTerminator()) - set handler = configuration.GetDispatchHandler() set jar = ##class(Forgery.Internal.CookieJar).%New() set cspContextFactory = ##class(Forgery.CSP.ContextFactory).%New() set merger = ##class(Forgery.Internal.ConfigurationMerger).%New() @@ -13,7 +12,6 @@ ClassMethod CreateUsingConfiguration(configuration As Forgery.Configuration) As return ##class(Forgery.Internal.RequestDispatcher).%New( configuration, resolver, - handler, interceptor, jar, cspContextFactory, From 86a10b82168c92b7c3b4851a0448621a63288b33 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 25 Feb 2025 16:08:00 -0300 Subject: [PATCH 35/58] fix response misplacement --- cls/Forgery/CSP/ContextFactory.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cls/Forgery/CSP/ContextFactory.cls b/cls/Forgery/CSP/ContextFactory.cls index 867d098..082ec08 100644 --- a/cls/Forgery/CSP/ContextFactory.cls +++ b/cls/Forgery/CSP/ContextFactory.cls @@ -20,7 +20,7 @@ Method Create(application As %String, resource As %String, httpMethod As %String do ..Preparer.Prepare(request, data, overrides) - return ##class(Forgery.CSP.Context).%New(session, request, session) + return ##class(Forgery.CSP.Context).%New(session, request, response) } } From 2e6b857dae8ffc18d9bd73792e5e0c347ba991f1 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 4 Apr 2025 17:03:00 -0300 Subject: [PATCH 36/58] add file test --- tests/RequestDataHandlingTest.cls | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/RequestDataHandlingTest.cls b/tests/RequestDataHandlingTest.cls index 86e1aa0..1e91fc3 100644 --- a/tests/RequestDataHandlingTest.cls +++ b/tests/RequestDataHandlingTest.cls @@ -72,10 +72,8 @@ Method TestConsumePayloadAsMimeDataIfHeaderIsSet() Method TestConsumeBinaryPayloadAsData() { - set binary = ##class(%Stream.FileBinary).%New() - do binary.LinkToFile("/usr/bin/file") - - set payload = binary + set payload = ##class(%Stream.FileBinary).%New() + do payload.LinkToFile("/usr/bin/file") set request = ..CreateRequestWithDefaults(payload, "POST", "/") From 9e8b9ad2be966a0d01ad60c5dd9d616dc3289807 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Fri, 4 Apr 2025 17:03:23 -0300 Subject: [PATCH 37/58] add safecheck to prevent null dispatch classes --- cls/Forgery/Internal/WebApplicationResolver.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cls/Forgery/Internal/WebApplicationResolver.cls b/cls/Forgery/Internal/WebApplicationResolver.cls index 496bb14..58c3de4 100644 --- a/cls/Forgery/Internal/WebApplicationResolver.cls +++ b/cls/Forgery/Internal/WebApplicationResolver.cls @@ -36,7 +36,7 @@ Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status 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 ORDER BY LEN(Name) DESC", prefixedResource) + 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 info.Name = rows.%Get("Name") set info.DispatchClass = rows.%Get("DispatchClass") From 933ebb5a99cb612f7a654242d5fa37482cd89d83 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 18 Aug 2025 15:21:40 -0300 Subject: [PATCH 38/58] distinguish formdata provision --- cls/Forgery/Agent.cls | 5 --- cls/Forgery/FormData.cls | 24 +++++++++++++ cls/Forgery/IAgent.cls | 5 +++ cls/Forgery/IFormData.cls | 17 +++++++++ cls/Forgery/Internal/BaseAgent.cls | 35 +++---------------- .../Internal/FormDataEntryIterator.cls | 26 ++++++++++++++ cls/Forgery/Internal/RequestPreparer.cls | 29 ++++++++++----- tests/FormDataTest.cls | 24 +++++++++++++ tests/RequestDataHandlingTest.cls | 27 ++++++++++---- 9 files changed, 142 insertions(+), 50 deletions(-) create mode 100644 cls/Forgery/FormData.cls create mode 100644 cls/Forgery/IFormData.cls create mode 100644 cls/Forgery/Internal/FormDataEntryIterator.cls create mode 100644 tests/FormDataTest.cls diff --git a/cls/Forgery/Agent.cls b/cls/Forgery/Agent.cls index ce4057e..ab05a25 100644 --- a/cls/Forgery/Agent.cls +++ b/cls/Forgery/Agent.cls @@ -26,9 +26,4 @@ Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, return ..DoRequest(..#HTTPHEADMETHOD, resource, queryParams, overrides) } -Method SendMultipart(resource, formData As %DynamicArray, overrides As %DynamicObject = {$$$NULLOREF}) As %Status -{ - return ..DoRequest(..#HTTPPOSTMETHOD, resource, formData, { "headers": { "Content-Type": "multipart/form-data" } }) -} - } 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 index f6950e0..d031d50 100644 --- a/cls/Forgery/IAgent.cls +++ b/cls/Forgery/IAgent.cls @@ -41,4 +41,9 @@ Method GetLastReply() As %Stream.Object $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "GetLastReply")) } +Method NewFormData() As Forgery.IFormData +{ + $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "NewFormData")) +} + } diff --git a/cls/Forgery/IFormData.cls b/cls/Forgery/IFormData.cls new file mode 100644 index 0000000..04d101f --- /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 object for iterating over every entry. +Method GetEntryIterator() As %Iterator.Array +{ + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "ToPlainObject")) +} + +} diff --git a/cls/Forgery/Internal/BaseAgent.cls b/cls/Forgery/Internal/BaseAgent.cls index 7be4779..00ff5c3 100644 --- a/cls/Forgery/Internal/BaseAgent.cls +++ b/cls/Forgery/Internal/BaseAgent.cls @@ -25,36 +25,6 @@ Method %OnNew(configuration As Forgery.Configuration, requestDispatcherFactory A return $$$OK } -Method Post(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] -{ - $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Post")) -} - -Method Get(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] -{ - $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Get")) -} - -Method Put(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] -{ - $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Put")) -} - -Method Delete(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] -{ - $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Delete")) -} - -Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status [ Abstract ] -{ - $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Head")) -} - -Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) [ Abstract ] -{ - $$$ThrowOnError($$$ERROR($$$MethodNotImplemented, "Patch")) -} - 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) @@ -70,4 +40,9 @@ Method GetLastReply() As %Stream.Object return ..RequestDispatcher.GetLastReply() } +Method NewFormData() As Forgery.IFormData +{ + return ##class(Forgery.FormData).%New() +} + } diff --git a/cls/Forgery/Internal/FormDataEntryIterator.cls b/cls/Forgery/Internal/FormDataEntryIterator.cls new file mode 100644 index 0000000..39a047e --- /dev/null +++ b/cls/Forgery/Internal/FormDataEntryIterator.cls @@ -0,0 +1,26 @@ +Class Forgery.Internal.FormDataEntryIterator Extends %RegisteredObject +{ + +Property Iteratee As %DynamicArray [ Private ]; + +Property Index As %Integer [ InitialExpression = 0, Private ]; + +Method %OnNew(iteratee As %DynamicArray) As %Status +{ + set ..Iteratee = iteratee + return $$$OK +} + +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/RequestPreparer.cls b/cls/Forgery/Internal/RequestPreparer.cls index a776372..84b8fdc 100644 --- a/cls/Forgery/Internal/RequestPreparer.cls +++ b/cls/Forgery/Internal/RequestPreparer.cls @@ -37,7 +37,7 @@ Method ConsumeQueryParameters(request As Forgery.CSP.Request) [ Private ] } } -Method ConsumeData(request As Forgery.CSP.Request, data As %Any, overrides As %DynamicObject) As %Status [ Private ] +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) @@ -45,12 +45,18 @@ Method ConsumeData(request As Forgery.CSP.Request, data As %Any, overrides As %D return $$$OK } - if overrides.headers.%Get("Content-Type") [ "multipart/form-data" { - do ..AppendToRequest(request, "mimedata", data) + if data.%Extends("Forgery.IFormData") { + do ConsumeFormData(data) + do request.SetHeader("Content-Type", "multipart/form-data") } elseif data.%IsA("%Stream.Object") { - set fileExtension = $piece(data.Filename, ".", *) + 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)) @@ -70,6 +76,12 @@ Method ConsumeData(request As Forgery.CSP.Request, data As %Any, overrides As %D } 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 ] @@ -95,7 +107,7 @@ Method CalculateContentLength(stream As %Stream.FileCharacter, type As %String) return "" } -Method AppendToRequest(request As Forgery.CSP.Request, settingName As %String, settingData As %Any, parentKey = "") [ Private ] +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() @@ -105,15 +117,14 @@ Method AppendToRequest(request As Forgery.CSP.Request, settingName As %String, s if $isobject(val) { if val.%IsA("%DynamicObject") { do ..AppendToRequest(request, settingName, val) - } elseif val.%IsA("%DynamicArray") { - do ..AppendToRequest(request, settingName, val, key) + } 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 = "mimedata" { do request.InsertMimeData(appendToKeyName, val) } elseif settingName = "queryparams" { do request.Insert(appendToKeyName, val) } } } diff --git a/tests/FormDataTest.cls b/tests/FormDataTest.cls new file mode 100644 index 0000000..a5afcac --- /dev/null +++ b/tests/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/RequestDataHandlingTest.cls b/tests/RequestDataHandlingTest.cls index 1e91fc3..ff18ccf 100644 --- a/tests/RequestDataHandlingTest.cls +++ b/tests/RequestDataHandlingTest.cls @@ -60,14 +60,29 @@ Method TestConsumeAuthorizationHeaderFromOverrides() do $$$AssertEquals(request.Authorization, "Basic blah blah blah") } -Method TestConsumePayloadAsMimeDataIfHeaderIsSet() +Method TestContentTypeIsSetAccordingToPayload() { - set payload = [{ "key": "value1" }, { "key": "value2" }, { "key": "value3" }] - set request = ..CreateRequestWithDefaults(payload, "POST", "/", { "headers": { "Content-Type": "multipart/form-data" } }) + set payload1 = ##class(Forgery.FormData).%New() + set payload2 = ##class(%Stream.GlobalCharacter).%New() - do $$$AssertEquals(request.MimeData("key", 1), "value1") - do $$$AssertEquals(request.MimeData("key", 2), "value2") - do $$$AssertEquals(request.MimeData("key", 3), "value3") + 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() From 7678eb47c6f6108863c07c3b7d3d5275633560b8 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 18 Aug 2025 15:22:20 -0300 Subject: [PATCH 39/58] fix signature --- cls/Forgery/CSP/Request.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cls/Forgery/CSP/Request.cls b/cls/Forgery/CSP/Request.cls index c097a9f..5863ce7 100644 --- a/cls/Forgery/CSP/Request.cls +++ b/cls/Forgery/CSP/Request.cls @@ -10,7 +10,7 @@ Method %OnNew(application As %String, resource As %String, httpMethod As %String return $$$OK } -Method PickFromJar(jar As Forgery.Agent.CookieJar) +Method PickFromJar(jar As Forgery.Internal.CookieJar) { do jar.PutCookiesInRequest($this) } From fd6d4bba307cdc205c053e22c39c7a2f6f17c56d Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 18 Aug 2025 15:23:09 -0300 Subject: [PATCH 40/58] improve interception shutdown --- cls/Forgery/Internal/RequestDispatcher.cls | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index cad5110..411284d 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -54,9 +54,8 @@ Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbst set status = ex.AsStatus() } - $$$QuitOnError(dispatchHandler.OnDispose()) - do ..DeviceInterceptor.EndInterception() + $$$QuitOnError(dispatchHandler.OnDispose()) // Ensure we are back to the original namespace after the dispatch. set $namespace = ..InitialNamespace From a0ee1418763f8ce982435ee89b63d6132d713017 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 18 Aug 2025 15:34:45 -0300 Subject: [PATCH 41/58] add test action --- .github/workflows/run-tests.yml | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..4ca6b0c --- /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 docker.pkg.github.com/rfns/cache-ci:v0.6.3 + From 0327e43e8d003b8256574853c544a89b0c440f30 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 18 Aug 2025 15:36:27 -0300 Subject: [PATCH 42/58] add helper scripts --- bin/iris-bc.sh | 19 +++++++++++++++++++ bin/strip-atelier-headers.sh | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100755 bin/iris-bc.sh create mode 100755 bin/strip-atelier-headers.sh 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 From 9cdb50f224785b9e17cbac0051b832e8bfb3debb Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Mon, 18 Aug 2025 15:48:30 -0300 Subject: [PATCH 43/58] fix docker registry address --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4ca6b0c..38ea929 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -30,5 +30,5 @@ jobs: docker run --rm -t --name test-ci \ -e TEST_SUITE="tests" \ -e TEST_AUTOLOAD_DIR="_setup" \ - -v $PWD/app:/opt/ci/app docker.pkg.github.com/rfns/cache-ci:v0.6.3 + -v $PWD/app:/opt/ci/app ghcr.io/rfns/iris-ci/iris-ci:v0.6.3 From f8920589bffa26de9f4a38cca4e31ff3433b3935 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 14:32:33 -0300 Subject: [PATCH 44/58] bump iris-ci image version --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 38ea929..ea196ca 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -30,5 +30,5 @@ jobs: 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.3 + -v $PWD/app:/opt/ci/app ghcr.io/rfns/iris-ci/iris-ci:v0.6.4 From 6329ae7d699b69512b38a2652ce3040e367e70b3 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 15:02:34 -0300 Subject: [PATCH 45/58] fix signature and bump iris-ci again --- .github/workflows/run-tests.yml | 2 +- cls/Forgery/IFormData.cls | 2 +- .../_setup/fakes/FrontierDispatchHandler.cls | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tests/_setup/fakes/FrontierDispatchHandler.cls diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ea196ca..c4a55d7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -30,5 +30,5 @@ jobs: 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.4 + -v $PWD/app:/opt/ci/app ghcr.io/rfns/iris-ci/iris-ci:v0.6.5 diff --git a/cls/Forgery/IFormData.cls b/cls/Forgery/IFormData.cls index 04d101f..3b86f5e 100644 --- a/cls/Forgery/IFormData.cls +++ b/cls/Forgery/IFormData.cls @@ -9,7 +9,7 @@ Method Append(key As %String, value As %Any) [ Abstract ] } /// Exposes object for iterating over every entry. -Method GetEntryIterator() As %Iterator.Array +Method GetEntryIterator() As Forgery.Internal.FormDataEntryIterator { $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "ToPlainObject")) } 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 +} + +} From fc5aceff36a457f744631bcf8d76d8d09e5d1922 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 15:48:11 -0300 Subject: [PATCH 46/58] test commit --- cls/Forgery/Internal/CookieJar.cls | 1 + 1 file changed, 1 insertion(+) diff --git a/cls/Forgery/Internal/CookieJar.cls b/cls/Forgery/Internal/CookieJar.cls index 4a50f24..b1fbfb6 100644 --- a/cls/Forgery/Internal/CookieJar.cls +++ b/cls/Forgery/Internal/CookieJar.cls @@ -3,6 +3,7 @@ Class Forgery.Internal.CookieJar Extends %RegisteredObject Property Cookies As %String [ MultiDimensional ]; +/// testing Method PutCookiesFromResponse(response As %CSP.Response) As %Status { set index = "" From fde0c01627f51dc68de660a86e8056110622a713 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 16:21:45 -0300 Subject: [PATCH 47/58] fix invalid type ref --- cls/Forgery/Internal/CookieJar.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cls/Forgery/Internal/CookieJar.cls b/cls/Forgery/Internal/CookieJar.cls index b1fbfb6..cfa55aa 100644 --- a/cls/Forgery/Internal/CookieJar.cls +++ b/cls/Forgery/Internal/CookieJar.cls @@ -22,7 +22,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 = "" From 7fc2598b537a5153e461cfd36e322dec3bc974e3 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 16:49:21 -0300 Subject: [PATCH 48/58] fix wrong return type --- cls/Forgery/AgentBuilder.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cls/Forgery/AgentBuilder.cls b/cls/Forgery/AgentBuilder.cls index bd1b0f0..c86932d 100644 --- a/cls/Forgery/AgentBuilder.cls +++ b/cls/Forgery/AgentBuilder.cls @@ -27,7 +27,7 @@ Method WithHeaders(headers As %DynamicObject = {{}}) As Forgery.AgentBuilder return $this } -Method WithCookies(cookies As %DynamicArray) As %Status +Method WithCookies(cookies As %DynamicArray) As Forgery.AgentBuilder { do ..Configuration.SetRequestDefaultCookies(cookies) return $this From e371a4b0911a76faf6cfd19fc61377243582be7c Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 16:58:27 -0300 Subject: [PATCH 49/58] update action to use recent format --- .github/workflows/xml-asset.yml | 41 ++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 19 deletions(-) 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 + From 4769ae7cab2cbc506adff4fb9e4b40fd3e33314c Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 18:13:12 -0300 Subject: [PATCH 50/58] add interceptor as arg and ready to use dispatcher --- cls/Forgery/CSP/BasicDispatchHandler.cls | 32 ++++++++++++++++++++++ cls/Forgery/IDispatchHandler.cls | 6 ++-- cls/Forgery/Internal/RequestDispatcher.cls | 2 +- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 cls/Forgery/CSP/BasicDispatchHandler.cls diff --git a/cls/Forgery/CSP/BasicDispatchHandler.cls b/cls/Forgery/CSP/BasicDispatchHandler.cls new file mode 100644 index 0000000..92e0170 --- /dev/null +++ b/cls/Forgery/CSP/BasicDispatchHandler.cls @@ -0,0 +1,32 @@ +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 +{ + break + kill %request, %session, %response + return $$$OK +} + +} diff --git a/cls/Forgery/IDispatchHandler.cls b/cls/Forgery/IDispatchHandler.cls index df61efe..22e6629 100644 --- a/cls/Forgery/IDispatchHandler.cls +++ b/cls/Forgery/IDispatchHandler.cls @@ -1,14 +1,14 @@ Class Forgery.IDispatchHandler [ Abstract ] { -Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, context As Forgery.CSP.Context) As %Status [ 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, "Handle")) + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "OnDispatch")) } Method OnDispose() As %Status { - $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "AfterHandle")) + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "OnDispose")) } } diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index 411284d..c11f70a 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -48,7 +48,7 @@ Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbst try { do ..LastContext.Request.PickFromJar(..CookieJar) do ..DeviceInterceptor.StartInterception() - $$$ThrowOnError(dispatchHandler.OnDispatch(resource, httpMethod, appInfo.DispatchClass, ..LastContext)) + $$$ThrowOnError(dispatchHandler.OnDispatch(resource, httpMethod, appInfo.DispatchClass, ..LastContext, ..DeviceInterceptor)) do ..CookieJar.PutCookiesFromResponse(..LastContext.Response) } catch ex { set status = ex.AsStatus() From fa072de9c80c9b16c67a9bf5724402e4465ce0e7 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 18:15:56 -0300 Subject: [PATCH 51/58] semantic segregation between url and web app --- cls/Forgery/AgentBuilder.cls | 4 ++-- cls/Forgery/CSP/Request.cls | 4 ++-- cls/Forgery/Configuration.cls | 12 ++++++------ ...ApplicationResolver.cls => RESTClassResolver.cls} | 12 ++++++------ cls/Forgery/Internal/RequestDispatcherFactory.cls | 2 +- tests/ConfigurationTest.cls | 12 ++++++------ tests/RequestDispatchingTest.cls | 2 +- 7 files changed, 24 insertions(+), 24 deletions(-) rename cls/Forgery/Internal/{WebApplicationResolver.cls => RESTClassResolver.cls} (79%) diff --git a/cls/Forgery/AgentBuilder.cls b/cls/Forgery/AgentBuilder.cls index c86932d..8b6bbf8 100644 --- a/cls/Forgery/AgentBuilder.cls +++ b/cls/Forgery/AgentBuilder.cls @@ -9,9 +9,9 @@ Method %OnNew() As %Status return $$$OK } -Method SetBaseURL(url As %String) As Forgery.AgentBuilder +Method SetWebApplication(webApplication As %String) As Forgery.AgentBuilder { - do ..Configuration.SetBaseURL(url) + do ..Configuration.SetWebApplication(webApplication) return $this } diff --git a/cls/Forgery/CSP/Request.cls b/cls/Forgery/CSP/Request.cls index 5863ce7..1efff6f 100644 --- a/cls/Forgery/CSP/Request.cls +++ b/cls/Forgery/CSP/Request.cls @@ -1,11 +1,11 @@ Class Forgery.CSP.Request Extends (%RegisteredObject, Forgery.CSP.AbstractRequestLike) { -Method %OnNew(application As %String, resource As %String, httpMethod As %String) As %Status [ Private ] +Method %OnNew(webApplication As %String, resource As %String, httpMethod As %String) As %Status [ Private ] { set ..URL = resource set ..Method = httpMethod - set ..Application = application + set ..Application = webApplication do ..LoadDefaultCgiEnvs() return $$$OK } diff --git a/cls/Forgery/Configuration.cls b/cls/Forgery/Configuration.cls index 277d893..99b239b 100644 --- a/cls/Forgery/Configuration.cls +++ b/cls/Forgery/Configuration.cls @@ -1,7 +1,7 @@ Class Forgery.Configuration Extends %RegisteredObject { -Property BaseURL As %String [ Private ]; +Property WebApplication As %String [ Private ]; Property DispatchHandler As Forgery.IDispatchHandler [ Private ]; @@ -13,15 +13,15 @@ Property RequestDefaultHeaders As %DynamicObject [ InitialExpression = {{}}, Pri Property RequestDefaultCookies As %DynamicArray [ InitialExpression = {[]}, Private ]; -Method SetBaseURL(baseURL As %String) As %Status +Method SetWebApplication(webApplication As %String) As %Status { - set ..BaseURL = baseURL + set ..WebApplication = webApplication return $$$OK } -Method GetBaseURL() As %String [ CodeMode = expression ] +Method GetWebApplication() As %String [ CodeMode = expression ] { -..BaseURL +..WebApplication } Method SetDispatchHandler(handler As Forgery.IDispatchHandler) As %Status @@ -83,7 +83,7 @@ Method Validate() As %Status { set status = $$$OK - if ..BaseURL = "" set status = $$$ADDSC(status, $$$ERROR($$$GeneralError, "Base URL is required.")) + 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.")) diff --git a/cls/Forgery/Internal/WebApplicationResolver.cls b/cls/Forgery/Internal/RESTClassResolver.cls similarity index 79% rename from cls/Forgery/Internal/WebApplicationResolver.cls rename to cls/Forgery/Internal/RESTClassResolver.cls index 58c3de4..a06415a 100644 --- a/cls/Forgery/Internal/WebApplicationResolver.cls +++ b/cls/Forgery/Internal/RESTClassResolver.cls @@ -1,13 +1,13 @@ -Class Forgery.Internal.WebApplicationResolver Extends %RegisteredObject +Class Forgery.Internal.RESTClassResolver Extends %RegisteredObject { -Property BaseURL As %String [ Private ]; +Property WebApplication As %String [ Private ]; Property URLMapCache As %DynamicObject [ Private ]; -Method %OnNew(baseUrl As %String) As %Status +Method %OnNew(webApplication As %String) As %Status { - set ..BaseURL = baseUrl + set ..WebApplication = webApplication set ..URLMapCache = {} return $$$OK } @@ -25,7 +25,7 @@ Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status new $namespace set $namespace = "%SYS" - set baseUrl = ..BaseURL + set baseUrl = ..WebApplication if $extract(baseUrl, *) = "/" { set baseUrl = $extract(baseUrl, 1, *-1) @@ -41,7 +41,7 @@ Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status set info.Name = rows.%Get("Name") set info.DispatchClass = rows.%Get("DispatchClass") set info.Path = rows.%Get("Path") - set info.AppUrl = name_$select($extract(name, *) '= "/" : "/", 1: "") + set info.AppUrl = info.Name_$select($extract(info.Name, *) '= "/" : "/", 1: "") } if info.%Size() = 0 { diff --git a/cls/Forgery/Internal/RequestDispatcherFactory.cls b/cls/Forgery/Internal/RequestDispatcherFactory.cls index c151803..0c9c107 100644 --- a/cls/Forgery/Internal/RequestDispatcherFactory.cls +++ b/cls/Forgery/Internal/RequestDispatcherFactory.cls @@ -3,7 +3,7 @@ Class Forgery.Internal.RequestDispatcherFactory Extends %RegisteredObject ClassMethod CreateUsingConfiguration(configuration As Forgery.Configuration) As Forgery.Internal.RequestDispatcher { - set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New(configuration.GetBaseURL()) + 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() diff --git a/tests/ConfigurationTest.cls b/tests/ConfigurationTest.cls index 8f6c9b2..b6b404b 100644 --- a/tests/ConfigurationTest.cls +++ b/tests/ConfigurationTest.cls @@ -4,16 +4,16 @@ Class Test.Forgery.ConfigurationTest Extends (%UnitTest.TestCase, Test.Forgery.E 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: BaseURL configuration is required.", "#5001: Dispatch handler is required."), 1) + 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 TestCanSetAndGetBaseUrl() +Method TestCanSetAndGetWebApplication() { set expectedUrl = "/testing/api" set config = ##class(Forgery.Configuration).%New() - do $$$AssertStatusOK(config.SetBaseURL(expectedUrl)) - do $$$AssertEquals(config.GetBaseURL(), expectedUrl) + do $$$AssertStatusOK(config.SetWebApplication(expectedUrl)) + do $$$AssertEquals(config.GetWebApplication(), expectedUrl) } Method TestSetAndGetDispatchHandler() @@ -64,7 +64,7 @@ Method TestCanSetAndGetDefaultCookies() Method TestValidateInvalidHeaders() { set config = ##class(Forgery.Configuration).%New() - do config.SetBaseURL("/whatever") + do config.SetWebApplication("/whatever") do config.SetDispatchHandler(##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("whatever")) do $$$AssertStatusOK(config.SetRequestDefaultHeaders([])) @@ -74,7 +74,7 @@ Method TestValidateInvalidHeaders() Method TestValidateInvalidCookies() { set config = ##class(Forgery.Configuration).%New() - do config.SetBaseURL("/whatever") + do config.SetWebApplication("/whatever") do config.SetDispatchHandler(##class(Test.Forgery.Setup.StubbedDispatchHandler).%New("whatever")) do $$$AssertStatusOK(config.SetRequestDefaultCookies({})) diff --git a/tests/RequestDispatchingTest.cls b/tests/RequestDispatchingTest.cls index 7ea394f..67a2a77 100644 --- a/tests/RequestDispatchingTest.cls +++ b/tests/RequestDispatchingTest.cls @@ -8,7 +8,7 @@ Property Dispatcher As Forgery.Internal.RequestDispatcher; Method OnBeforeOneTest() As %Status { set ..Configuration = ##class(Forgery.Configuration).%New() - do ..Configuration.SetBaseURL(##class(Test.Forgery.Setup.Parameters).#TESTBASEURL) + 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) From 15873827c2c3c36fe015763a85b24150026b1b29 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 18:16:48 -0300 Subject: [PATCH 52/58] add flush method, missing fixes and tests --- cls/Forgery/IO/DeviceInterceptor.cls | 9 +++++++++ ...ationResolvingTest.cls => RESTClassResolvingTest.cls} | 6 +++--- tests/_setup/fakes/SimpleRESTDispatchHandler.cls | 2 +- tests/_setup/fakes/StubbedDispatchHandler.cls | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) rename tests/{WebApplicationResolvingTest.cls => RESTClassResolvingTest.cls} (72%) diff --git a/cls/Forgery/IO/DeviceInterceptor.cls b/cls/Forgery/IO/DeviceInterceptor.cls index 9920d08..c06b853 100644 --- a/cls/Forgery/IO/DeviceInterceptor.cls +++ b/cls/Forgery/IO/DeviceInterceptor.cls @@ -87,4 +87,13 @@ Method GetInterceptedContent() As %Stream.FileCharacter return ..DeviceContent } +Method Flush() As %Status +{ + if '$data(%forgeryDeviceInterceptedStream) || '$isobject(%forgeryDeviceInterceptedStream) { + return $$$OK + } + + return %forgeryDeviceInterceptedStream.Clear() +} + } diff --git a/tests/WebApplicationResolvingTest.cls b/tests/RESTClassResolvingTest.cls similarity index 72% rename from tests/WebApplicationResolvingTest.cls rename to tests/RESTClassResolvingTest.cls index b72db9d..6d9f9e4 100644 --- a/tests/WebApplicationResolvingTest.cls +++ b/tests/RESTClassResolvingTest.cls @@ -1,4 +1,4 @@ -Class Test.Forgery.WebApplicationResolving Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) +Class Test.Forgery.RESTClassResolvingTest Extends (%UnitTest.TestCase, Test.Forgery.Extensions.AssertiveStatus) { Property BaseURL As %String [ InitialExpression = {##class(Test.Forgery.Setup.Parameters).#TESTBASEURL} ]; @@ -12,7 +12,7 @@ Method WithBaseURL(resource As %String) Method TestResolveAPIThroughUrl() { - set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New(..BaseURL) + set resolver = ##class(Forgery.Internal.RESTClassResolver).%New(..BaseURL) do $$$AssertStatusOK(resolver.Resolve(..WithBaseURL("/hello"), .info)) do $$$AssertEquals(info.DispatchClass, ..DispatchClass) @@ -20,7 +20,7 @@ Method TestResolveAPIThroughUrl() Method TestGetErrorIfWebAppDoesntExist() { - set resolver = ##class(Forgery.Internal.WebApplicationResolver).%New("/invalid/app") + 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/_setup/fakes/SimpleRESTDispatchHandler.cls b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls index 8050ade..05612e7 100644 --- a/tests/_setup/fakes/SimpleRESTDispatchHandler.cls +++ b/tests/_setup/fakes/SimpleRESTDispatchHandler.cls @@ -1,7 +1,7 @@ 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) As %Status +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 diff --git a/tests/_setup/fakes/StubbedDispatchHandler.cls b/tests/_setup/fakes/StubbedDispatchHandler.cls index dd3a9c0..3f2896b 100644 --- a/tests/_setup/fakes/StubbedDispatchHandler.cls +++ b/tests/_setup/fakes/StubbedDispatchHandler.cls @@ -10,7 +10,7 @@ Method %OnNew(message As %String = "") As %Status return $$$OK } -Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass As %String, cspContext As Forgery.CSP.Context) As %Status +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. From baef2f948ec83f056d12d10b7ddf1820eef07525 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Tue, 19 Aug 2025 18:21:31 -0300 Subject: [PATCH 53/58] fix more invalid types --- cls/Forgery/Internal/RequestDispatcher.cls | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index c11f70a..6f7b64e 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -9,7 +9,7 @@ Property ContextFactory As Forgery.CSP.ContextFactory [ Private ]; Property LastContext As Forgery.CSP.Context [ Private ]; -Property WebApplicationResolver As Forgery.Internal.WebApplicationResolver [ Private ]; +Property RESTClassResolver As Forgery.Internal.RESTClassResolver [ Private ]; Property DeviceInterceptor As Forgery.IO.DeviceInterceptor [ Private ]; @@ -17,10 +17,10 @@ Property ConfigurationMerger As Forgery.Internal.ConfigurationMerger [ Private ] Property CookieJar As Forgery.Internal.CookieJar [ Private ]; -Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Internal.WebApplicationResolver, interceptor As Forgery.IO.DeviceInterceptor, jar As Forgery.Internal.CookieJar, cspContextFactory As Forgery.CSP.ContextFactory, configurationMerger As Forgery.Internal.ConfigurationMerger) As %Status +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 ..WebApplicationResolver = resolver + set ..RESTClassResolver = resolver set ..DeviceInterceptor = interceptor set ..CookieJar = jar set ..ContextFactory = cspContextFactory @@ -33,7 +33,7 @@ Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbst { #define SafeOverrides $select($isobject(overrides) : overrides, 1: {}) - $$$QuitOnError(..WebApplicationResolver.Resolve(resource, .appInfo)) + $$$QuitOnError(..RESTClassResolver.Resolve(resource, .appInfo)) set mergedOverrides = ..ConfigurationMerger.Merge( $$$SafeOverrides, From da8082119cd3b20f2c6ba4e5875e57cb29da1325 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Thu, 21 Aug 2025 09:45:32 -0300 Subject: [PATCH 54/58] missing patch method and cleanup --- cls/Forgery/Agent.cls | 5 +++++ cls/Forgery/CSP/BasicDispatchHandler.cls | 1 - cls/Forgery/CSP/ContextFactory.cls | 2 -- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cls/Forgery/Agent.cls b/cls/Forgery/Agent.cls index ab05a25..a8d4930 100644 --- a/cls/Forgery/Agent.cls +++ b/cls/Forgery/Agent.cls @@ -26,4 +26,9 @@ Method Head(resource As %String, queryParams As %DynamicObject = {$$$NULLOREF}, return ..DoRequest(..#HTTPHEADMETHOD, resource, queryParams, overrides) } +Method Patch(resource As %String, data As %DynamicAbstractObject = {$$$NULLOREF}, overrides As %DynamicObject = {$$$NULLOREF}) As %Status +{ + return ..DoRequest(..#HTTPPATCHMETHOD, resource, data, overrides) +} + } diff --git a/cls/Forgery/CSP/BasicDispatchHandler.cls b/cls/Forgery/CSP/BasicDispatchHandler.cls index 92e0170..366eba3 100644 --- a/cls/Forgery/CSP/BasicDispatchHandler.cls +++ b/cls/Forgery/CSP/BasicDispatchHandler.cls @@ -24,7 +24,6 @@ Method OnDispatch(resource As %String, httpMethod As %String, restDispatchClass Method OnDispose() As %Status { - break kill %request, %session, %response return $$$OK } diff --git a/cls/Forgery/CSP/ContextFactory.cls b/cls/Forgery/CSP/ContextFactory.cls index 082ec08..87fbb65 100644 --- a/cls/Forgery/CSP/ContextFactory.cls +++ b/cls/Forgery/CSP/ContextFactory.cls @@ -3,8 +3,6 @@ Class Forgery.CSP.ContextFactory Extends %RegisteredObject Property Preparer As Forgery.Internal.RequestPreparer [ Private ]; -Property BaseUrl As %String [ Private ]; - Method %OnNew() As %Status { set ..Preparer = ##class(Forgery.Internal.RequestPreparer).%New() From 08391ac7d76178782fdaed3f4b87ca70dea8335b Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Thu, 21 Aug 2025 09:46:50 -0300 Subject: [PATCH 55/58] improve handling of url paths --- cls/Forgery/CSP/Request.cls | 34 ++++++++++++++++++++-- cls/Forgery/Internal/RequestDispatcher.cls | 2 +- cls/Forgery/Internal/RequestPreparer.cls | 16 +--------- tests/RequestDataHandlingTest.cls | 3 +- tests/_setup/Parameters.cls | 2 +- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/cls/Forgery/CSP/Request.cls b/cls/Forgery/CSP/Request.cls index 1efff6f..6e63c3f 100644 --- a/cls/Forgery/CSP/Request.cls +++ b/cls/Forgery/CSP/Request.cls @@ -1,15 +1,45 @@ Class Forgery.CSP.Request Extends (%RegisteredObject, Forgery.CSP.AbstractRequestLike) { -Method %OnNew(webApplication As %String, resource As %String, httpMethod As %String) As %Status [ Private ] +Property Resource As %String [ ReadOnly ]; + +Method %OnNew(webApplication As %String, unformattedResource As %String, httpMethod As %String) As %Status [ Private ] { - set ..URL = resource 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) diff --git a/cls/Forgery/Internal/RequestDispatcher.cls b/cls/Forgery/Internal/RequestDispatcher.cls index 6f7b64e..b9b836b 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -48,7 +48,7 @@ Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbst try { do ..LastContext.Request.PickFromJar(..CookieJar) do ..DeviceInterceptor.StartInterception() - $$$ThrowOnError(dispatchHandler.OnDispatch(resource, httpMethod, appInfo.DispatchClass, ..LastContext, ..DeviceInterceptor)) + $$$ThrowOnError(dispatchHandler.OnDispatch(..LastContext.Request.Resource, httpMethod, appInfo.DispatchClass, ..LastContext, ..DeviceInterceptor)) do ..CookieJar.PutCookiesFromResponse(..LastContext.Response) } catch ex { set status = ex.AsStatus() diff --git a/cls/Forgery/Internal/RequestPreparer.cls b/cls/Forgery/Internal/RequestPreparer.cls index 84b8fdc..1836bcc 100644 --- a/cls/Forgery/Internal/RequestPreparer.cls +++ b/cls/Forgery/Internal/RequestPreparer.cls @@ -7,7 +7,6 @@ Method Prepare(request As Forgery.CSP.Request, data As %DynamicAbstractObject = do ..AppendToRequest(request, "headers", overrides.headers) do ..AppendToRequest(request, "cookies", overrides.cookies) - do ..ConsumeQueryParameters(request) do ..ConsumeAuthorizationHeader(request, overrides) $$$QuitOnError(..ConsumeData(request, data, overrides)) @@ -24,23 +23,10 @@ Method ConsumeAuthorizationHeader(request As Forgery.CSP.Request, overrides As % } } -Method ConsumeQueryParameters(request As Forgery.CSP.Request) [ Private ] -{ - if request.URL '[ "?" return - - set queryParts = $replace(request.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) - } -} - 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 } diff --git a/tests/RequestDataHandlingTest.cls b/tests/RequestDataHandlingTest.cls index ff18ccf..6ec7901 100644 --- a/tests/RequestDataHandlingTest.cls +++ b/tests/RequestDataHandlingTest.cls @@ -29,11 +29,12 @@ Method TestTransformDynamicObjectToContentStream() Method TestConsumeQueryParametersFromUrl() { - set request = ..CreateRequestWithDefaults("", "GET", "?q1=consume&q2=this&q3=message") + 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() diff --git a/tests/_setup/Parameters.cls b/tests/_setup/Parameters.cls index 742baf8..f2844b8 100644 --- a/tests/_setup/Parameters.cls +++ b/tests/_setup/Parameters.cls @@ -1,7 +1,7 @@ Class Test.Forgery.Setup.Parameters { -Parameter TESTBASEURL = "/test/forgery/api"; +Parameter TESTBASEURL = "/test/forgery/api/"; Parameter TESTDISPATCHCLASS = "Test.Forgery.Setup.FakeRouter"; From 24448aa27ab5610981e26d785d303dd2e6d37232 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Thu, 21 Aug 2025 15:59:39 -0300 Subject: [PATCH 56/58] fix interface detail --- cls/Forgery/IFormData.cls | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cls/Forgery/IFormData.cls b/cls/Forgery/IFormData.cls index 3b86f5e..d21aa6e 100644 --- a/cls/Forgery/IFormData.cls +++ b/cls/Forgery/IFormData.cls @@ -8,10 +8,10 @@ Method Append(key As %String, value As %Any) [ Abstract ] $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "Append")) } -/// Exposes object for iterating over every entry. -Method GetEntryIterator() As Forgery.Internal.FormDataEntryIterator +/// Exposes an object for iterating over every entry. +Method GetEntryIterator() As Forgery.Internal.FormDataEntryIterator [ Abstract ] { - $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "ToPlainObject")) + $$$ThrowStatus($$$ERROR($$$MethodNotImplemented, "GetEntryIterator")) } } From dc13c6c7b6170ca826ee0fdcb8b5f0cc0cc8ba83 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Thu, 21 Aug 2025 16:01:04 -0300 Subject: [PATCH 57/58] move test classes to a more appropriate folder --- tests/{ => unit}/ConfigurationMergingTest.cls | 0 tests/{ => unit}/ConfigurationTest.cls | 0 tests/{ => unit}/DeviceInterceptionTest.cls | 0 tests/{ => unit}/FormDataTest.cls | 0 tests/{ => unit}/RESTClassResolvingTest.cls | 0 tests/{ => unit}/RequestDataHandlingTest.cls | 0 tests/{ => unit}/RequestDispatchingTest.cls | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => unit}/ConfigurationMergingTest.cls (100%) rename tests/{ => unit}/ConfigurationTest.cls (100%) rename tests/{ => unit}/DeviceInterceptionTest.cls (100%) rename tests/{ => unit}/FormDataTest.cls (100%) rename tests/{ => unit}/RESTClassResolvingTest.cls (100%) rename tests/{ => unit}/RequestDataHandlingTest.cls (100%) rename tests/{ => unit}/RequestDispatchingTest.cls (100%) diff --git a/tests/ConfigurationMergingTest.cls b/tests/unit/ConfigurationMergingTest.cls similarity index 100% rename from tests/ConfigurationMergingTest.cls rename to tests/unit/ConfigurationMergingTest.cls diff --git a/tests/ConfigurationTest.cls b/tests/unit/ConfigurationTest.cls similarity index 100% rename from tests/ConfigurationTest.cls rename to tests/unit/ConfigurationTest.cls diff --git a/tests/DeviceInterceptionTest.cls b/tests/unit/DeviceInterceptionTest.cls similarity index 100% rename from tests/DeviceInterceptionTest.cls rename to tests/unit/DeviceInterceptionTest.cls diff --git a/tests/FormDataTest.cls b/tests/unit/FormDataTest.cls similarity index 100% rename from tests/FormDataTest.cls rename to tests/unit/FormDataTest.cls diff --git a/tests/RESTClassResolvingTest.cls b/tests/unit/RESTClassResolvingTest.cls similarity index 100% rename from tests/RESTClassResolvingTest.cls rename to tests/unit/RESTClassResolvingTest.cls diff --git a/tests/RequestDataHandlingTest.cls b/tests/unit/RequestDataHandlingTest.cls similarity index 100% rename from tests/RequestDataHandlingTest.cls rename to tests/unit/RequestDataHandlingTest.cls diff --git a/tests/RequestDispatchingTest.cls b/tests/unit/RequestDispatchingTest.cls similarity index 100% rename from tests/RequestDispatchingTest.cls rename to tests/unit/RequestDispatchingTest.cls From 3535bda123e7ec39eae501d13c5001eee2abf708 Mon Sep 17 00:00:00 2001 From: "Rubens F. N. da Silva" Date: Thu, 21 Aug 2025 16:01:40 -0300 Subject: [PATCH 58/58] add class member documentation --- cls/Forgery/Agent.cls | 10 ++++++ cls/Forgery/IAgent.cls | 15 +++++++++ cls/Forgery/Internal/BaseAgent.cls | 15 +++++++++ cls/Forgery/Internal/ConfigurationMerger.cls | 5 +++ cls/Forgery/Internal/CookieJar.cls | 7 ++++- .../Internal/FormDataEntryIterator.cls | 5 +++ cls/Forgery/Internal/RESTClassResolver.cls | 31 +++++++++---------- cls/Forgery/Internal/RequestDispatcher.cls | 16 ++++++++++ .../Internal/RequestDispatcherFactory.cls | 2 ++ cls/Forgery/Internal/RequestPreparer.cls | 2 ++ 10 files changed, 91 insertions(+), 17 deletions(-) diff --git a/cls/Forgery/Agent.cls b/cls/Forgery/Agent.cls index a8d4930..abdf9a5 100644 --- a/cls/Forgery/Agent.cls +++ b/cls/Forgery/Agent.cls @@ -1,31 +1,41 @@ +/// 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) { +/// 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 { return ..DoRequest(..#HTTPPOSTMETHOD, resource, data, overrides) } +/// 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 { return ..DoRequest(..#HTTPGETMETHOD, resource, queryParams, overrides) } +/// 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 { return ..DoRequest(..#HTTPPUTMETHOD, resource, data, overrides) } +/// 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 { return ..DoRequest(..#HTTPDELETEMETHOD, resource, queryParams, overrides) } +/// 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 { return ..DoRequest(..#HTTPHEADMETHOD, resource, queryParams, overrides) } +/// 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 { return ..DoRequest(..#HTTPPATCHMETHOD, resource, data, overrides) diff --git a/cls/Forgery/IAgent.cls b/cls/Forgery/IAgent.cls index d031d50..b2701d6 100644 --- a/cls/Forgery/IAgent.cls +++ b/cls/Forgery/IAgent.cls @@ -1,46 +1,61 @@ 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/Internal/BaseAgent.cls b/cls/Forgery/Internal/BaseAgent.cls index 00ff5c3..0ca0158 100644 --- a/cls/Forgery/Internal/BaseAgent.cls +++ b/cls/Forgery/Internal/BaseAgent.cls @@ -1,3 +1,6 @@ +/// Base class for creating agents. +/// +/// It also provides methods that can be used to quickstart requests. Class Forgery.Internal.BaseAgent Extends Forgery.IAgent { @@ -15,8 +18,10 @@ 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 @@ -25,21 +30,31 @@ Method %OnNew(configuration As Forgery.Configuration, requestDispatcherFactory A 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 index e13194c..a9f76c7 100644 --- a/cls/Forgery/Internal/ConfigurationMerger.cls +++ b/cls/Forgery/Internal/ConfigurationMerger.cls @@ -1,6 +1,11 @@ +/// 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 diff --git a/cls/Forgery/Internal/CookieJar.cls b/cls/Forgery/Internal/CookieJar.cls index cfa55aa..0aa329c 100644 --- a/cls/Forgery/Internal/CookieJar.cls +++ b/cls/Forgery/Internal/CookieJar.cls @@ -1,9 +1,14 @@ +/// 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 ]; -/// testing +/// Puts cookies from a response object into a request. Method PutCookiesFromResponse(response As %CSP.Response) As %Status { set index = "" diff --git a/cls/Forgery/Internal/FormDataEntryIterator.cls b/cls/Forgery/Internal/FormDataEntryIterator.cls index 39a047e..1cde956 100644 --- a/cls/Forgery/Internal/FormDataEntryIterator.cls +++ b/cls/Forgery/Internal/FormDataEntryIterator.cls @@ -1,8 +1,11 @@ +/// 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 @@ -11,6 +14,8 @@ Method %OnNew(iteratee As %DynamicArray) As %Status 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) { diff --git a/cls/Forgery/Internal/RESTClassResolver.cls b/cls/Forgery/Internal/RESTClassResolver.cls index a06415a..9d60009 100644 --- a/cls/Forgery/Internal/RESTClassResolver.cls +++ b/cls/Forgery/Internal/RESTClassResolver.cls @@ -1,27 +1,26 @@ +/// Utility class for REST dispatch class discovery. Class Forgery.Internal.RESTClassResolver Extends %RegisteredObject { +/// The provided web application name. Property WebApplication As %String [ Private ]; -Property URLMapCache As %DynamicObject [ Private ]; +Property WebApplicationInfoCache As %DynamicObject [ Private ]; -Method %OnNew(webApplication As %String) As %Status +Method %OnNew(webApplication As %String, cache As %DynamicObject = {{}}) As %Status { set ..WebApplication = webApplication - set ..URLMapCache = {} + set ..WebApplicationInfoCache = cache + return $$$OK } Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status { - if ..URLMapCache.%IsDefined(url) { - set info = ..URLMapCache.%Get(url) - return $$$OK + if ..WebApplicationInfoCache.%Size() '= 0 { + return ..WebApplicationInfoCache } - do ..URLMapCache.%Set(url, {}) - set info = ..URLMapCache.%Get(url) - new $namespace set $namespace = "%SYS" @@ -31,23 +30,23 @@ Method Resolve(url As %String, Output info As %DynamicObject = "") As %Status set baseUrl = $extract(baseUrl, 1, *-1) } - set result = {} - set name = "" 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 info.Name = rows.%Get("Name") - set info.DispatchClass = rows.%Get("DispatchClass") - set info.Path = rows.%Get("Path") - set info.AppUrl = info.Name_$select($extract(info.Name, *) '= "/" : "/", 1: "") + 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 info.%Size() = 0 { + 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 index b9b836b..97a42cd 100644 --- a/cls/Forgery/Internal/RequestDispatcher.cls +++ b/cls/Forgery/Internal/RequestDispatcher.cls @@ -1,20 +1,31 @@ +/// 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 @@ -29,6 +40,9 @@ Method %OnNew(configuration As Forgery.Configuration, resolver As Forgery.Intern 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: {}) @@ -62,11 +76,13 @@ Method Dispatch(httpMethod As %String, resource As %String, data As %DynamicAbst 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 index 0c9c107..9bb4e0b 100644 --- a/cls/Forgery/Internal/RequestDispatcherFactory.cls +++ b/cls/Forgery/Internal/RequestDispatcherFactory.cls @@ -1,6 +1,8 @@ +/// 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()) diff --git a/cls/Forgery/Internal/RequestPreparer.cls b/cls/Forgery/Internal/RequestPreparer.cls index 1836bcc..a1dbe22 100644 --- a/cls/Forgery/Internal/RequestPreparer.cls +++ b/cls/Forgery/Internal/RequestPreparer.cls @@ -1,6 +1,8 @@ +/// 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) {