├── .github
└── workflows
│ ├── publish-release.yml
│ └── run-tests.yml
├── .gitignore
├── LICENSE.txt
├── README.md
├── build.sbt
├── project
├── build.properties
└── plugins.sbt
├── repo.sbt
├── src
├── main
│ ├── resources
│ │ └── reference.conf
│ └── scala
│ │ └── com
│ │ └── greenfossil
│ │ └── thorium
│ │ ├── AESUtil.scala
│ │ ├── AnnotatedActionMacroSupport.scala
│ │ ├── AppException.scala
│ │ ├── Configuration.scala
│ │ ├── Conversions.scala
│ │ ├── CookieUtil.scala
│ │ ├── Endpoint.scala
│ │ ├── EndpointMcr.scala
│ │ ├── Environment.scala
│ │ ├── EssentialAction.scala
│ │ ├── Extensions.scala
│ │ ├── Flash.scala
│ │ ├── FormUrlEncoded.scala
│ │ ├── FormUrlEncodedParser.scala
│ │ ├── HMACUtil.scala
│ │ ├── HttpConfiguration.scala
│ │ ├── HttpResponseConverter.scala
│ │ ├── MacroSupport.scala
│ │ ├── MultipartFormData.scala
│ │ ├── MultipartRequest.scala
│ │ ├── Recaptcha.scala
│ │ ├── Request.scala
│ │ ├── RequestAttrs.scala
│ │ ├── ResponseHeader.scala
│ │ ├── Result.scala
│ │ ├── Resultable.scala
│ │ ├── Server.scala
│ │ ├── Session.scala
│ │ └── decorators
│ │ ├── CSRFGuardModule.scala
│ │ ├── FirstResponderDecoratingFunction.scala
│ │ ├── RecaptchaGuardModule.scala
│ │ ├── ThreatGuardModule.scala
│ │ └── ThreatGuardModuleDecoratingFunction.scala
└── test
│ ├── resources
│ ├── application.conf
│ ├── favicon.png
│ ├── image-no-ext
│ └── logback-test.xml
│ └── scala
│ └── com
│ └── greenfossil
│ └── thorium
│ ├── AESUtilSuite.scala
│ ├── AccessLoggerSuite.scala
│ ├── AddAnnotatedServiceSuite.scala
│ ├── AddHttpServiceSuite.scala
│ ├── AddRouteSuite.scala
│ ├── CSRFGuardModule_API_Suite.scala
│ ├── CSRFGuardModule_Post_FormData_Fail_Suite.scala
│ ├── CSRFGuardModule_Post_FormData_Pass_Suite.scala
│ ├── CSRFGuardModule_Post_Multipart_Fail_Suite.scala
│ ├── CSRFGuardModule_Post_Multipart_Pass_Suite.scala
│ ├── CSRFMainTestService.scala
│ ├── CSRFServices.scala
│ ├── ContextPathSuite.scala
│ ├── DocServiceMain.scala
│ ├── EndpointMacro1Suite.scala
│ ├── EndpointMacro2Suite.scala
│ ├── EndpointMacro3Suite.scala
│ ├── EndpointMacroParameterizedServicesSuite.scala
│ ├── HeadersSuite.scala
│ ├── InheritedAnnotatedPathSuite.scala
│ ├── MultiPartFormSuite.scala
│ ├── MultipartFileDataBindingSuite.scala
│ ├── MultipartFileSuite.scala
│ ├── NestedRedirectMacroSuite.scala
│ ├── RecaptchaFormPage.scala
│ ├── RecaptchaMainTestService.scala
│ ├── RecaptchaPlaywright_Stress_Suite.scala
│ ├── RecaptchaServices.scala
│ ├── RecaptchaSuite.scala
│ ├── Recaptcha_Post_Formdata_Stress_Suite.scala
│ ├── Recaptcha_Post_Multipart_Stress_Suite.scala
│ ├── Recaptcha_Post_Suite.scala
│ ├── RegexEndpointSuite.scala
│ ├── ResponseHeaderSuite.scala
│ ├── SessionSuite.scala
│ ├── ThreatGuardModule_Stress_Suite.scala
│ ├── ThreatGuardServices.scala
│ ├── ThreatGuardTestModule.scala
│ ├── UrlPrefixSuite.scala
│ ├── VirtualHostSuite.scala
│ ├── contextPath
│ └── ContextPathSuite.scala
│ └── macros
│ ├── AnnotatedActionMacroSupportSuite.scala
│ └── AnnotatedActionMacroSupportTestImpl.scala
└── thorium-buildinfo.sbt
/.github/workflows/publish-release.yml:
--------------------------------------------------------------------------------
1 | name: Publish to Maven repository
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | publish:
10 | uses: Greenfossil/.github/.github/workflows/publish-release.yml@main
11 | secrets: inherit
12 |
13 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | branches: [ "main", "scala-3.5.x" ]
6 | pull_request:
7 | branches: [ "main", "scala-3.5.x" ]
8 |
9 | jobs:
10 | build:
11 | uses: Greenfossil/.github/.github/workflows/run-tests.yml@main
12 | secrets: inherit
13 |
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | logs/
3 | target/
4 | .metals
5 | .vscode
6 | .bloop
7 | metals.sbt
8 | .bsp
9 | .lh/
10 | src/test/scala/experiments/
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Thorium framework
2 |
3 | 
4 | 
5 | 
6 | 
7 | [](https://javadoc.io/doc/com.greenfossil/thorium_3)
8 |
9 | Thorium Framework is a modern microservices framework built on top of Armeria, Scala 3 and Java 17.
10 |
11 | For more information, visit [our official website](https://thoriumframework.dev).
12 |
--------------------------------------------------------------------------------
/build.sbt:
--------------------------------------------------------------------------------
1 | val scala3Version = "3.7.0"
2 |
3 | scalacOptions ++= Seq(
4 | // "-feature", "-deprecation", "-Wunused:all" ,
5 | // "-Xprint:postInlining", "-Xmax-inlines:100000",
6 | )
7 |
8 | lazy val thorium = project
9 | .in(file("."))
10 | .settings(
11 | name := "thorium",
12 | organization := "com.greenfossil",
13 | version := "0.10.0-RC1",
14 |
15 | scalaVersion := scala3Version,
16 |
17 | libraryDependencies ++= Seq(
18 | "com.greenfossil" %% "htmltags" % "1.3.0-RC1",
19 | "com.greenfossil" %% "data-mapping" % "1.3.0-RC1",
20 | "com.greenfossil" %% "commons-i18n" % "1.3.0-RC1",
21 | "com.greenfossil" %% "typesafe-config-ext" % "1.3.0-RC3",
22 | "io.projectreactor" % "reactor-core" % "3.7.3",
23 | "com.linecorp.armeria" % "armeria" % "1.32.5",
24 | "com.linecorp.armeria" % "armeria-logback" % "1.32.5",
25 | "org.overviewproject" % "mime-types" % "2.0.0",
26 | "io.github.yskszk63" % "jnhttp-multipartformdata-bodypublisher" % "0.0.1",
27 | "org.slf4j" % "slf4j-api" % "2.0.16",
28 | "com.microsoft.playwright" % "playwright" % "1.51.0" % Test,
29 | "ch.qos.logback" % "logback-classic" % "1.5.16" % Test,
30 | "org.scalameta" %% "munit" % "1.1.1" % Test,
31 | "org.scala-lang" %% "scala3-compiler" % scalaVersion.value % Test
32 |
33 | )
34 | )
35 |
36 | //This is required for testcases that submits header content-length explicitly
37 | javacOptions += "-Djdk.httpclient.allowRestrictedHeaders=content-length"
38 |
39 | //https://www.scala-sbt.org/1.x/docs/Publishing.html
40 | ThisBuild / versionScheme := Some("early-semver")
41 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.11.0-RC2
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1")
--------------------------------------------------------------------------------
/repo.sbt:
--------------------------------------------------------------------------------
1 | ThisBuild / organizationName := "Greenfossil Pte Ltd"
2 | ThisBuild / organizationHomepage := Some(url("https://greenfossil.com/"))
3 |
4 | ThisBuild / scmInfo := Some(
5 | ScmInfo(
6 | url("https://github.com/greenfossil/thorium"),
7 | "scm:git@github.com:greenfossil/thorium.git"
8 | )
9 | )
10 |
11 | ThisBuild / homepage := Some(url("https://github.com/Greenfossil/thorium"))
12 |
13 | ThisBuild / description := "Microservices Framework built on top of Armeria, Java 17 and Scala 3"
14 |
15 | ThisBuild / licenses := List(
16 | "Apache 2" -> url("https://www.apache.org/licenses/LICENSE-2.0.txt")
17 | )
18 |
19 | // Remove all additional repository other than Maven Central from POM
20 | ThisBuild / pomIncludeRepository := { _ => false }
21 | ThisBuild / publishMavenStyle := true
22 |
23 | ThisBuild / developers := List(
24 | Developer(
25 | "greenfossil",
26 | "Greenfossil Pte Ltd",
27 | "devadmin@greenfossil.com",
28 | url("https://github.com/Greenfossil")
29 | )
30 | )
31 |
32 | ThisBuild / publishTo := {
33 | val centralSnapshots = "https://central.sonatype.com/repository/maven-snapshots/"
34 | if (isSnapshot.value) Some("central-snapshots" at centralSnapshots)
35 | else localStaging.value
36 | }
37 |
38 | ThisBuild / credentials += {
39 | val ghCredentials = for {
40 | user <- sys.env.get("PUBLISH_USER").filter(_.nonEmpty)
41 | pass <- sys.env.get("PUBLISH_PASSWORD").filter(_.nonEmpty)
42 | } yield Credentials("Sonatype Nexus Repository Manager", "central.sonatype.com", user, pass)
43 |
44 | val fileCredentials = {
45 | val credFile = Path.userHome / ".sbt" / "sonatype_central_credentials"
46 | if (credFile.exists) Some(Credentials(credFile)) else None
47 | }
48 |
49 | ghCredentials
50 | .orElse(fileCredentials)
51 | .getOrElse {
52 | Credentials("Sonatype Nexus Repository Manager", "central.sonatype.com", "", "")
53 | }
54 | }
55 |
56 |
57 |
58 | lazy val sonatypeStaging = "https://s01.oss.sonatype.org/content/groups/staging/"
59 |
60 | ThisBuild / resolvers ++= Seq(
61 | "Sonatype Staging" at sonatypeStaging
62 | )
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/AppException.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 |
20 | /** A UsefulException is something useful to display in the User browser. */
21 | trait UsefulException extends RuntimeException:
22 | /** Exception title. */
23 | val title: String
24 |
25 | /** Exception description. */
26 | val description: String
27 |
28 | /** Exception cause if defined. */
29 | val cause: Throwable
30 |
31 | /** Unique id for this exception. */
32 | val id: String
33 |
34 | override def toString: String = "@" + id + ": " + getMessage
35 |
36 | private val generator = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis())
37 |
38 | def nextId = java.lang.Long.toString(generator.incrementAndGet(), 26)
39 |
40 |
41 | trait AppException extends UsefulException
42 |
43 | trait ExceptionSource extends AppException
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Configuration.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.typesafe.config.{Config, ConfigFactory}
20 |
21 | object Configuration:
22 |
23 | def apply(): Configuration = from(getClass.getClassLoader)
24 |
25 | def usingPort(port: Int): Configuration =
26 | val configuration = from(getClass.getClassLoader)
27 | configuration.copy(httpConfiguration = configuration.httpConfiguration.copy(httpPort = port))
28 |
29 | def from(classLoader: ClassLoader): Configuration =
30 | from(ConfigFactory.load(classLoader))
31 |
32 | def from(config: Config): Configuration =
33 | val env = Environment.from(config)
34 | new Configuration(config, env, HttpConfiguration.from(config, env))
35 |
36 | case class Configuration(config: Config, environment: Environment, httpConfiguration: HttpConfiguration) :
37 | def httpPort: Int = httpConfiguration.httpPort
38 |
39 | def maxRequestLength = httpConfiguration.maxRequestLength
40 |
41 | def maxNumConnectionOpt = httpConfiguration.maxNumConnectionOpt
42 |
43 | def requestTimeout = httpConfiguration.requestTimeout
44 |
45 | def isProd: Boolean = environment.isProd
46 |
47 | def isDev: Boolean = environment.isDev
48 |
49 | def isDemo: Boolean = environment.isDemo
50 |
51 | def isTest: Boolean = environment.isTest
52 |
53 | def mode: Mode = environment.mode
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Conversions.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.commons.json.JsValue
20 | import com.greenfossil.htmltags.Tag
21 | import com.linecorp.armeria.common.{HttpResponse, HttpStatus, MediaType}
22 |
23 | given Conversion[HttpResponse, Result] = Result(_)
24 |
25 | given Conversion[JsValue, Result] with
26 | def apply(jsValue: JsValue): Result = Result.of(HttpStatus.OK, jsValue.stringify, MediaType.JSON)
27 |
28 | given Conversion[Tag, Result] with
29 | def apply(tag: Tag): Result = Result.of(HttpStatus.OK, tag.render, MediaType.HTML_UTF_8)
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Endpoint.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.ServiceConfig
20 |
21 | import java.nio.charset.StandardCharsets
22 | import java.util.regex.Pattern
23 |
24 | case class Endpoint(path: String, method: String, queryParams: List[(String, Any)], pathPatternOpt: Option[String] = None):
25 |
26 | def url: String =
27 | //Query Param is expected to be UrlEncoded
28 | queryParams match
29 | case Nil => path
30 | case _ => path +
31 | queryParams
32 | .map(kv => s"${Endpoint.paramKeyValue(kv._1, kv._2)}")
33 | .mkString("?", "&", "")
34 |
35 | def absoluteUrl(authority: String, secure: Boolean): String =
36 | val protocol = if secure then "https" else "http"
37 | s"$protocol://$authority$url"
38 |
39 | def absoluteUrl(using request: Request): String =
40 | absoluteUrl(request.uriAuthority, request.secure)
41 |
42 | def prefixedUrl(using request: Request): String =
43 | import scala.jdk.CollectionConverters.*
44 | prefixedUrl(request.path, request.requestContext.config().server().serviceConfigs().asScala.toList)
45 |
46 | def prefixedUrl(requestPath: String, serviceConfigs: Seq[ServiceConfig]): String =
47 | Endpoint
48 | .getPrefix(requestPath, serviceConfigs, pathPatternOpt.getOrElse(requestPath))
49 | .map(prefix => prefix + url)
50 | .getOrElse(url)
51 |
52 | /**
53 | *
54 | * @param params - replace the existing queryParams with params
55 | * @return
56 | */
57 | def setQueryParams(params: (String, Any)*): Endpoint =
58 | copy(queryParams = params.toList)
59 |
60 | /**
61 | *
62 | * @param params - append params to existing queryParams
63 | * @return
64 | */
65 | def addQueryParams(params: (String, Any)*): Endpoint =
66 | copy(queryParams = queryParams ++ params)
67 |
68 | override def toString: String = url
69 |
70 | object Endpoint:
71 |
72 | def apply(path: String): Endpoint = new Endpoint(path, "GET", Nil)
73 |
74 | def apply(path: String, pathPattern: String): Endpoint =
75 | new Endpoint(path, "GET", Nil, Option(pathPattern))
76 |
77 | def paramKeyValue(name: String, value: Any): String =
78 | (name, value) match
79 | case (name, x :: Nil) => paramKeyValue(name, x)
80 | case (name, xs: Seq[Any]) => xs.map(x => s"${name}[]=${x.toString}").mkString("&")
81 | case (name, x: Any) => s"$name=${x.toString}"
82 |
83 | def paramKeyValueUrlEncoded(name: String, value: Any): String =
84 | (name, value) match
85 | case (name, x :: Nil) => paramKeyValueUrlEncoded(name, x)
86 | case (name, xs: Seq[Any]) => xs.map(x => s"${urlencode(name)}[]=${urlencode(x.toString)}").mkString("&")
87 | case (name, x: Any) => s"${urlencode(name)}=${urlencode(x.toString)}"
88 |
89 | def urlencode(value: String): String =
90 | java.net.URLEncoder.encode(value, StandardCharsets.UTF_8.toString)
91 |
92 | def getPrefix(serviceConfigs: Seq[ServiceConfig], rawPathPattern: String): Option[String] =
93 | getPrefix("", serviceConfigs, rawPathPattern)
94 |
95 | def getPrefix(requestPath: String, serviceConfigs: Seq[ServiceConfig], rawPathPattern: String): Option[String] =
96 | //1. Compute Redirect Endpoint prefix that matches incoming Request
97 | val isPrefix = rawPathPattern.startsWith("prefix:")
98 | val epPathPattern = //This would be forwardEndpoint Annotated path pattern
99 | if isPrefix
100 | then rawPathPattern.replaceAll("prefix:", "") + "/*"
101 | else rawPathPattern.replaceAll("regex:|glob:", "")
102 |
103 | val matchedConfigs = serviceConfigs
104 | .filter { serviceConfig =>
105 | val configRoutePattern = serviceConfig.route().patternString()
106 | val isConfigRoute = configRoutePattern.endsWith(epPathPattern)
107 | val configRoutePrefix =
108 | configRoutePattern.replaceAll(Pattern.quote(epPathPattern), "")
109 | val matchedRequestPrefix =
110 | //if requestPath is empty then matchedRequestPrefix is set as true
111 | val prefix = if isPrefix then configRoutePrefix + "/" else configRoutePrefix
112 | requestPath.isEmpty ||
113 | configRoutePrefix.nonEmpty && requestPath.startsWith(prefix)
114 |
115 | isConfigRoute && matchedRequestPrefix
116 | }
117 | matchedConfigs.lastOption.map(serviceConfig => {
118 | //Convert the matched serviceConfig to the prefix
119 | val configRoutePattern = serviceConfig.route().patternString()
120 | val prefix = configRoutePattern.replaceAll(Pattern.quote(epPathPattern), "")
121 | if prefix.lastOption.contains('/') then prefix.init else prefix
122 | })
123 |
124 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/EndpointMcr.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | inline def EndpointMcr[A](inline ep: A): Endpoint =
20 | ${ EndpointMcrImpl( '{ep} ) }
21 |
22 | import scala.quoted.*
23 | def EndpointMcrImpl[A : Type](epExpr: Expr[A])(using Quotes): Expr[Endpoint] =
24 | import AnnotatedActionMacroSupport.*
25 |
26 | computeActionAnnotatedPath(
27 | epExpr,
28 | (exprMethod, exprPathPartList, exprQueryParamKeys, exprQueryParamValues, exprPathPattern) =>
29 | '{
30 | Endpoint(
31 | ${exprPathPartList}.mkString("/"),
32 | ${exprMethod},
33 | ${exprQueryParamKeys}.zip(${exprQueryParamValues}),
34 | Some(${exprPathPattern})
35 | )
36 | }
37 | )
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Environment.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.typesafe.config.{Config, ConfigFactory}
20 |
21 | import java.io.{File, InputStream}
22 |
23 | enum Mode extends Enum[Mode]:
24 | case Dev, Test, Prod, Demo
25 |
26 | object Environment:
27 |
28 | def from(classLoader: ClassLoader): Environment =
29 | from(ConfigFactory.load(classLoader))
30 |
31 | /**
32 | * A simple environment.
33 | *
34 | * Uses the same classloader that the environment classloader is defined in, and the current working directory as the
35 | * path.
36 | */
37 | def from(config: Config): Environment =
38 | val mode = config.getEnum(classOf[Mode], "app.env")
39 | Environment(config.getClass.getClassLoader, new File("."), mode)
40 |
41 |
42 | case class Environment(classLoader: ClassLoader, rootPath: File, mode: Mode):
43 |
44 | /**
45 | * Retrieves a file relative to the application root path.
46 | *
47 | * Note that it is up to you to manage the files in the application root path in production. By default, there will
48 | * be nothing available in the application root path.
49 | *
50 | * For example, to retrieve some deployment specific data file:
51 | * {{{
52 | * val myDataFile = application.getFile("data/data.xml")
53 | * }}}
54 | *
55 | * @param relativePath relative path of the file to fetch
56 | * @return a file instance; it is not guaranteed that the file exists
57 | */
58 | def getFile(relativePath: String): File = new File(rootPath, relativePath)
59 |
60 | /**
61 | * Retrieves a file relative to the application root path.
62 | * This method returns an Option[File], using None if the file was not found.
63 | *
64 | * Note that it is up to you to manage the files in the application root path in production. By default, there will
65 | * be nothing available in the application root path.
66 | *
67 | * For example, to retrieve some deployment specific data file:
68 | * {{{
69 | * val myDataFile = application.getExistingFile("data/data.xml")
70 | * }}}
71 | *
72 | * @param relativePath the relative path of the file to fetch
73 | * @return an existing file
74 | */
75 | def getExistingFile(relativePath: String): Option[File] = Some(getFile(relativePath)).filter(_.exists)
76 |
77 | /**
78 | * Scans the application classloader to retrieve a resource.
79 | *
80 | * The conf directory is included on the classpath, so this may be used to look up resources, relative to the conf
81 | * directory.
82 | *
83 | * For example, to retrieve the conf/logback.xml configuration file:
84 | * {{{
85 | * val maybeConf = application.resource("logback.xml")
86 | * }}}
87 | *
88 | * @param name the absolute name of the resource (from the classpath root)
89 | * @return the resource URL, if found
90 | */
91 | def resource(name: String): Option[java.net.URL] =
92 | val n = name.stripPrefix("/")
93 | Option(classLoader.getResource(n))
94 |
95 | /**
96 | * Scans the application classloader to retrieve a resource’s contents as a stream.
97 | *
98 | * The conf directory is included on the classpath, so this may be used to look up resources, relative to the conf
99 | * directory.
100 | *
101 | * For example, to retrieve the conf/logback.xml configuration file:
102 | * {{{
103 | * val maybeConf = application.resourceAsStream("logback.xml")
104 | * }}}
105 | *
106 | * @param name the absolute name of the resource (from the classpath root)
107 | * @return a stream, if found
108 | */
109 | def resourceAsStream(name: String): Option[InputStream] =
110 | val n = name.stripPrefix("/")
111 | Option(classLoader.getResourceAsStream(n))
112 |
113 | def isProd: Boolean = mode == Mode.Prod
114 |
115 | def isTest: Boolean = mode == Mode.Test
116 |
117 | def isDemo: Boolean = mode == Mode.Demo
118 |
119 | def isDev: Boolean = mode == Mode.Dev
120 |
121 | def modeName: String = mode.toString.toLowerCase
122 |
123 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/EssentialAction.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.{HttpRequest, HttpResponse}
20 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext}
21 | import org.slf4j.LoggerFactory
22 |
23 | import java.io.InputStream
24 | import java.util.concurrent.CompletableFuture
25 |
26 | type SimpleResponse = String | Array[Byte] | InputStream | HttpResponse
27 |
28 | type ActionResponse = SimpleResponse | Result
29 |
30 | private[thorium] val actionLogger = LoggerFactory.getLogger("com.greenfossil.thorium.action")
31 |
32 | trait EssentialAction extends HttpService :
33 |
34 | /**
35 | * An EssentialAction is an Request => ActionResponse
36 | * All subclasses must implements this function signature
37 | *
38 | * This method will be invoked by EssentialAction.serve
39 | *
40 | * @param request
41 | * @return
42 | */
43 | protected def apply(request: Request): ActionResponse
44 |
45 | /**
46 | * Armeria invocation during an incoming request
47 | *
48 | * @param svcRequestContext
49 | * @param httpRequest
50 | * @return
51 | */
52 | override def serve(svcRequestContext: ServiceRequestContext, httpRequest: HttpRequest): HttpResponse =
53 | actionLogger.debug(s"Processing EssentialAction.serve - method:${svcRequestContext.method()}, content-type:${httpRequest.contentType()}, path:${httpRequest.uri}")
54 | val futureResp = new CompletableFuture[HttpResponse]()
55 | svcRequestContext
56 | .request()
57 | .aggregate()
58 | .thenAccept { aggregateRequest =>
59 | actionLogger.debug("Setting up blockingTaskExecutor()")
60 | svcRequestContext.blockingTaskExecutor().execute(() => {
61 | //Invoke EssentialAction
62 | val ctxCl = Thread.currentThread().getContextClassLoader
63 | if ctxCl == null then {
64 | val cl = this.getClass.getClassLoader
65 | actionLogger.debug(s"Async setContextClassloader:${cl}")
66 | Thread.currentThread().setContextClassLoader(cl)
67 | }
68 | val httpResp =
69 | try
70 | val req = new Request(svcRequestContext, aggregateRequest) {}
71 | actionLogger.debug(s"Invoke EssentialAction.apply. cl:$ctxCl, req:${req.hashCode()}")
72 | val resp = apply(req)
73 | actionLogger.debug("Response from EssentialAction.apply")
74 | HttpResponseConverter.convertActionResponseToHttpResponse(req, resp)
75 | catch
76 | case t =>
77 | actionLogger.debug(s"Exception raised in EssentialAction.apply.", t)
78 | HttpResponse.ofFailure(t)
79 | futureResp.complete(httpResp)
80 | })
81 | }
82 | HttpResponse.of(futureResp)
83 |
84 | end EssentialAction
85 |
86 | trait Action extends EssentialAction
87 |
88 | object Action:
89 |
90 | /**
91 | * AnyContent request
92 | *
93 | * @param actionResponder
94 | * @return
95 | */
96 | def apply(fn: Request => ActionResponse): Action =
97 | actionLogger.debug(s"Processing Action...")
98 | (request: Request) => fn(request)
99 |
100 | /**
101 | * Multipart form request
102 | *
103 | * @param actionResponder
104 | * @return
105 | */
106 | def multipart(fn: MultipartRequest => ActionResponse): Action =
107 | actionLogger.debug("Processing Multipart Action...")
108 | (request: Request) => request.asMultipartFormData { form =>
109 | fn(MultipartRequest(form, request.requestContext, request.aggregatedHttpRequest))
110 | }
111 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Extensions.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.data.mapping.Mapping
20 | import com.linecorp.armeria.common.multipart.MultipartFile
21 | import com.linecorp.armeria.common.{HttpMethod, MediaType}
22 | import org.overviewproject.mime_types.MimeTypeDetector
23 |
24 | import java.io.InputStream
25 | import java.net.URI
26 |
27 |
28 | extension (inline action: EssentialAction)
29 |
30 | inline def absoluteUrl(authority: String, secure: Boolean) =
31 | EndpointMcr(action).absoluteUrl(authority, secure)
32 |
33 | inline def absoluteUrl(using request: Request): String =
34 | EndpointMcr(action).absoluteUrl(using request)
35 |
36 | inline def endpoint: Endpoint = EndpointMcr(action)
37 |
38 | extension[A](field: Mapping[A])
39 | def bindFromRequest()(using request: Request): Mapping[A] =
40 | request match
41 | case req if Option(req.contentType).exists(_.isJson) =>
42 | field.bind(request.asJson)
43 |
44 | case req =>
45 | //QueryParams for POST,PUT,PATCH will be not be used for binding
46 | val queryParams: List[(String, String)] =
47 | request.method match {
48 | case HttpMethod.POST | HttpMethod.PUT | HttpMethod.PATCH => Nil
49 | case _ => request.queryParamsList
50 | }
51 | field.bind(req.asFormUrlEncoded ++ queryParams.groupMap(_._1)(_._2))
52 |
53 | val mimeTypeDetector = new MimeTypeDetector()
54 |
55 | extension(mpFile: MultipartFile)
56 | def sizeInGB: Long = mpFile.file().length() / 1024 / 1024 / 1024
57 | def sizeInMB: Long = mpFile.file().length() / 1024 / 1024
58 | def sizeInKB: Long = mpFile.file().length() / 1024
59 | def sizeInBytes: Long = mpFile.file().length()
60 |
61 | def contentType: MediaType = MediaType.parse(mimeTypeDetector.detectMimeType(mpFile.path()))
62 |
63 | def inputStream: InputStream = mpFile.path().toUri.toURL.openStream()
64 |
65 | extension(cs: java.net.CookieStore)
66 |
67 | /**
68 | * Add an Armeria cookie to java.net.CookieStore
69 | * This is meant for client testing only. Do not suse for production.
70 | * @param host
71 | * @param cookie
72 | */
73 | def add(host: String, cookie: com.linecorp.armeria.common.Cookie) =
74 | val uri = URI.create(host)
75 | val httpCookie = java.net.HttpCookie(cookie.name(), cookie.value())
76 | httpCookie.setSecure(cookie.isSecure)
77 | httpCookie.setPath(cookie.path())
78 | httpCookie.setHttpOnly(cookie.isHttpOnly)
79 | httpCookie.setMaxAge(httpCookie.getMaxAge)
80 | if cookie.isHostOnly then httpCookie.setHttpOnly(true)
81 | if cookie.domain() != null && !cookie.domain().isBlank then httpCookie.setDomain(cookie.domain())
82 |
83 | cs.add(uri, httpCookie)
84 |
85 | extension (l: Long) def humanize: String =
86 | if l < 1024 then s"$l B"
87 | else
88 | val z = (63 - java.lang.Long.numberOfLeadingZeros(l)) / 10
89 | f"${(l * 1.0) / (1L << (z * 10))}%.1f ${" KMGTPE".charAt(z)}%sB"
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Flash.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import scala.annotation.targetName
20 |
21 | object Flash:
22 | def apply(): Flash = new Flash(Map.empty.withDefaultValue(""))
23 |
24 | case class Flash(data: Map[String, String]):
25 | export data.{+ as _, *}
26 |
27 | @targetName("add")
28 | def +(tup: (String, String)): Flash =
29 | copy(data = data + tup )
30 |
31 | def ++(newFlash: Flash): Flash =
32 | copy(data = data ++ newFlash.data )
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/FormUrlEncoded.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.{AggregatedHttpRequest, HttpMethod, MediaType}
20 | import com.linecorp.armeria.server.ServiceRequestContext
21 | import com.linecorp.armeria.server.annotation.RequestConverterFunction
22 |
23 | import java.lang.reflect.ParameterizedType
24 |
25 | case class FormUrlEndcoded(form: Map[String, Seq[String]]):
26 | export form.*
27 | def getFirst(fieldName: String):Option[String] =
28 | get(fieldName).map(_.headOption.orNull)
29 |
30 | object FormUrlEncodedRequestConverterFunction extends RequestConverterFunction:
31 | override def convertRequest(ctx: ServiceRequestContext,
32 | request: AggregatedHttpRequest,
33 | expectedResultType: Class[?],
34 | expectedParameterizedResultType: ParameterizedType): AnyRef =
35 | if expectedResultType == classOf[FormUrlEndcoded]
36 | && MediaType.FORM_DATA == request.contentType() && HttpMethod.POST == request.method() then
37 | FormUrlEncodedParser.parse(request.contentUtf8())
38 | else RequestConverterFunction.fallthrough()
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/FormUrlEncodedParser.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.net.URLDecoder
20 | import scala.collection.immutable.ListMap
21 | import scala.collection.mutable
22 |
23 | /** An object for parsing application/x-www-form-urlencoded data */
24 | object FormUrlEncodedParser:
25 |
26 | /**
27 | * Parse the content type "application/x-www-form-urlencoded" which consists of a bunch of & separated key=value
28 | * pairs, both of which are URL encoded. This parser to maintain the original order of the
29 | * keys by using groupBy as some applications depend on the original browser ordering.
30 | * @param data The body content of the request, or whatever needs to be so parsed
31 | * @param encoding The character encoding of data
32 | * @return A ListMap of keys to the sequence of values for that key
33 | */
34 | def parse(data: String, encoding: String = "utf-8"): FormUrlEndcoded =
35 |
36 | // Generate the pairs of values from the string.
37 | val pairs: Seq[(String, String)] = parseToPairs(data, encoding)
38 |
39 | // Group the pairs by the key (first item of the pair) being sure to preserve insertion order
40 | FormUrlEndcoded(toMap(pairs))
41 |
42 | private val parameterDelimiter = "[&;]".r
43 |
44 | /**
45 | * Do the basic parsing into a sequence of key/value pairs
46 | * @param data The data to parse
47 | * @param encoding The encoding to use for interpreting the data
48 | * @return The sequence of key/value pairs
49 | */
50 | private def parseToPairs(data: String, encoding: String): Seq[(String, String)] =
51 | val split = parameterDelimiter.split(data)
52 | if split.length == 1 && split(0).isEmpty then Seq.empty
53 | else
54 | split.toIndexedSeq.map { param =>
55 | val parts = param.split("=", -1)
56 | val key = URLDecoder.decode(parts(0), encoding)
57 | val value = URLDecoder.decode(parts.lift(1).getOrElse(""), encoding)
58 | key -> value
59 | }
60 |
61 | private def toMap[K, V](seq: Seq[(K, V)]): Map[K, Seq[V]] =
62 | // This mutable map will not retain insertion order for the seq, but it is fast for retrieval. The value is
63 | // a builder for the desired Seq[String] in the final result.
64 | val m = mutable.Map.empty[K, mutable.Builder[V, Seq[V]]]
65 |
66 | // Run through the seq and create builders for each unique key, effectively doing the grouping
67 | for ((key, value) <- seq) m.getOrElseUpdate(key, Seq.newBuilder[V]) += value
68 |
69 | // Create a builder for the resulting ListMap. Note that this one is immutable and will retain insertion order
70 | val b = ListMap.newBuilder[K, Seq[V]]
71 |
72 | // Note that we are NOT going through m (didn't retain order) but we are iterating over the original seq
73 | // just to get the keys so we can look up the values in m with them. This is how order is maintained.
74 | for ((k, v) <- seq.iterator) b += k -> m.getOrElse(k, Seq.newBuilder[V]).result
75 |
76 | // Get the builder to produce the final result
77 | b.result
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/HMACUtil.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.security.{MessageDigest, SecureRandom}
20 | import javax.crypto.Mac
21 | import javax.crypto.spec.SecretKeySpec
22 |
23 | object HMACUtil:
24 |
25 | /**
26 | *
27 | * @param message
28 | * @param key -
29 | * @param algorithm - HmacSHA256, HmacSHA384, HmacSHA512 or the equivalent HS256, HS384, HS512
30 | * @param converter
31 | * @tparam A
32 | * @return
33 | */
34 | def hmac[A](message: Array[Byte], key: Array[Byte], algorithm: String, converter: Array[Byte] => A): A =
35 | val alg = algorithm.replaceAll("^HS", "HmacSHA")
36 | val keySpec = new SecretKeySpec(key, alg)
37 | val mac = Mac.getInstance(alg)
38 | mac.init(keySpec)
39 | converter(mac.doFinal(message))
40 |
41 | def digest(bytes: Array[Byte], algorithm: String): Array[Byte] =
42 | digest(bytes, algorithm, identity)
43 |
44 | def digest[A](bytes: Array[Byte], algorithm: String, converter: Array[Byte] => A): A =
45 | digest(Seq(bytes), algorithm, converter)
46 |
47 | def digest[A](bytesSeq: Seq[Array[Byte]], algorithm: String, converter: Array[Byte] => A): A =
48 | val md = MessageDigest.getInstance(algorithm)
49 | bytesSeq.foreach(v => md.update(v))
50 | converter(md.digest)
51 |
52 | /**
53 | *
54 | * @param byteLength
55 | * @return
56 | */
57 | def randomBytes(byteLength: Int): Array[Byte] =
58 | randomBytes(byteLength, "NativePRNG", identity)
59 |
60 | def randomBytes[A](byteLength: Int, converter: Array[Byte] => A): A =
61 | randomBytes(byteLength, "NativePRNG", converter)
62 |
63 |
64 | /**
65 | *
66 | * @param byteLength
67 | * @param algorithm - e.g. NativePRNG
68 | * @return
69 | */
70 | def randomBytes[A](byteLength: Int, algorithm: String, converter: Array[Byte] => A): A =
71 | val random = SecureRandom.getInstance(algorithm)
72 | val bytes = new Array[Byte](byteLength)
73 | random.nextBytes(bytes)
74 | converter(bytes)
75 |
76 | /**
77 | *
78 | * @param bytes
79 | * @return
80 | */
81 | def hex(bytes: Array[Byte]): String =
82 | bytes.map(byte => f"${byte & 0xFF}%02x").mkString("")
83 |
84 | /**
85 | *
86 | * @param length
87 | * @return
88 | */
89 | def randomAlphaNumericString(length: Int): String =
90 | randomAlphaNumericString(length, "NativePRNG")
91 |
92 | inline private val ALPHA_NUMERIC_STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
93 | inline private val NUMERIC_STRING = "0123456789"
94 |
95 | /**
96 | *
97 | * @param length
98 | * @param algorithm
99 | * @return
100 | */
101 | def randomAlphaNumericString(length: Int, algorithm: String): String =
102 | randomStringBuilder(ALPHA_NUMERIC_STRING, length, algorithm)
103 |
104 | /**
105 | *
106 | * @param length
107 | * @return
108 | */
109 | def randomNumericString(length: Int): String =
110 | randomNumericString(length, "NativePRNG")
111 |
112 | /**
113 | * Generate a random numeric string
114 | *
115 | * @param length
116 | * @param algorithm
117 | * @return
118 | */
119 | def randomNumericString(length: Int, algorithm: String): String =
120 | randomStringBuilder(NUMERIC_STRING, length, algorithm)
121 |
122 | /**
123 | *
124 | * @param str
125 | * @param length
126 | * @param rand
127 | * @return
128 | */
129 | private def randomStringBuilder(str: String, length: Int, algorithm: String): String =
130 | if length < 1 then
131 | throw new IllegalArgumentException("The length must be a positive integer")
132 |
133 | val random = SecureRandom.getInstance(algorithm)
134 | val alphaLength = str.length
135 | val builder = new StringBuilder()
136 | 0 until length foreach { i =>
137 | builder.append(str.charAt(random.nextInt(alphaLength)))
138 | }
139 | builder.toString
140 |
141 |
142 | /**
143 | * Constant time equals method.
144 | *
145 | * Given a length that both Arrays are equal to, this method will always run in constant time.
146 | * This prevents timing attacks.
147 | */
148 | def constantTimeEquals(array1: Array[Byte], array2: Array[Byte]): Boolean =
149 | // Check if the arrays have the same length
150 | if array1.length != array2.length then false
151 | else
152 | // Perform a bitwise comparison of each byte
153 | // If the arrays are equal, the result will be 0
154 | var result = 0
155 | for (i <- array1.indices) {
156 | result |= array1(i) ^ array2(i)
157 | }
158 | result == 0
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/HttpResponseConverter.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.{Cookie, HttpData, HttpHeaderNames, HttpMethod, HttpResponse, HttpStatus, MediaType, ResponseHeaders}
20 | import com.linecorp.armeria.common.stream.StreamMessage
21 |
22 | import java.io.InputStream
23 | import scala.util.{Try, Using}
24 |
25 | object HttpResponseConverter:
26 |
27 | private def addCookiesToHttpResponse(cookies: Seq[Cookie], resp: HttpResponse): HttpResponse =
28 | if cookies.isEmpty then resp
29 | else resp.mapHeaders(_.toBuilder.cookies(cookies *).build())
30 |
31 | private def addHeadersToHttpResponse(responseHeader: ResponseHeader, resp: HttpResponse): HttpResponse =
32 | if responseHeader == null || responseHeader.headers.isEmpty then resp
33 | else resp.mapHeaders(_.withMutations { builder =>
34 | responseHeader.headers.map { header =>
35 | builder.set(header._1, header._2)
36 | }
37 | })
38 |
39 | private def addContentTypeToHttpResponse(contextTypeOpt: Option[MediaType], resp: HttpResponse): HttpResponse =
40 | contextTypeOpt match
41 | case Some(contentType) =>
42 | resp.mapHeaders(_.withMutations { builder =>
43 | builder.contentType(contentType)
44 | })
45 | case None => resp
46 |
47 | private def getCSRFCookie(req: Request): Option[Cookie] =
48 | val token = req.requestContext.attr(RequestAttrs.CSRFToken)
49 | if token == null then None
50 | else
51 | val config = req.requestContext.attr(RequestAttrs.Config)
52 | serverLogger.debug(s"CSRFToken added to header:${token}")
53 | Option(CookieUtil
54 | .csrfCookieBuilder(config.httpConfiguration.csrfConfig, token)
55 | .build())
56 |
57 |
58 | private def getAllCookies(req: Request, actionResp: ActionResponse): Seq[Cookie] =
59 | val (newCookies, newSessionOpt, newFlashOpt) =
60 | actionResp match
61 | case result: Result => (result.newCookies, result.newSessionOpt, result.newFlashOpt)
62 | case _ => (Nil, None, None)
63 | (getNewSessionCookie(req, newSessionOpt) ++ getNewFlashCookie(req, newFlashOpt) ++ getCSRFCookie(req)).toList ++ newCookies
64 |
65 | private def responseHeader(actionResp: ActionResponse): ResponseHeader =
66 | actionResp match
67 | case result: Result => result.header
68 | case _ => null
69 |
70 | private def contentTypeOpt(actionResp: ActionResponse): Option[MediaType] =
71 | actionResp match
72 | case result: Result => result.contentTypeOpt
73 | case _ => None
74 |
75 | /*
76 | * Keep current request Session, if newSessionOpt is None
77 | * If newSessionOpt is defined, discard session if newSession isEmpty or create a new Session with values
78 | */
79 | private def getNewSessionCookie(req: Request, newSessionOpt: Option[Session]): Option[Cookie] =
80 | newSessionOpt.map { newSession =>
81 | //If newSession isEmtpy, expire session cookie
82 | if newSession.isEmpty then
83 | CookieUtil.bakeDiscardCookie(req.httpConfiguration.sessionConfig.cookieName)(using req)
84 | else
85 | //Bake a new session cookie
86 | CookieUtil.bakeSessionCookie(newSession)(using req).orNull
87 | }
88 |
89 | private def getNewFlashCookie(req: Request, newFlashOpt: Option[Flash]): Option[Cookie] =
90 | /*
91 | * Flash cookie could survive for 1 request.
92 | * However, if there is X-THORIUM-FLASH:Again header, flash will survive 1 more request
93 | */
94 | val hasThoriumFlashHeader =
95 | req.getHeader("X-THORIUM-FLASH").exists("AGAIN".equalsIgnoreCase)
96 | && HttpMethod.GET == req.method
97 | val flashOpt = if !hasThoriumFlashHeader then newFlashOpt else newFlashOpt.map(newFlash => req.flash ++ newFlash).orElse(Option(req.flash))
98 | flashOpt.flatMap { newFlash =>
99 | CookieUtil.bakeFlashCookie(newFlash)(using req) //Create a new flash cookie
100 | }.orElse {
101 | //Expire the current flash cookie
102 | if req.flash == null || req.flash.isEmpty then None
103 | else Some(CookieUtil.bakeDiscardCookie(req.httpConfiguration.flashConfig.cookieName)(using req))
104 | }
105 |
106 | private def _addHttpResponseHeaders(req: com.greenfossil.thorium.Request,
107 | actionResp: ActionResponse,
108 | httpResp: HttpResponse
109 | ): Try[HttpResponse] =
110 | for {
111 | respWithCookies <- Try(addCookiesToHttpResponse(getAllCookies(req, actionResp), httpResp))
112 | respWithHeaders <- Try(addHeadersToHttpResponse(responseHeader(actionResp), respWithCookies))
113 | httpResponse <- Try(addContentTypeToHttpResponse(contentTypeOpt(actionResp), respWithHeaders))
114 | } yield httpResponse
115 |
116 | private def _toHttpResponse(req: com.greenfossil.thorium.Request, status: HttpStatus, actionResponse: ActionResponse): Try[HttpResponse] =
117 | Try:
118 | actionResponse match
119 | case null => throw new Exception(s"Null response in request [${req.uri}]")
120 | case hr: HttpResponse => hr
121 | case s: String => HttpResponse.of(status, MediaType.PLAIN_TEXT_UTF_8, s)
122 | case bytes: Array[Byte] => HttpResponse.of(status, Option(req.contentType).getOrElse(MediaType.ANY_TYPE), HttpData.wrap(bytes))
123 | case is: InputStream =>
124 | HttpResponse.of(
125 | ResponseHeaders.of(status, HttpHeaderNames.CONTENT_TYPE, Option(req.contentType).getOrElse(MediaType.ANY_TYPE)),
126 | StreamMessage.fromOutputStream(os => Using.resources(is, os) { (is, os) => is.transferTo(os) })
127 | )
128 | case result: Result =>
129 | if Result.isRedirect(result.status.code()) then HttpResponse.ofRedirect(result.status, result.body.asInstanceOf[String])
130 | else _toHttpResponse(req, result.status, result.body).get
131 |
132 | def convertActionResponseToHttpResponse(req: com.greenfossil.thorium.Request, actionResp: ActionResponse): HttpResponse =
133 | serverLogger.debug(s"Convert ActionResponse to HttpResponse.")
134 | (for {
135 | httpResp <- _toHttpResponse(req, HttpStatus.OK, actionResp)
136 | httpRespWithHeaders <- _addHttpResponseHeaders(req, actionResp, httpResp)
137 | } yield httpRespWithHeaders)
138 | .fold(
139 | ex => {
140 | serverLogger.error("Invoke Action error", ex)
141 | HttpResponse.ofFailure(ex) // allow exceptionHandlerFunctions and serverErrorHandler to kick in
142 | },
143 | identity
144 | )
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/MacroSupport.scala:
--------------------------------------------------------------------------------
1 | ///*
2 | // * Copyright 2022 Greenfossil Pte Ltd
3 | // *
4 | // * Licensed under the Apache License, Version 2.0 (the "License");
5 | // * you may not use this file except in compliance with the License.
6 | // * You may obtain a copy of the License at
7 | // *
8 | // * http://www.apache.org/licenses/LICENSE-2.0
9 | // *
10 | // * Unless required by applicable law or agreed to in writing, software
11 | // * distributed under the License is distributed on an "AS IS" BASIS,
12 | // * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // * See the License for the specific language governing permissions and
14 | // * limitations under the License.
15 | // */
16 | //
17 | //package com.greenfossil.thorium
18 | //
19 | //import scala.quoted.Quotes
20 | //
21 | //trait MacroSupport(globalDebug: Boolean):
22 | //
23 | // def findEnclosingTerm(using quotes: Quotes)(sym: quotes.reflect.Symbol): quotes.reflect.Symbol =
24 | // import quotes.reflect.*
25 | // sym match
26 | // case sym if sym.flags.is(Flags.Macro) => findEnclosingTerm(sym.owner)
27 | // case sym if !sym.isTerm => findEnclosingTerm(sym.owner)
28 | // case _ => sym
29 | //
30 | // def showStructure(using quotes:Quotes)(msg: String, x: quotes.reflect.Tree | List[quotes.reflect.Tree], debug: Boolean = globalDebug): Unit =
31 | // import quotes.reflect.*
32 | // if debug
33 | // then
34 | // x match
35 | // case xs: List[Tree @unchecked] =>
36 | // println(s"$msg: ${xs.map(_.show(using Printer.TreeStructure))}")
37 | //
38 | // case term: Tree @unchecked =>
39 | // println(s"$msg: ${term.show(using Printer.TreeStructure)}")
40 | //
41 | // def showCode(using quotes:Quotes)(msg: String, x: quotes.reflect.Tree | List[quotes.reflect.Tree], debug: Boolean = globalDebug): Unit =
42 | // import quotes.reflect.*
43 | // if debug
44 | // then
45 | // x match
46 | // case xs: List[Tree @unchecked] =>
47 | // println(s"$msg: ${xs.map(_.show(using quotes.reflect.Printer.TreeAnsiCode))}")
48 | //
49 | // case term: Tree @unchecked =>
50 | // println(s"$msg: ${term.show(using quotes.reflect.Printer.TreeAnsiCode)}")
51 | //
52 | // def show(using quotes:Quotes)(msg: String, x: quotes.reflect.Tree | List[quotes.reflect.Tree], debug: Boolean = globalDebug): Unit =
53 | // import quotes.reflect.*
54 | // if debug
55 | // then
56 | // x match
57 | // case xs: List[Tree @unchecked] =>
58 | // println(s"===> [List] ${msg}")
59 | // println(s"Code - Size:${xs.size}")
60 | // println(" " + xs.map(_.show(using quotes.reflect.Printer.TreeAnsiCode)))
61 | //
62 | // println(s"Structure - Size:${xs.size}")
63 | // println(" " + xs.map(_.show(using Printer.TreeStructure)))
64 | //
65 | // case term: Tree @unchecked =>
66 | // println(s"===> [Tree] ${msg}")
67 | // println(s"Symbol: ${term.symbol.flags.show}")
68 | // println(s"Code: ${term.show(using quotes.reflect.Printer.TreeAnsiCode)}")
69 | // println(s"Struct: ${term.show(using Printer.TreeStructure)}")
70 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/MultipartFormData.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.MediaType
20 | import com.linecorp.armeria.common.multipart.{AggregatedBodyPart, AggregatedMultipart, MultipartFile}
21 |
22 | import java.io.{File, InputStream}
23 | import java.nio.charset.Charset
24 | import java.nio.file.*
25 | import scala.util.Try
26 |
27 | case class MultipartFormData(aggMultipart: AggregatedMultipart, multipartUploadLocation: Path):
28 | import scala.jdk.CollectionConverters.*
29 |
30 | lazy val bodyPart: Seq[AggregatedBodyPart] = aggMultipart.bodyParts().asScala.toSeq
31 |
32 | lazy val names: List[String] = aggMultipart.names().asScala.toList
33 |
34 | lazy val asFormUrlEncoded: FormUrlEndcoded =
35 | val xs = for {
36 | name <- names
37 | part <- aggMultipart.fields(name).asScala
38 | content =
39 | if part.contentType().is(MediaType.PLAIN_TEXT) then
40 | part.content(Option(part.contentType().charset()).getOrElse(Charset.forName("UTF-8")))
41 | else if part.filename() != null then
42 | part.filename()
43 | else null
44 | if content != null
45 | } yield
46 | (name, content)
47 | FormUrlEndcoded(xs.groupMap(_._1)(_._2))
48 |
49 | private def saveFileTo( part: AggregatedBodyPart): Option[File] =
50 | Try {
51 | if !Files.exists(multipartUploadLocation) then multipartUploadLocation.toFile.mkdirs()
52 | val filePath = multipartUploadLocation.resolve(part.filename())
53 | val is: InputStream = part.content().toInputStream
54 | Files.copy(is, filePath, StandardCopyOption.REPLACE_EXISTING)
55 | is.close()
56 | filePath.toFile
57 | }.toOption
58 |
59 | lazy val files: List[MultipartFile] =
60 | for {
61 | name <- names
62 | part <- aggMultipart.fields(name).asScala
63 | if part.filename() != null && !part.content().isEmpty
64 | file <- saveFileTo(part)
65 | } yield MultipartFile.of(name, part.filename(), file)
66 |
67 | /**
68 | * Find a file using the form name
69 | * @param formNameRegex - this is the alias of findFileOfFormName
70 | * @return
71 | */
72 | def findFile(formNameRegex: String): Option[MultipartFile] =
73 | findFileOfFormName(formNameRegex)
74 |
75 | /**
76 | * Find a file using the form name
77 | * @param formNameRegex
78 | * @return
79 | */
80 | def findFileOfFormName(formNameRegex: String): Option[MultipartFile] =
81 | files.find(file => file.name.matches(formNameRegex) && file.file().length() > 0)
82 |
83 | /**
84 | * Find a file using the actual loaded filename
85 | * @param fileNameRegex
86 | * @return
87 | */
88 | def findFileOfFileName(fileNameRegex: String): Option[MultipartFile] =
89 | files.find(file => file.filename().matches(fileNameRegex) && file.file().length() > 0)
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/MultipartRequest.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.AggregatedHttpRequest
20 | import com.linecorp.armeria.server.ServiceRequestContext
21 |
22 | final class MultipartRequest(val multipartFormData: MultipartFormData,
23 | requestContext: ServiceRequestContext,
24 | aggregatedHttpRequest: AggregatedHttpRequest) extends Request(requestContext, aggregatedHttpRequest):
25 |
26 | export multipartFormData.{asFormUrlEncoded as _, *}
27 |
28 | override def asFormUrlEncoded: FormUrlEndcoded = multipartFormData.asFormUrlEncoded
29 |
30 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Recaptcha.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.greenfossil.commons.json.*
4 | import com.greenfossil.data.mapping.Mapping
5 | import com.greenfossil.data.mapping.Mapping.*
6 | import com.linecorp.armeria.common.MediaType
7 | import org.slf4j.LoggerFactory
8 |
9 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
10 | import java.net.{URI, URLEncoder}
11 | import java.nio.charset.StandardCharsets
12 | import java.time.{Duration, Instant}
13 | import scala.util.Try
14 |
15 | object Recaptcha:
16 | private val logger = LoggerFactory.getLogger("com.greenfossil.thorium.recaptcha")
17 |
18 | def onVerified(using request: Request)(predicate: Recaptcha => Boolean)(result: => ActionResponse): ActionResponse =
19 | verify.fold(
20 | ex => Unauthorized("Access denied."),
21 | recaptcha =>
22 | if !predicate(recaptcha) then
23 | logger.warn(s"Unauthorized access. recaptcha:${recaptcha}, method:${request.method}, uri:${request.uri} path:${request.path} content-type:${request.contentType}")
24 | Unauthorized("Access denied.")
25 | else
26 | request.requestContext.setAttr(RequestAttrs.RecaptchaResponse, recaptcha)
27 | result
28 | )
29 |
30 |
31 | def verify(using request: Request): Try[Recaptcha] =
32 | verify(request.config.httpConfiguration.recaptchaConfig.secretKey)
33 |
34 | def verify(secretKey: String)(using request: Request): Try[Recaptcha] =
35 | Mapping("g-recaptcha-response", seq[String])
36 | .bindFromRequest()
37 | .fold(
38 | ex => Try(throw new IllegalArgumentException(s"Binding exception - ${ex.errors.map(_.message).mkString(",")}")),
39 | xs => siteVerify(xs, secretKey, request.httpConfiguration.recaptchaConfig.timeout)
40 | )
41 |
42 | def siteVerify(captchaValues: Seq[String], recaptchaSecret: String, timeoutMSec: Int): Try[Recaptcha] =
43 | captchaValues.find(_.nonEmpty) match
44 | case Some(token) => siteVerify(token, recaptchaSecret, timeoutMSec)
45 | case None => Try(throw new IllegalArgumentException("No recaptcha token found"))
46 |
47 |
48 | /**
49 | * https://developers.google.com/recaptcha/docs/verify
50 | *
51 | * @param recaptchaToken
52 | * @param recaptchaSecret
53 | * @return - json
54 | * Recaptcha V2 response format
55 | * {
56 | * "success": true|false,
57 | * "action": string // the action name for this request (important to verify)
58 | * "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
59 | * "hostname": string, // the hostname of the site where the reCAPTCHA was solved
60 | * "error-codes": [...] // optional
61 | * }
62 | *
63 | * Recaptcha V3 response format
64 | * {
65 | * "success": true|false, // whether this request was a valid reCAPTCHA token for your site
66 | * "score": number // the score for this request (0.0 - 1.0)
67 | * "action": string // the action name for this request (important to verify)
68 | * "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
69 | * "hostname": string, // the hostname of the site where the reCAPTCHA was solved
70 | * "error-codes": [...] // optional
71 | * }
72 | */
73 | def siteVerify(recaptchaToken: String, recaptchaSecret: String, timeoutMSec: Int): Try[Recaptcha] =
74 | //POST to googleVerifyURL using query-params
75 | Try:
76 | if recaptchaToken == null || recaptchaToken.isBlank then throw new IllegalArgumentException("recaptchaToken missing")
77 | else
78 | val content = s"secret=${URLEncoder.encode(recaptchaSecret, StandardCharsets.UTF_8)}&response=${URLEncoder.encode(recaptchaToken, StandardCharsets.UTF_8)}"
79 | logger.debug(s"siteVerify content:$content")
80 |
81 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMSec)).build()
82 | .send(
83 | HttpRequest.newBuilder(URI.create("https://www.google.com/recaptcha/api/siteverify"))
84 | .POST(HttpRequest.BodyPublishers.ofString(content))
85 | .header("content-type", MediaType.FORM_DATA.toString)
86 | .build,
87 | HttpResponse.BodyHandlers.ofString()
88 | )
89 |
90 | /*
91 | * Json format - success, challenge_ts, hostname, score, action
92 | */
93 | if resp.statusCode() != 200 then
94 | val msg = s"Recaptcha response error ${resp.statusCode()} -${resp.body}"
95 | logger.error(msg)
96 | throw IllegalStateException(msg)
97 | else
98 | logger.debug(s"siteVerify response - status:${resp.statusCode()}, content-type:${resp.headers().firstValue("content-type")}, content:${resp.body()}")
99 | new Recaptcha(Json.parse(resp.body()))
100 |
101 | case class Recaptcha(jsValue: JsValue):
102 | def success:Boolean =
103 | (jsValue \ "success").asOpt[Boolean]
104 | .getOrElse(false)
105 |
106 | def fail: Boolean = !success
107 |
108 | /**
109 | * Only for V3, for V2 is None
110 | * @return
111 | */
112 | def scoreOpt:Option[Double] =
113 | (jsValue \ "score").asOpt[Double]
114 |
115 | def score: Double = scoreOpt.getOrElse(if success then 1 else 0)
116 |
117 | def actionOpt: Option[String] =
118 | (jsValue \ "action").asOpt[String]
119 |
120 | def challengeTS: Option[Instant] =
121 | (jsValue \ "challenge_ts").asOpt[String]
122 | .flatMap(ts => Try(Instant.parse(ts)).toOption)
123 |
124 | def hostname: String =
125 | (jsValue \ "hostname").asOpt[String].getOrElse("")
126 |
127 | def errorCodes: List[String] =
128 | (jsValue \ "error-codes").asOpt[List[String]]
129 | .getOrElse(Nil)
130 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/RequestAttrs.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.time.ZoneId
20 |
21 | object RequestAttrs:
22 | import io.netty.util.AttributeKey
23 | val TZ = AttributeKey.valueOf[ZoneId]("tz")
24 | val Session = AttributeKey.valueOf[Session]("session")
25 | val Flash = AttributeKey.valueOf[Flash]("flash")
26 | val Config = AttributeKey.valueOf[Configuration]("config")
27 | val Request = AttributeKey.valueOf[Request]("request")
28 | val CSRFToken = AttributeKey.valueOf[String]("csrf-token")
29 | val RecaptchaResponse = AttributeKey.valueOf[Recaptcha]("recaptcha-response")
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/ResponseHeader.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.time.ZoneOffset
20 | import java.time.format.DateTimeFormatter
21 | import scala.collection.immutable.TreeMap
22 |
23 | object ResponseHeader:
24 | val basicDateFormatPattern = "EEE, dd MMM yyyy HH:mm:ss"
25 | val httpDateFormat: DateTimeFormatter =
26 | DateTimeFormatter
27 | .ofPattern(basicDateFormatPattern + " 'GMT'")
28 | .withLocale(java.util.Locale.ENGLISH)
29 | .withZone(ZoneOffset.UTC)
30 |
31 | def apply(headers: Map[String, String]): ResponseHeader = apply(headers, null)
32 |
33 | private object CaseInsensitiveOrdered extends Ordering[String]:
34 | def compare(left: String, right: String): Int =
35 | left.compareToIgnoreCase(right)
36 |
37 | def apply(headers: Map[String, String], reasonPhrase: String): ResponseHeader =
38 | val ciHeaders = TreeMap[String, String]()(using CaseInsensitiveOrdered) ++ headers
39 | new ResponseHeader(ciHeaders, Option(reasonPhrase))
40 |
41 | case class ResponseHeader(headers: TreeMap[String, String], reasonPhrase:Option[String] = None)
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Resultable.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.commons.json.Json
20 | import com.linecorp.armeria.common.{Cookie, HttpStatus, MediaType}
21 | import io.netty.util.AsciiString
22 |
23 | import java.io.InputStream
24 | import java.time.ZonedDateTime
25 |
26 | trait Resultable[A <: String | InputStream | Array[Byte] ]:
27 | extension (a: A)
28 | def withHeaders(headers: (String | AsciiString, String)*): Result
29 |
30 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result
31 |
32 | def discardingHeader(name: String): Result
33 |
34 | def as(contentType: MediaType): Result
35 |
36 | def as(status: HttpStatus, contentType: MediaType): Result
37 |
38 | def withCookies(cookies: Cookie*): Result
39 |
40 | def discardingCookies[B <: String | Cookie](cookies: B*)(using request: Request): Result
41 |
42 | def withSession(session: Session): Result
43 |
44 | def withSession(sessions: (String, String)*): Result
45 |
46 | def withNewSession: Result
47 |
48 | def flashing(flash: Flash): Result
49 |
50 | def flashing(values: (String, String)*): Result
51 |
52 | def session(using request: Request): Session
53 |
54 | def addingToSession(values: (String, String)*)(using request: Request): Result
55 |
56 | def removingFromSession(keys: String*)(using request: Request): Result
57 |
58 |
59 | given StringResultable: Resultable[String] with
60 | extension (s: String)
61 | def withHeaders(headers: (String | AsciiString, String)*): Result =
62 | Result(s).withHeaders(headers *)
63 |
64 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result =
65 | Result(s).withDateHeaders(headers *)
66 |
67 | def discardingHeader(name: String): Result =
68 | Result(s).discardingHeader(name)
69 |
70 | def as(contentType: MediaType): Result =
71 | Result(s).as(contentType)
72 |
73 | def as(status: HttpStatus, contentType: MediaType): Result =
74 | Result(s).as(status, contentType)
75 |
76 | def withCookies(cookies: Cookie*): Result =
77 | Result(s).withCookies(cookies*)
78 |
79 | def discardingCookies[A <: String | Cookie](cookies: A*)(using request: Request): Result =
80 | Result(s).discardingCookies(cookies*)
81 |
82 | def withSession(session: Session): Result =
83 | Result(s).withSession(session)
84 |
85 | def withSession(sessions: (String, String)*): Result =
86 | Result(s).withSession(sessions*)
87 |
88 | def withNewSession: Result =
89 | Result(s).withSession()
90 |
91 | def flashing(flash: Flash): Result =
92 | Result(s).flashing(flash)
93 |
94 | def flashing(values: (String, String)*): Result =
95 | Result(s).flashing(values*)
96 |
97 | def session(using request: Request): Session =
98 | Result(s).session
99 |
100 | def addingToSession(values: (String, String)*)(using request: Request): Result =
101 | Result(s).addingToSession(values *)
102 |
103 | def removingFromSession(keys: String*)(using request: Request): Result =
104 | Result(s).removingFromSession(keys *)
105 |
106 | def jsonPrettyPrint: String =
107 | Json.prettyPrint(Json.parse(s))
108 |
109 | given InputStreamResultable: Resultable[InputStream] with
110 |
111 | extension (is: InputStream)
112 | def withHeaders(headers: (String | AsciiString, String)*): Result =
113 | Result(is).withHeaders(headers*)
114 |
115 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result =
116 | Result(is).withDateHeaders(headers *)
117 |
118 | def discardingHeader(name: String): Result =
119 | Result(is).discardingHeader(name)
120 |
121 | def as(contentType: MediaType): Result =
122 | Result(is).as(contentType)
123 |
124 | def as(status: HttpStatus, contentType: MediaType): Result =
125 | Result(is).as(status, contentType)
126 |
127 | def withCookies(cookies: Cookie*): Result =
128 | Result(is).withCookies(cookies *)
129 |
130 | def discardingCookies[A <: String | Cookie](cookies: A*)(using request: Request): Result =
131 | Result(is).discardingCookies(cookies *)
132 |
133 | def withSession(session: Session): Result =
134 | Result(is).withSession(session)
135 |
136 | def withSession(sessions: (String, String)*): Result =
137 | Result(is).withSession(sessions *)
138 |
139 | def withNewSession: Result =
140 | Result(is).withSession()
141 |
142 | def flashing(flash: Flash): Result =
143 | Result(is).flashing(flash)
144 |
145 | def flashing(values: (String, String)*): Result =
146 | Result(is).flashing(values *)
147 |
148 | def session(using request: Request): Session =
149 | Result(is).session
150 |
151 | def addingToSession(values: (String, String)*)(using request: Request): Result =
152 | Result(is).addingToSession(values *)
153 |
154 | def removingFromSession(keys: String*)(using request: Request): Result =
155 | Result(is).removingFromSession(keys *)
156 |
157 | given ArrayBytesResultable: Resultable[Array[Byte]] with
158 |
159 | extension (bytes: Array[Byte])
160 | def withHeaders(headers: (String | AsciiString, String)*): Result =
161 | Result(bytes).withHeaders(headers*)
162 |
163 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result =
164 | Result(bytes).withDateHeaders(headers *)
165 |
166 | def discardingHeader(name: String): Result =
167 | Result(bytes).discardingHeader(name)
168 |
169 | def as(contentType: MediaType): Result =
170 | Result(bytes).as(contentType)
171 |
172 | def as(status: HttpStatus, contentType: MediaType): Result =
173 | Result(bytes).as(status, contentType)
174 |
175 | def withCookies(cookies: Cookie*): Result =
176 | Result(bytes).withCookies(cookies *)
177 |
178 | def discardingCookies[A <: String | Cookie](cookies: A*)(using request: Request): Result =
179 | Result(bytes).discardingCookies(cookies *)
180 |
181 | def withSession(session: Session): Result =
182 | Result(bytes).withSession(session)
183 |
184 | def withSession(sessions: (String, String)*): Result =
185 | Result(bytes).withSession(sessions *)
186 |
187 | def withNewSession: Result =
188 | Result(bytes).withSession()
189 |
190 | def flashing(flash: Flash): Result =
191 | Result(bytes).flashing(flash)
192 |
193 | def flashing(values: (String, String)*): Result =
194 | Result(bytes).flashing(values *)
195 |
196 | def session(using request: Request): Session =
197 | Result(bytes).session
198 |
199 | def addingToSession(values: (String, String)*)(using request: Request): Result =
200 | Result(bytes).addingToSession(values *)
201 |
202 | def removingFromSession(keys: String*)(using request: Request): Result =
203 | Result(bytes).removingFromSession(keys *)
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/Session.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.time.Instant
20 | import scala.annotation.targetName
21 |
22 | inline val SessionID = "sessionID"
23 |
24 | object Session:
25 | def apply(): Session =
26 | Session(Map.empty.withDefaultValue(""), Instant.now)
27 |
28 | def apply(data: Map[String, String]): Session =
29 | Session(data.withDefaultValue(""), Instant.now)
30 |
31 |
32 | /**
33 | *
34 | * HTTP Session.
35 | * Session data are encoded into an HTTP cookie, and can only contain simple String values.
36 | * @param data
37 | */
38 | case class Session(data: Map[String, String], created: Instant):
39 | export data.{- as _, apply as _, *}
40 |
41 | def idOpt: Option[String] = data.get(SessionID)
42 |
43 | def apply(key: String): String = data(key)
44 |
45 | /**
46 | * Returns a new session with the given key-value pair added.
47 | *
48 | * For example:
49 | * {{{
50 | * session + ("username" -> "bob")
51 | * }}}
52 | *
53 | * @param kv the key-value pair to add
54 | * @return the modified session
55 | */
56 | @targetName("add")
57 | def +(kv: (String, String)): Session =
58 | require(kv._2 != null, s"Session value for ${kv._1} cannot be null")
59 | copy(data + kv)
60 |
61 | @targetName("add")
62 | def +(name: String, value: String): Session =
63 | this.+((name, value))
64 |
65 | @targetName("add")
66 | def +(newSession: Session): Session =
67 | copy(data = data ++ newSession.data)
68 |
69 | @targetName("add")
70 | def +(newSession: Map[String, String]): Session =
71 | copy(data = data ++ newSession)
72 |
73 | /**
74 | * Returns a new session with elements added from the given `Iterable`.
75 | *
76 | * @param kvs an `Iterable` containing key-value pairs to add.
77 | */
78 | @targetName("concat")
79 | def ++(kvs: Iterable[(String, String)]): Session =
80 | for ((k, v) <- kvs) require(v != null, s"Session value for $k cannot be null")
81 | copy(data ++ kvs)
82 |
83 | /**
84 | * Returns a new session with the given key removed.
85 | *
86 | * For example:
87 | * {{{
88 | * session - "username"
89 | * }}}
90 | *
91 | * @param key the key to remove
92 | * @return the modified session
93 | */
94 | @targetName("minus")
95 | def -(key: String): Session = copy(data - key)
96 |
97 | /**
98 | * Returns a new session with the given keys removed.
99 | *
100 | * For example:
101 | * {{{
102 | * session -- Seq("username", "name")
103 | * }}}
104 | *
105 | * @param keys the keys to remove
106 | * @return the modified session
107 | */
108 | @targetName("diff")
109 | def --(keys: Iterable[String]): Session = copy(data -- keys)
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/decorators/RecaptchaGuardModule.scala:
--------------------------------------------------------------------------------
1 |
2 | package com.greenfossil.thorium.decorators
3 |
4 |
5 | import com.greenfossil.thorium.{Recaptcha, RequestAttrs}
6 | import com.linecorp.armeria.common.{HttpMethod, HttpRequest}
7 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext}
8 | import org.slf4j.LoggerFactory
9 |
10 | import java.util.concurrent.CompletableFuture
11 | import scala.util.{Failure, Success}
12 |
13 | object RecaptchaGuardModule:
14 |
15 | private val DefaultAllPathsVerifyPredicate =
16 | (_: String, _: ServiceRequestContext) => true
17 |
18 | def apply():RecaptchaGuardModule =
19 | new RecaptchaGuardModule(DefaultAllPathsVerifyPredicate)
20 |
21 | /**
22 | * pathVerifyPredicate is true implies that the path's will be verified against Recaptcha service.
23 | * If false, implies that Recaptcha will not be verified.
24 | * @param pathVerifyPredicate
25 | * @return
26 | */
27 | def apply(pathVerifyPredicate: (String, ServiceRequestContext) => Boolean): RecaptchaGuardModule =
28 | new RecaptchaGuardModule(pathVerifyPredicate)
29 |
30 | /**
31 | * pathVerifyPredicate is true implies that the path's will be verified against Recaptcha service.
32 | * If false, implies that Recaptcha will not be verified.
33 | * @param pathVerifyPredicate - (Request Path, ServiceRequestContext) => Boolean
34 | */
35 | class RecaptchaGuardModule(pathVerifyPredicate: (String, ServiceRequestContext) => Boolean) extends ThreatGuardModule:
36 |
37 | protected val logger = LoggerFactory.getLogger("com.greenfossil.thorium.recaptcha")
38 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] =
39 | if HttpMethod.GET.equals(req.method) || !pathVerifyPredicate(ctx.path(), ctx) then CompletableFuture.completedFuture(true)
40 | else
41 | val config = ctx.attr(RequestAttrs.Config)
42 | val recaptchaSecretKey = config.httpConfiguration.recaptchaConfig.secretKey
43 | val recaptchaTokenName = config.httpConfiguration.recaptchaConfig.tokenName
44 | logger.trace(s"Recaptcha isSafe - uri:${ctx.uri}, method:${req.method}, recaptchaTokenName:$recaptchaTokenName, ecaptchaV3SecretKey = $recaptchaSecretKey")
45 | extractTokenValue(ctx, recaptchaTokenName)
46 | .thenApply: recaptchaToken =>
47 | val isSafe = Recaptcha.siteVerify(recaptchaToken, recaptchaSecretKey, config.httpConfiguration.recaptchaConfig.timeout) match
48 | case Failure(exception) =>
49 | logger.warn("isSafe excception", exception)
50 | false
51 | case Success(recaptchaResponse) =>
52 | logger.debug(s"recaptchaResponse = $recaptchaResponse")
53 | ctx.setAttr(RequestAttrs.RecaptchaResponse, recaptchaResponse)
54 | recaptchaResponse.success
55 | val msg = s"Request isSafe:$isSafe, method:${req.method}, uri:${req.uri} path:${req.path} content-type:${req.contentType}"
56 | if isSafe then logger.debug(msg)
57 | else
58 | logger.warn(msg)
59 | req.headers.forEach((key, value) => logger.warn(s"Header:$key value:$value"))
60 | isSafe
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/decorators/ThreatGuardModule.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium.decorators
19 |
20 | import com.greenfossil.thorium.FormUrlEncodedParser
21 | import com.linecorp.armeria.common.multipart.Multipart
22 | import com.linecorp.armeria.common.{HttpMethod, HttpRequest, MediaType}
23 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext}
24 | import org.slf4j.Logger
25 |
26 | import java.util.concurrent.CompletableFuture
27 | import scala.util.Using
28 |
29 | class AndThreatGuardModule(moduleA: ThreatGuardModule, moduleB: ThreatGuardModule) extends ThreatGuardModule:
30 |
31 | override protected val logger: Logger = null
32 |
33 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] =
34 | moduleA.isSafe(delegate, ctx, req)
35 | .thenCombine(moduleB.isSafe(delegate, ctx, req), (isSafeA, isSafeB) => isSafeA && isSafeB)
36 |
37 | class OrThreatGuardModule(moduleA: ThreatGuardModule, moduleB: ThreatGuardModule) extends ThreatGuardModule:
38 | override protected val logger: Logger = null
39 |
40 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] =
41 | moduleA.isSafe(delegate, ctx, req)
42 | .thenCombine(moduleB.isSafe(delegate, ctx, req), (isSafeA, isSafeB) => isSafeA || isSafeB)
43 |
44 | trait ThreatGuardModule:
45 |
46 | protected val logger: Logger
47 |
48 | //Need to allow, on success response callback
49 | def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean]
50 |
51 | def and(nextModule: ThreatGuardModule): ThreatGuardModule = AndThreatGuardModule(this, nextModule)
52 |
53 | def or(nextModule: ThreatGuardModule): ThreatGuardModule = OrThreatGuardModule(this, nextModule)
54 |
55 | def isAssetPath(ctx: ServiceRequestContext): Boolean =
56 | ctx.request().path().startsWith("/assets") && HttpMethod.GET == ctx.method()
57 |
58 | protected def extractTokenValue(ctx: ServiceRequestContext, tokenName: String): CompletableFuture[String] =
59 | val mediaType = ctx.request().contentType()
60 | val futureResp = new CompletableFuture[String]()
61 | logger.debug(s"Extract token from request - content-type:${ctx.request().contentType()}")
62 | if mediaType == null then
63 | logger.warn("Null media type found. Treat token as null")
64 | futureResp.complete(null)
65 | else if mediaType.is(MediaType.FORM_DATA) then
66 | ctx.request()
67 | .aggregate()
68 | .thenAccept: aggReq =>
69 | ctx.blockingTaskExecutor().execute(() => {
70 | //Extract token from FormData
71 | val form = FormUrlEncodedParser.parse(aggReq.contentUtf8())
72 | val token = form.get(tokenName).flatMap(_.find(!_.isBlank)).orNull
73 | logger.trace(s"Found Token:$token, content-type:$mediaType.")
74 | futureResp.complete(token)
75 | })
76 | else if mediaType.isMultipart then
77 | ctx.request()
78 | .aggregate()
79 | .thenAccept: aggReg =>
80 | ctx.blockingTaskExecutor().execute(() => {
81 | //Extract token from Multipart
82 | Multipart.from(aggReg.toHttpRequest.toDuplicator.duplicate())
83 | .aggregate()
84 | .thenAccept: multipart =>
85 | val partOpt = Using.resource(multipart.fields(tokenName).stream())(_.filter(!_.contentUtf8().isBlank).findFirst())
86 | val token = if partOpt.isEmpty then null else partOpt.get().contentUtf8()
87 | logger.trace(s"Found Token:$token, content-type:$mediaType.")
88 | futureResp.complete(token)
89 | })
90 |
91 | else {
92 | logger.info(s"Token found unsupported for content-type:$mediaType.")
93 | futureResp.complete(null)
94 | }
95 | futureResp
96 |
--------------------------------------------------------------------------------
/src/main/scala/com/greenfossil/thorium/decorators/ThreatGuardModuleDecoratingFunction.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium.decorators
19 |
20 | import com.greenfossil.thorium
21 | import com.greenfossil.thorium.{CookieUtil, *}
22 | import com.linecorp.armeria.common.{Request as _, *}
23 | import com.linecorp.armeria.server.{DecoratingHttpServiceFunction, HttpService, ServiceRequestContext}
24 | import org.slf4j.LoggerFactory
25 |
26 | object ThreatGuardModuleDecoratingFunction:
27 |
28 | private val logger = LoggerFactory.getLogger("com.greenfossil.thorium.csrf")
29 |
30 | private def accessDeniedHtml: String =
31 | s"""
32 | |
33 | |
34 | | Unauthorized Access
35 | |
36 | |
37 | | Access Denied
38 | |
39 | |
40 | |""".stripMargin
41 |
42 | def apply(module: ThreatGuardModule): ThreatGuardModuleDecoratingFunction =
43 | new ThreatGuardModuleDecoratingFunction(module, (_, _) => accessDeniedHtml)
44 |
45 | class ThreatGuardModuleDecoratingFunction(module: ThreatGuardModule, accessDeniedFn: (Configuration, ServiceRequestContext) => String) extends DecoratingHttpServiceFunction:
46 |
47 | import ThreatGuardModuleDecoratingFunction.logger
48 |
49 | private def accessDeniedResponse(svcReqContext: ServiceRequestContext): HttpResponse =
50 | val config = svcReqContext.attr(RequestAttrs.Config)
51 |
52 | //Remove session and assume it is a CSRF attack
53 | import config.httpConfiguration.*
54 |
55 | //discard all cookies
56 | val discardCookieNames = List(sessionConfig.cookieName, csrfConfig.cookieName, flashConfig.cookieName, "tz")
57 | logger.info(s"""Discard cookies:${discardCookieNames.mkString("[",",","]")}""")
58 |
59 | val headers =
60 | ResponseHeaders.builder(HttpStatus.UNAUTHORIZED)
61 | .contentType(MediaType.HTML_UTF_8)
62 | .cookies(CookieUtil.bakeDiscardCookies(config.httpConfiguration.cookieConfig, discardCookieNames) *)
63 | .build()
64 |
65 | val html = accessDeniedFn(config, svcReqContext)
66 | HttpResponse.of(headers, HttpData.ofUtf8(html))
67 |
68 | override def serve(delegate: HttpService, svcReqContext: ServiceRequestContext, req: HttpRequest): HttpResponse =
69 | HttpResponse.of:
70 | module.isSafe(delegate, svcReqContext, req)
71 | .handle: (isSafe, ex) =>
72 | if ex != null then
73 | logger.warn("Exception raised", ex)
74 | accessDeniedResponse(svcReqContext)
75 | else if !isSafe then
76 | logger.warn("IsSafe validation fails")
77 | accessDeniedResponse(svcReqContext)
78 | else
79 | logger.debug(s"Request isSafe... forward request to delegate:${delegate}")
80 | delegate.serve(svcReqContext, req)
81 |
--------------------------------------------------------------------------------
/src/test/resources/application.conf:
--------------------------------------------------------------------------------
1 | app {
2 | env = Test
3 | http {
4 | port = 0
5 | secret.key = "this is thorium!"
6 | recaptchaV2.secretKey = ""
7 | recaptchaV2.siteKey = ""
8 | recaptchaV3.secretKey = ""
9 | recaptchaV3.siteKey = ""
10 | recaptcha = ${app.http.recaptchaV2}
11 |
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/test/resources/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Greenfossil/thorium/1a068bdbf46a1d66ae34170e4e5f10f8d37da5ba/src/test/resources/favicon.png
--------------------------------------------------------------------------------
/src/test/resources/image-no-ext:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Greenfossil/thorium/1a068bdbf46a1d66ae34170e4e5f10f8d37da5ba/src/test/resources/image-no-ext
--------------------------------------------------------------------------------
/src/test/resources/logback-test.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
20 |
21 | %-5level %logger{36} - [%thread] - %msg%n
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/AESUtilSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.util.Base64
20 |
21 | @SerialVersionUID(1L)
22 | case class User(id: Long, name: String) extends Serializable
23 |
24 | class AESUtilSuite extends munit.FunSuite {
25 |
26 | val ALGO = AESUtil.AES_CTR_NOPADDING
27 |
28 | test("Base64 Encrypt then Decrypt using generateKey") {
29 | val text = "thorium 钍"
30 | val key = AESUtil.generateKey(128)
31 | val ivSpec = AESUtil.generateIV
32 | val algo = ALGO
33 | val cipherText = AESUtil.encrypt(text, key, algo, ivSpec, Base64.getEncoder)
34 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getDecoder)
35 | assertNoDiff(plainText, text)
36 | }
37 |
38 | test("Base64 Encrypt then decrypt using Password"){
39 | val text = "Thorium 钍"
40 | val password = "thorium"
41 | val salt = "12345678"
42 | val key = AESUtil.getSaltedKeyFromPassword(password, salt)
43 | val ivSpec = AESUtil.generateIV
44 | val algo = ALGO
45 | val cipherText = AESUtil.encrypt(text, key, algo, ivSpec, Base64.getEncoder)
46 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getDecoder)
47 | assertNoDiff(plainText, text)
48 | }
49 |
50 | test("URLBase64 Encrypt then Decrypt using generateKey") {
51 | val text = "thorium 钍"
52 | val key = AESUtil.generateKey(128)
53 | val ivSpec = AESUtil.generateIV
54 | val algo = ALGO
55 | val cipherText = AESUtil.encrypt(text, key, algo, ivSpec, Base64.getUrlEncoder)
56 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getUrlDecoder)
57 | assertNoDiff(plainText, text)
58 | }
59 |
60 | test("URLBase64 Encrypt then decrypt using Password") {
61 | val input = "Thorium 钍"
62 | val password = "thorium"
63 | val salt = "12345678"
64 | val key = AESUtil.getSaltedKeyFromPassword(password, salt)
65 | val ivSpec = AESUtil.generateIV
66 | val algo = ALGO
67 | val cipherText = AESUtil.encrypt(input, key, algo, ivSpec, Base64.getUrlEncoder)
68 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getUrlDecoder)
69 | assertNoDiff(plainText, input)
70 | }
71 |
72 | test("Encrypt then decrypt of an object") {
73 | val homer = User(42, "Homer")
74 | val key = AESUtil.generateKey(128)
75 | val iv = AESUtil.generateIV
76 | val algo = ALGO
77 | val sealedObject = AESUtil.encryptObject(homer, key, algo, iv)
78 | val obj = AESUtil.decryptObject[User](sealedObject, key, algo, iv)
79 | assertEquals(obj, homer)
80 | }
81 |
82 | test("Sign and verify object") {
83 | val homer = User(42, "Homer")
84 | val algo = "DSA"
85 | val keyPair = AESUtil.generateKeyPair(algo, 1024)
86 | val signedObj = AESUtil.signObject(homer, keyPair.getPrivate, algo)
87 | assertEquals(AESUtil.verifyObject(signedObj, keyPair.getPublic, algo), true)
88 | }
89 |
90 | test("encrypt/decrypt with Derived Key") {
91 | val input = "Thorium 钍"
92 | val key = "thorium"
93 | val cipherText = AESUtil.encryptWithEmbeddedIV(input, key, Base64.getEncoder)
94 | val plainText = AESUtil.decryptWithEmbeddedIV(cipherText, key, Base64.getDecoder)
95 | assertNoDiff(plainText, input)
96 | }
97 |
98 | test("encrypt/decrypt with Derived Key") {
99 | val input = "Thorium 钍"
100 | val key = "thorium"
101 | val cipherText = AESUtil.encryptWithEmbeddedIV(input, key, AESUtil.AES_GCM_NOPADDING, Base64.getEncoder)
102 | val plainText = AESUtil.decryptWithEmbeddedIV(cipherText, key, AESUtil.AES_GCM_NOPADDING, Base64.getDecoder)
103 | assertNoDiff(plainText, input)
104 | }
105 |
106 | test("SecretKey to Base64 and back conversion") {
107 | // Generate a 256-bit key
108 | val originalKey = AESUtil.generateKey(256)
109 |
110 | // Convert SecretKey to Base64
111 | val base64Key: String = AESUtil.secretKeyToBase64(originalKey, Base64.getEncoder)
112 |
113 | // Convert Base64 back to SecretKey
114 | val decodedKey = AESUtil.base64ToSecretKey(base64Key, Base64.getDecoder)
115 |
116 | // Verify the conversion
117 | assertEquals(originalKey.getEncoded.toSeq, decodedKey.getEncoded.toSeq)
118 | }
119 |
120 | test("generateBase64Key") {
121 | val base64Key = AESUtil.generateBase64Key(256, Base64.getEncoder)
122 | println(s"base64Key.length = ${base64Key.length}")
123 | println(s"base64Key = ${base64Key}")
124 | }
125 |
126 | test("Base64Encoding with and without padding"){
127 | val bytes = "".getBytes
128 | val paddingEncode = Base64.getEncoder.encodeToString(bytes)
129 | val withoutPaddingEncode = Base64.getEncoder.withoutPadding().encodeToString(bytes)
130 | println(s"paddingEncode = ${paddingEncode} len:${paddingEncode.length}")
131 | println(s"withoutPadding = ${withoutPaddingEncode} len:${withoutPaddingEncode.length}")
132 | }
133 |
134 | }
135 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/AccessLoggerSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import ch.qos.logback.classic.spi.ILoggingEvent
20 | import ch.qos.logback.classic.{Level, Logger}
21 | import ch.qos.logback.core.read.ListAppender
22 | import com.greenfossil.commons.json.{JsObject, Json}
23 | import com.linecorp.armeria.common.MediaType
24 | import com.linecorp.armeria.server.annotation.{Get, Param}
25 | import munit.IgnoreSuite
26 | import org.slf4j.LoggerFactory
27 |
28 | import java.net.{URI, http}
29 | import java.time.Duration
30 | import java.util.Optional
31 | import scala.language.implicitConversions
32 |
33 | private object LoggerService:
34 | @Get("/log-appender")
35 | def logAppender(@Param msg:String)(req: Request) =
36 | s"content:$msg"
37 | end LoggerService
38 |
39 | @IgnoreSuite
40 | class AccessLoggerSuite extends munit.FunSuite:
41 |
42 | var server: Server = null
43 |
44 | //List appender for Logging events
45 | lazy val logger: Logger = LoggerFactory.getLogger("com.linecorp.armeria.logging.access").asInstanceOf[ch.qos.logback.classic.Logger]
46 |
47 | lazy val listAppender: ListAppender[ILoggingEvent] = new ListAppender()
48 |
49 | lazy val logs: java.util.List[ILoggingEvent] = listAppender.list
50 |
51 | override def beforeAll(): Unit =
52 | listAppender.start()
53 | logger.addAppender(listAppender)
54 | logger.setLevel(Level.INFO)
55 | server= Server(0).addServices(LoggerService).start()
56 |
57 | override def afterAll(): Unit =
58 | server.stop()
59 | listAppender.stop()
60 |
61 |
62 | test("Request Response Access Logging".flaky) {
63 |
64 | http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build().send(
65 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/log-appender?msg=HelloWorld!"))
66 | .header("Content-Type", MediaType.PLAIN_TEXT.toString)
67 | .build(),
68 | http.HttpResponse.BodyHandlers.ofString()
69 | )
70 |
71 | Thread.sleep(2000)
72 | def findEvent(dir: String, method: String, path: String): Optional[ILoggingEvent] =
73 | logs.stream()
74 | .filter(x => x.getMessage.contains(dir) && x.getMessage.contains(method) && x.getMessage.contains(path))
75 | .findFirst()
76 |
77 | import util.control.Breaks.*
78 | var cnt = 0
79 | breakable{
80 | while(true) {
81 | println(s"cnt = ${cnt} log.size:${logs.size}")
82 | if findEvent("request", "GET", "/log-appender").isPresent && findEvent("response", "GET", "/log-appender").isPresent || cnt > 5 then
83 | println(s"Breaking out of loop. log.size:${logs.size}")
84 | break()
85 | else
86 | cnt += 1
87 | println(s"Sleeping for 2 seconds.. cnt:${cnt}, log.size:${logs.size}")
88 | Thread.sleep(2000)
89 | }
90 | }
91 | val requestMsg = findEvent("request", "GET", "/log-appender").get.getMessage
92 | val responseMsg = findEvent("response", "GET", "/log-appender").get.getMessage
93 | println(s"requestMsg = ${requestMsg}")
94 | println(s"responseMsg = ${responseMsg}")
95 |
96 | val requestJson = (Json.parse(requestMsg) \ "request").as[JsObject]
97 | val responseJson = (Json.parse(responseMsg) \ "response").as[JsObject]
98 |
99 | //checks the request data
100 | assert((requestJson \ "timestamp").as[String].nonEmpty)
101 | assert((requestJson \ "requestId").as[String].nonEmpty)
102 | assert((requestJson \ "clientIP").as[String].nonEmpty)
103 | assert((requestJson \ "remoteIP").as[String].nonEmpty)
104 | assert((requestJson \ "proxiedDestinationAddresses").toString.nonEmpty)
105 |
106 | assert((requestJson \ "headers").as[String].nonEmpty)
107 | assert((requestJson \ "method").as[String].equalsIgnoreCase("GET"))
108 | assert((requestJson \ "query").as[String].nonEmpty)
109 | assert((requestJson \ "scheme").as[String].nonEmpty)
110 | assert((requestJson \ "requestStartTimeMillis").toString.nonEmpty)
111 | println(s""" = ${(requestJson \ "path").as[String]}""")
112 | assert((requestJson \ "path").as[String].equalsIgnoreCase("/log-appender"))
113 | assert((requestJson \ "requestLength").as[Int].equals(0))
114 |
115 | //checks the response data
116 | assert((responseJson \ "timestamp").as[String].nonEmpty)
117 | assert((responseJson \ "requestId").as[String].nonEmpty)
118 | assert((responseJson \ "clientIP").as[String].nonEmpty)
119 | assert((responseJson \ "remoteIP").as[String].nonEmpty)
120 | assert((responseJson \ "proxiedDestinationAddresses").toString.nonEmpty)
121 |
122 | assert((responseJson \ "headers").as[String].nonEmpty)
123 | assert((responseJson \ "statusCode").as[Int].equals(200))
124 | assert((responseJson \ "method").as[String].equalsIgnoreCase("GET"))
125 | assert((responseJson \ "query").as[String].nonEmpty)
126 | assert((responseJson \ "scheme").as[String].nonEmpty)
127 | assert((responseJson \ "responseStartTimeMillis").as[String].nonEmpty)
128 | assert((responseJson \ "path").as[String].equalsIgnoreCase("/log-appender"))
129 | assert((responseJson \ "responseLength").as[Int].equals(19))
130 |
131 | }
132 |
133 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/AddAnnotatedServiceSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.{Get, Param, Post}
20 |
21 | import java.net.{URI, http}
22 | import scala.language.implicitConversions
23 |
24 | private object Service:
25 |
26 | @Get("/foo")
27 | def foo(@Param msg:String)(req: Request) =
28 | s"${req.path} content:$msg"
29 |
30 | @Get("/bar")
31 | def bar(@Param msg:String)(implicit req: Request) =
32 | s"${req.path} content:$msg"
33 |
34 | @Get("/baz/:name")
35 | def baz(@Param name: String)(implicit req: Request) =
36 | s"${req.path} name:$name"
37 |
38 | @Post("/foobaz/:name")
39 | def foobaz(form: FormUrlEndcoded)(implicit req: Request) =
40 | s"${req.path} form:${form} content:${req.asText}"
41 |
42 | end Service
43 |
44 | class AddAnnotatedServiceSuite extends munit.FunSuite:
45 | import com.linecorp.armeria.common.*
46 |
47 | var server: Server = null
48 |
49 | override def beforeAll(): Unit = {
50 | server = Server(0)
51 | .addServices(Service)
52 | .start()
53 | }
54 |
55 | override def afterAll(): Unit = {
56 | Thread.sleep(1000)
57 | server.stop()
58 | }
59 |
60 | test("foo") {
61 | val resp = http.HttpClient.newHttpClient().send(
62 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/foo?msg=HelloWorld!"))
63 | .header("Content-Type", MediaType.PLAIN_TEXT.toString)
64 | .build(),
65 | http.HttpResponse.BodyHandlers.ofString()
66 | )
67 | assertNoDiff(resp.body(), "/foo content:HelloWorld!")
68 | }
69 |
70 | test("bar") {
71 | val resp = http.HttpClient.newHttpClient().send(
72 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/bar?msg=HelloWorld!"))
73 | .header("Content-Type", MediaType.PLAIN_TEXT.toString)
74 | .build(),
75 | http.HttpResponse.BodyHandlers.ofString()
76 | )
77 | assertNoDiff(resp.body(), "/bar content:HelloWorld!")
78 | }
79 |
80 | test("baz") {
81 | val resp = http.HttpClient.newHttpClient().send(
82 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/baz/homer"))
83 | .header("Content-Type", MediaType.PLAIN_TEXT.toString)
84 | .build(),
85 | http.HttpResponse.BodyHandlers.ofString()
86 | )
87 | assertNoDiff(resp.body(), "/baz/homer name:homer")
88 | }
89 |
90 | test("foobaz") {
91 | val resp = http.HttpClient.newHttpClient().send(
92 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/foobaz/homer"))
93 | .POST(http.HttpRequest.BodyPublishers.ofString("msg[]=Hello&msg[]=World!"))
94 | .header("Content-Type", MediaType.FORM_DATA.toString)
95 | .build(),
96 | http.HttpResponse.BodyHandlers.ofString()
97 | )
98 | assertNoDiff(resp.body(), "/foobaz/homer form:FormUrlEndcoded(ListMap(msg[] -> List(Hello, World!))) content:msg[]=Hello&msg[]=World!")
99 | }
100 |
101 | test("foobaz - wrong media-type") {
102 | val resp = http.HttpClient.newHttpClient().send(
103 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/foobaz/homer"))
104 | .POST(http.HttpRequest.BodyPublishers.ofString("msg[]=Hello&msg[]=World!"))
105 | .header("Content-Type", MediaType.JSON.toString)
106 | .build(),
107 | http.HttpResponse.BodyHandlers.ofString()
108 | )
109 | assertEquals(resp.statusCode(), HttpStatus.BAD_REQUEST.code())
110 | }
111 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/AddHttpServiceSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.commons.json.Json
20 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
21 |
22 | import java.io.ByteArrayInputStream
23 | import java.net.{CookieManager, URI, http}
24 | import scala.language.implicitConversions
25 |
26 | class AddHttpServiceSuite extends munit.FunSuite{
27 | import com.linecorp.armeria.common.*
28 |
29 | var server: Server = null
30 |
31 | override def beforeAll(): Unit = {
32 | server = Server(0)
33 | .addHttpService("/text", Action { req =>
34 | val method = req.method
35 | if req.asText == "Hello Armeria!" && method == HttpMethod.POST then Ok("Received Text")
36 | else BadRequest("Did not receive the right text")
37 | })
38 | .addHttpService("/json", Action { req =>
39 | val method = req.method
40 | val json = req.asJson
41 | val msgOpt = (json \ "msg").asOpt[String]
42 | if msgOpt.contains("Hello Armeria!") && method == HttpMethod.POST then Ok("Received Text")
43 | else BadRequest("Did not receive the right text")
44 | })
45 | .addHttpService("/form", Action { req =>
46 | val method = req.method
47 | val form = req.asFormUrlEncoded
48 | val msg = form.getOrElse("msg[]", Nil)
49 | if msg == Seq("Hello", "Armeria!") && method == HttpMethod.POST then Ok("Received Text")
50 | else BadRequest("Did not receive the right text")
51 | })
52 | .addHttpService("/multipart-form", Action { req =>
53 | req.asMultipartFormData(mpForm => {
54 | val form = mpForm.asFormUrlEncoded
55 | if form.nonEmpty then Ok(s"Received Text $form")
56 | else BadRequest("Did not receive the right text")
57 | })
58 | })
59 | .addHttpService("/cookie", Action { req =>
60 | val cookie1 = Cookie.ofSecure("cookie1", "one")
61 | val cookie2 = Cookie.ofSecure("cookie2", "two")
62 | Ok("Here are your cookies").withCookies(cookie1, cookie2)
63 | })
64 | .addHttpService("/json2", Action {req =>
65 | Json.obj(
66 | "msg" -> "HelloWorld!"
67 | )
68 | })
69 | .start()
70 | }
71 |
72 | override def afterAll(): Unit = {
73 | Thread.sleep(1000)
74 | server.stop()
75 | }
76 |
77 | test("Text") {
78 | val resp = http.HttpClient.newHttpClient().send(
79 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/text"))
80 | .POST(http.HttpRequest.BodyPublishers.ofString("Hello Armeria!"))
81 | .header("Content-Type", MediaType.PLAIN_TEXT.toString)
82 | .build(),
83 | http.HttpResponse.BodyHandlers.ofString()
84 | )
85 | assertNoDiff(resp.body(), "Received Text")
86 | }
87 |
88 | test("Json"){
89 | val resp = http.HttpClient.newHttpClient().send(
90 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/json"))
91 | .POST(http.HttpRequest.BodyPublishers.ofString(Json.obj("msg" -> "Hello Armeria!").stringify))
92 | .header("Content-Type", MediaType.JSON.toString)
93 | .build(),
94 | http.HttpResponse.BodyHandlers.ofString()
95 | )
96 | assertNoDiff(resp.body(), "Received Text")
97 | }
98 |
99 | test("FormUrlEncoded"){
100 | val resp = http.HttpClient.newHttpClient().send(
101 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/form"))
102 | .POST(http.HttpRequest.BodyPublishers.ofString("msg[]=Hello&msg[]=Armeria!"))
103 | .header("Content-Type", MediaType.FORM_DATA.toString)
104 | .build(),
105 | http.HttpResponse.BodyHandlers.ofString()
106 | )
107 | assertNoDiff(resp.body(), "Received Text")
108 | }
109 |
110 | test("Multipart Form") {
111 | // val fileURI = getClass.getClassLoader.getResource("sample.png").toURI
112 | // val file = new File(fileURI)
113 |
114 | val mpPub = MultipartFormDataBodyPublisher()
115 | .add("name1", "hello1")
116 | .add("name1", "hello2")
117 | .addStream("name2", "hello.txt", () => ByteArrayInputStream("hello1".getBytes))
118 | val resp = http.HttpClient.newHttpClient()
119 | .send(
120 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-form"))
121 | .POST(mpPub)
122 | .header("Content-Type", mpPub.contentType())
123 | .build(),
124 | http.HttpResponse.BodyHandlers.ofString()
125 | )
126 |
127 | assertNoDiff(resp.body(), "Received Text FormUrlEndcoded(Map(name2 -> List(hello.txt), name1 -> List(hello1, hello2)))")
128 | }
129 |
130 | test("HttpClient Cookie"){
131 | val cm = CookieManager()
132 | val resp = http.HttpClient.newBuilder().cookieHandler(cm).build().send(
133 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/cookie")).build(),
134 | http.HttpResponse.BodyHandlers.ofString()
135 | )
136 | assertEquals(cm.getCookieStore.getCookies.size, 2)
137 | cm.getCookieStore.getCookies.forEach{c =>
138 | c.getName match
139 | case "cookie1" => assertNoDiff(c.getValue, "one")
140 | case "cookie2" => assertNoDiff(c.getValue, "two")
141 | case badCookie => fail(s"Bad cookie $badCookie")
142 | }
143 | assertNoDiff(resp.body(), "Here are your cookies")
144 | }
145 |
146 | test("Json2") {
147 | val resp = http.HttpClient.newHttpClient().send(
148 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/json2")).build(),
149 | http.HttpResponse.BodyHandlers.ofString()
150 | )
151 | assertEquals(resp.statusCode(), HttpStatus.OK.code())
152 | assertEquals(resp.headers().firstValue("content-type").get, MediaType.JSON.toString)
153 | assertNoDiff(resp.body(), """{"msg":"HelloWorld!"}""")
154 | }
155 |
156 | }
157 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/AddRouteSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.commons.json.Json
20 | import com.linecorp.armeria.common.MediaType
21 |
22 | import scala.language.implicitConversions
23 |
24 | object AddRouteSuite:
25 |
26 | var server: Server = null
27 |
28 | // override def beforeAll(): Unit =
29 | @main
30 | def test =
31 | server = Server(8080)
32 | .addRoute: route =>
33 | route.get("/route/html/:name").build:
34 | Action { req =>
35 | val name = req.pathParam("name")
36 | s"Greetings ${name}!
".as(MediaType.HTML_UTF_8)
37 | }
38 | .addRoute: route =>
39 | route.post("/route/form").consumes(MediaType.FORM_DATA).build:
40 | Action { req =>
41 | val form: FormUrlEndcoded = req.asFormUrlEncoded
42 | s"Howdy ${form.getFirst("name")}"
43 | }
44 | .addRoute: route =>
45 | route.get("/route/html-tag/:name").build:
46 | Action { req =>
47 | import com.greenfossil.htmltags.*
48 | h2(s"Welcome! ${req.pathParam("name")}")
49 | }
50 | .addRoute: route =>
51 | route.get("/route/html-tag-flash/:name").build:
52 | Action { req =>
53 | import com.greenfossil.htmltags.*
54 | h2(s"Howdy! ${req.pathParam("name")}")
55 | .flashing("test" -> "test")
56 | }
57 | .addRoute: route =>
58 | route.get("/route/json/:name").build:
59 | Action{req =>
60 | Json.obj{
61 | "Welcome" -> req.pathParam("name")
62 | }
63 | }
64 | .addRoute: route =>
65 | route.get("/route/json-flash/:name").build:
66 | Action { req =>
67 | Json.obj {
68 | "Howdy" -> req.pathParam("name")
69 | }.flashing("test" -> "test")
70 | }
71 | .start()
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFGuardModule_API_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.thorium.decorators.CSRFGuardModule
20 |
21 | class CSRFGuardModule_API_Suite extends munit.FunSuite:
22 |
23 | test("csrfToken - hmac with different key") {
24 | (for {
25 | csrfToken <- CSRFGuardModule.generateCSRFToken("ABCD", "HmacSHA256", "1234")
26 | } yield
27 | val verifyResult = CSRFGuardModule.verifyHmac(csrfToken, "ABCD1", "HmacSHA256")
28 | verifyResult
29 | )
30 | .fold(
31 | ex => fail("Fail", ex),
32 | success => assert(!success)
33 | )
34 | }
35 |
36 | test("csrfToken - with same key") {
37 | (for {
38 | csrfToken <- CSRFGuardModule.generateCSRFToken("ABCD", "HmacSHA256", "1234")
39 | } yield
40 | val verifyResult = CSRFGuardModule.verifyHmac(csrfToken, "ABCD", "HmacSHA256")
41 | verifyResult
42 | )
43 | .fold(
44 | ex => fail("Fail", ex),
45 | success => assert(success)
46 | )
47 | }
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_FormData_Fail_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.MediaType
20 | import munit.IgnoreSuite
21 |
22 | import java.net.{URI, http}
23 | import java.time.Duration
24 |
25 | @IgnoreSuite
26 | class CSRFGuardModule_Post_FormData_Fail_Suite extends munit.FunSuite:
27 |
28 | //Important - allow content-length to be sent in the headers
29 | System.setProperty("jdk.httpclient.allowRestrictedHeaders", "content-length")
30 |
31 | test("SameOrigin POST /csrf/email/change"):
32 | doPost(identity)
33 |
34 |
35 | test("Null Origin POST /csrf/email/change"):
36 | doPost(_ => null)
37 |
38 |
39 | test("CrossOrigin POST /csrf/email/change"):
40 | doPost(_ => "http://another-site")
41 |
42 | private def doPost(originFn: String => String)(using loc:munit.Location) =
43 | val server = Server(0)
44 | .addServices(CSRFServices)
45 | .addCSRFGuard((_ /*origin*/, _ /*referer*/, _ /*ctx*/) => false)
46 | .start()
47 |
48 | val postEpPath = "/csrf/email/change"
49 | val target = s"http://localhost:${server.port}"
50 | val content = s"email=password"
51 |
52 | //Set up Request
53 | val requestBuilder = http.HttpRequest.newBuilder(URI.create(target + postEpPath))
54 | .POST(http.HttpRequest.BodyPublishers.ofString(content))
55 | .headers(
56 | "content-type", MediaType.FORM_DATA.toString,
57 | "content-length", content.length.toString //required to set allowRestrictedHeaders
58 | )
59 |
60 | //Set origin
61 | Option(originFn(target)).foreach(target => requestBuilder.header("Origin", target))
62 |
63 | val postResp = http.HttpClient.newBuilder().connectTimeout(Duration.ofHours(1)).build()
64 | .send(requestBuilder.build(), http.HttpResponse.BodyHandlers.ofString())
65 |
66 | assertEquals(postResp.statusCode(), 401)
67 | assertNoDiff(postResp.body(), """|
68 | |
69 | |
70 | | Unauthorized Access
71 | |
72 | |
73 | | Access Denied
74 | |
75 | |
76 | |""".stripMargin)
77 |
78 | server.stop()
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_FormData_Pass_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.thorium.decorators.CSRFGuardModule
20 | import com.linecorp.armeria.common.*
21 | import munit.IgnoreSuite
22 |
23 | import java.net.*
24 | import java.time.Duration
25 |
26 | @IgnoreSuite
27 | class CSRFGuardModule_Post_FormData_Pass_Suite extends munit.FunSuite:
28 |
29 | //Important - allow content-length to be sent in the headers
30 | System.setProperty("jdk.httpclient.allowRestrictedHeaders", "content-length")
31 |
32 | test("SameOrigin POST /csrf/email/change"):
33 | doPost(identity)
34 |
35 |
36 | test("Null Origin POST /csrf/email/change"):
37 | doPost(_ => null)
38 |
39 |
40 | test("CrossOrigin POST /csrf/email/change"):
41 | doPost(_ => "http://another-site")
42 |
43 | private def doPost(originFn: String => String)(using loc:munit.Location) =
44 | val server = Server(0)
45 | .addServices(CSRFServices)
46 | .addCSRFGuard()
47 | .start()
48 |
49 | val postEpPath = "/csrf/email/change"
50 | val csrfCookieTokenName = Configuration().httpConfiguration.csrfConfig.cookieName
51 | val target = s"http://localhost:${server.port}"
52 | val csrfCookie: Cookie = CSRFGuardModule.generateCSRFTokenCookie(Configuration(), Some("ABC"))
53 | val content = s"email=password&${csrfCookieTokenName}=${URLEncoder.encode(csrfCookie.value(), "UTF-8")}"
54 |
55 | val cm = CookieManager()
56 | val cookie = HttpCookie(csrfCookieTokenName, csrfCookie.value())
57 | cookie.setDomain(csrfCookie.domain())
58 | cookie.setPath(csrfCookie.path())
59 | cm.getCookieStore.add(URI.create(target), cookie)
60 |
61 | //Set up Request
62 | val requestBuilder = http.HttpRequest.newBuilder(URI.create(target + postEpPath))
63 | .POST(http.HttpRequest.BodyPublishers.ofString(content))
64 | .headers(
65 | "content-type", MediaType.FORM_DATA.toString,
66 | "content-length", content.length.toString //required to set allowRestrictedHeaders
67 | )
68 |
69 | //Set origin
70 | Option(originFn(target)).foreach(target => requestBuilder.header("Origin", target))
71 |
72 | val postResp = http.HttpClient.newBuilder().cookieHandler(cm).connectTimeout(Duration.ofHours(1)).build()
73 | .send(requestBuilder.build(), http.HttpResponse.BodyHandlers.ofString())
74 | assertEquals(postResp.statusCode(), 200)
75 | assertNoDiff(postResp.body(), "Password Changed")
76 |
77 |
78 | server.stop()
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_Multipart_Fail_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
20 |
21 | import java.io.ByteArrayInputStream
22 | import java.net.{URI, http}
23 |
24 | class CSRFGuardModule_Post_Multipart_Fail_Suite extends munit.FunSuite:
25 |
26 | test("SameOrigin POST"):
27 | doPost(identity)
28 |
29 | test("null Origin POST"):
30 | doPost(_ => null)
31 |
32 | test("Cross Origin POST"):
33 | doPost(_ => "http://another-site")
34 |
35 | def doPost(originFn: String => String)(using loc: munit.Location) =
36 | val server = Server(0)
37 | .addServices(CSRFServices)
38 | .addCSRFGuard((_ /*origin*/, _ /*referer*/, _ /*ctx*/) => false)
39 | .start()
40 | val port = server.port
41 |
42 | val postEpPath = "/csrf/multipart-file"
43 | val target = s"http://localhost:$port"
44 |
45 | val mpPub = MultipartFormDataBodyPublisher()
46 | .add("name", "Homer")
47 | .addStream("file", "file.txt", () => ByteArrayInputStream("Hello world".getBytes))
48 |
49 | val reqBuilder = http.HttpRequest.newBuilder(URI.create( s"$target$postEpPath"))
50 | .POST(mpPub)
51 | .header("Content-Type", mpPub.contentType())
52 |
53 | //Set Origin
54 | Option(originFn(target)).foreach(target => reqBuilder.header("Origin", target))
55 |
56 | val resp = http.HttpClient.newHttpClient()
57 | .send(
58 | reqBuilder
59 | .POST(mpPub)
60 | .build(),
61 | http.HttpResponse.BodyHandlers.ofString()
62 | )
63 |
64 | assertEquals(resp.statusCode(), 401)
65 | assertNoDiff(resp.body(), """|
66 | |
67 | |
68 | | Unauthorized Access
69 | |
70 | |
71 | | Access Denied
72 | |
73 | |
74 | |""".stripMargin)
75 |
76 | server.stop()
77 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_Multipart_Pass_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.thorium.decorators.CSRFGuardModule
20 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
21 |
22 | import java.io.ByteArrayInputStream
23 | import java.net.{CookieManager, URI, http}
24 |
25 | class CSRFGuardModule_Post_Multipart_Pass_Suite extends munit.FunSuite:
26 |
27 | test("SameOrigin POST"):
28 | doPost(identity)
29 |
30 | test("null Origin POST"):
31 | doPost(_ => null)
32 |
33 | test("Cross Origin POST"):
34 | doPost(_ => "http://another-site")
35 |
36 | def doPost(originFn: String => String)(using loc: munit.Location) =
37 | val server = Server(0)
38 | .addServices(CSRFServices)
39 | .addCSRFGuard()
40 | .start()
41 | val port = server.port
42 |
43 | val postEpPath = "/csrf/multipart-file"
44 | val target = s"http://localhost:$port"
45 | val csrfCookieTokenName = Configuration().httpConfiguration.csrfConfig.cookieName
46 |
47 | //Get the CSRFToken cookie
48 | val csrfCookie = CSRFGuardModule.generateCSRFTokenCookie(Configuration(), Some("ABC"))
49 |
50 | val cm = CookieManager()
51 | cm.getCookieStore.add(target, csrfCookie)
52 | val mpPub = MultipartFormDataBodyPublisher()
53 | .add("name", "Homer")
54 | .add(csrfCookieTokenName, csrfCookie.value())
55 | .addStream("file", "file.txt", () => ByteArrayInputStream("Hello world".getBytes))
56 |
57 | val reqBuilder = http.HttpRequest.newBuilder(URI.create(s"$target$postEpPath"))
58 | .POST(mpPub)
59 | .header("Content-Type", mpPub.contentType())
60 |
61 | //Set Origin
62 | Option(originFn(target)).foreach(target => reqBuilder.header("Origin", target))
63 |
64 | val resp = http.HttpClient.newBuilder().cookieHandler(cm).build()
65 | .send(
66 | reqBuilder
67 | .POST(mpPub)
68 | .build(),
69 | http.HttpResponse.BodyHandlers.ofString()
70 | )
71 | assertEquals(resp.statusCode(), 200)
72 | assertNoDiff(resp.body(), s"Received multipart request with files: 1, form:FormUrlEndcoded(Map(file -> List(file.txt), name -> List(Homer), APP_CSRF_TOKEN -> List(${csrfCookie.value()})))")
73 |
74 | server.stop()
75 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFMainTestService.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import java.time.Duration
20 |
21 | object CSRFMainTestService:
22 |
23 | @main
24 | def csrfMain =
25 | val server = Server(8080)
26 | .addServices(CSRFServices)
27 | .addCSRFGuard()
28 | .serverBuilderSetup(_.requestTimeout(Duration.ofHours(1)))
29 | .start()
30 |
31 | server.serviceConfigs foreach { c =>
32 | println(s"c.route() = ${c.route()}")
33 | }
34 | println(s"Server started... ${Thread.currentThread()}")
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/CSRFServices.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.MediaType
20 | import com.linecorp.armeria.server.annotation.*
21 | import com.linecorp.armeria.server.annotation.decorator.{CorsDecorator, RequestTimeout}
22 |
23 | import java.util.concurrent.TimeUnit
24 |
25 | /*
26 | * Please ensure com.greenfossil.webserver.examples.main is started
27 | */
28 | object CSRFServices:
29 |
30 | @Get("/csrf/do-change-email")
31 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS)
32 | def startChange = Action { implicit request =>
33 | s"""
34 | |
35 | |
36 | |
37 | | Change Email
38 | |
39 | |
40 | |
41 | | Change Email
42 | |
47 | |
48 | |
49 | |""".stripMargin.as(MediaType.HTML_UTF_8)
50 | }
51 |
52 |
53 | @Post("/csrf/email/change")
54 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS)
55 | def changePassword = Action : _ =>
56 | "Password Changed"
57 |
58 | @Get("/csrf/do-delete")
59 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS)
60 | def startDelete = Action { implicit request =>
61 | s"""
62 | |
63 | |
64 | |
65 | | SameOrigin
66 | |
67 | |
68 | |
69 | | Delete Action
70 | |
71 | |
72 | |
73 | |
85 | |
86 | |""".stripMargin.as(MediaType.HTML_UTF_8)
87 | }
88 |
89 | @Options
90 | @Delete("/csrf/delete/:id")
91 | @CorsDecorator(origins = Array("*"))
92 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS)
93 | def deleteResource(@Param id: String) = Action { request =>
94 | s"Deleted $id"
95 | }
96 |
97 | @Post("/csrf/multipart-file")
98 | def multipartFile: Action = Action.multipart { implicit request =>
99 | val form = request.asFormUrlEncoded
100 | val files = request.files
101 | Ok(s"Received multipart request with files: ${files.size}, form:$form")
102 | }
103 |
104 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/ContextPathSuite.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.linecorp.armeria.common
4 |
5 | import java.net.URI
6 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
7 |
8 | trait AbstractTestServices(hostname: String):
9 | import com.linecorp.armeria.server.annotation.{Get, Param}
10 |
11 | @Get("/echo/hello")
12 | def echo(using request: Request) = s"${hostname}: hello!"
13 |
14 | @Get("/hi")
15 | def redirect(using request: Request) =
16 | Redirect(sayHello("User")) //Redirect will not work as the prefix is missing.
17 |
18 | @Get("/sayHello/:name")
19 | def sayHello(@Param name: String): String =
20 | s"${hostname}: Hello $name"
21 |
22 | object ContextPathTestServices extends AbstractTestServices("local")
23 |
24 | class ContextPathSuite extends munit.FunSuite:
25 |
26 | private def httpSend(url: String): String =
27 | HttpClient.newBuilder()
28 | .followRedirects(HttpClient.Redirect.NORMAL)
29 | .build()
30 | .send(
31 | HttpRequest.newBuilder(URI.create(url)).build(),
32 | HttpResponse.BodyHandlers.ofString()
33 | ).body()
34 |
35 | test("contextPath"){
36 | val server = Server()
37 | .serverBuilderSetup{_.contextPath("/v1")
38 | .service("/foo", (ctx, req) => common.HttpResponse.of("v1 foo"))
39 | .service("/bar", Action {req => s"v1 ${req.path}" })
40 | .annotatedService(ContextPathTestServices)
41 | .and
42 | .contextPath("/v2")
43 | .service("/foo", (ctx, req) => common.HttpResponse.of("v2 foo"))
44 | }
45 |
46 | val started = server.start()
47 | println("Sever started")
48 |
49 | started.printRoutes
50 |
51 | //Test context-path request
52 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/foo"), "v1 foo")
53 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/bar"), "v1 /v1/bar")
54 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/echo/hello"), "local: hello!")
55 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/hi"), """Status: 404
56 | |Description: Not Found""".stripMargin)
57 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v2/foo"), "v2 foo")
58 |
59 | val stopped = started.stop()
60 | println("Stopped.")
61 | }
62 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/DocServiceMain.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.linecorp.armeria.server.annotation.{Get, Param}
21 |
22 | object MyAnnotatedService {
23 |
24 | @Get("/foo/:name")
25 | def foo(@Param name: String) = s"Hello ${name}"
26 |
27 | }
28 | object DocServiceMain{
29 |
30 | def main(args: Array[String]): Unit = {
31 | Server(8080)
32 | .addServices(MyAnnotatedService)
33 | .addDocService()
34 | .start()
35 | .printRoutes
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/EndpointMacro1Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.thorium.*
20 | import com.linecorp.armeria.server.annotation.{Get, Param, Path, Post}
21 |
22 | import java.time.LocalDateTime
23 |
24 | class EndpointMacro1Suite extends munit.FunSuite {
25 |
26 | @Get("/endpoint1")
27 | def endpoint1 = Action { _ => "endpoint1" }
28 |
29 | @Get("/endpoint2/:name")
30 | def endpoint2(@Param name : String) = Action { _ => "endpoint2"}
31 |
32 | @Get("/endpoint3") //query string
33 | def endpoint3(@Param name: String) = Action { _ => "endpoint3"}
34 |
35 | @Get("/endpoint4/:id")
36 | def endpoint4(@Param id: Long, @Param name: String) = Action { _ => "endpoint3"}
37 |
38 | @Post("/endpoint4/:name")
39 | def endpoint4(@Param members: Seq[String], @Param name: String, @Param time: LocalDateTime) = Action { _ => "endpoint5"}
40 |
41 | @Post
42 | @Path("/endpoint5/:id/:name")
43 | def endpoint5(@Param id: Long, @Param name: String) = Action { _ => "endpoint5" }
44 |
45 | test("annotated endpoint"){
46 | val ep1 = endpoint1.endpoint
47 | assertNoDiff(ep1.url, "/endpoint1")
48 | assertNoDiff(ep1.method, "Get")
49 |
50 | val ep2 = endpoint2("homer").endpoint
51 | assertNoDiff(ep2.url, "/endpoint2/homer")
52 | assertEquals(ep2.pathPatternOpt, Some("/endpoint2/:name"))
53 | assertNoDiff(ep2.method, "Get")
54 |
55 | val ep3 = endpoint3("homer").endpoint
56 | assertNoDiff(ep3.url, "/endpoint3?name=homer")
57 | assertNoDiff(ep3.method, "Get")
58 | assertEquals(ep3.queryParams.size, 1)
59 |
60 | val now = LocalDateTime.now
61 | val members = Seq("Marge Simpson", "Bart Simpson", "Maggie Simpson")
62 | val ep4 = endpoint4(members, "homer", now).endpoint
63 | assertNoDiff(ep4.url, "/endpoint4/homer?" +
64 | Endpoint.paramKeyValue("members", members) + "&" +
65 | Endpoint.paramKeyValue("time", now.toString))
66 | assertNoDiff(ep4.method, "Post")
67 | assertEquals(ep4.queryParams.size, 2)
68 | }
69 |
70 | test("url-encoded param Val string"){
71 | val valToken = "Homer/Marge Simpson"
72 | val url = endpoint2(valToken).endpoint.url
73 |
74 | val url2 = endpoint2("Homer/Marge Simpson").endpoint.url
75 |
76 | assertNoDiff(url, url2)
77 | assertNoDiff(url, "/endpoint2/Homer%2FMarge%20Simpson")
78 | }
79 |
80 | test("url-encoded param val string (with path and query param)") {
81 | val valToken = "Homer/Marge Simpson"
82 |
83 | val url = endpoint4(1, valToken).endpoint.url
84 |
85 | val url2 = endpoint4(1, "Homer/Marge Simpson").endpoint.url
86 |
87 | assertNoDiff(url, url2)
88 | assertNoDiff(url, "/endpoint4/1?name=Homer%2FMarge%20Simpson")
89 | }
90 |
91 | test("url-encoded param val string (with query param)") {
92 | val valToken = "Homer/Marge Simpson"
93 |
94 | val url = endpoint3(valToken).endpoint.url
95 |
96 | val url2 = endpoint3("Homer/Marge Simpson").endpoint.url
97 |
98 | assertNoDiff(url, url2)
99 | assertNoDiff(url, "/endpoint3?name=Homer%2FMarge%20Simpson")
100 | }
101 |
102 |
103 | test("url-encoded param Def string") {
104 | def defToken = "Homer/Marge Simpson"
105 | val url = endpoint3(defToken).endpoint.url
106 |
107 | val url2 = endpoint3("Homer/Marge Simpson").endpoint.url
108 |
109 | assertNoDiff(url, url2)
110 | assertNoDiff(url, "/endpoint3?name=Homer%2FMarge%20Simpson")
111 | }
112 |
113 | test("endpoint with Path annotation"){
114 | val endpoint = endpoint5(1, "Homer").endpoint
115 | assertEquals(endpoint.url, "/endpoint5/1/Homer")
116 | assertEquals(endpoint.method, "Post")
117 | }
118 |
119 | }
120 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/EndpointMacro2Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.{Get, Param}
20 |
21 | class EndpointMacro2Suite extends munit.FunSuite {
22 |
23 | @Get("/endpoint-params/:int/:str")
24 | def endpointParams(@Param int: Int)(@Param str: String) = "endpoint1"
25 |
26 | @Get("/endpoint-using")
27 | def endpointUsing(using request: Request) =
28 | s"endpoint Int:${request.path}"
29 |
30 | @Get("/endpoint-redirect1")
31 | def endpointRedirect1(using request: Request) =
32 | Redirect(endpointRedirect2)
33 |
34 | @Get("/endpoint-redirect2")
35 | def endpointRedirect2(using request: Request) =
36 | "Howdy"
37 |
38 | @Get("/endpoint-redirect3")
39 | def endpointRedirect3(using request: Request) =
40 | Redirect(endpointRedirect4(1))
41 |
42 | @Get("/endpoint-redirect4")
43 | def endpointRedirect4(int: Int)(using request: Request) =
44 | "Howdy2"
45 |
46 | test("multi-param-list") {
47 | val epParams = EndpointMcr(endpointParams(1)("string"))
48 | assertNoDiff(epParams.url, "/endpoint-params/1/string")
49 | }
50 |
51 | test("using"){
52 | given Request = null
53 | val epUsing = EndpointMcr(endpointUsing)
54 | assertNoDiff(epUsing.url, "/endpoint-using")
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/EndpointMacro3Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.{Get, Param}
20 |
21 | import java.net.URI
22 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
23 |
24 | object TestServices:
25 | @Get("/sayHello/:name")
26 | def sayHello(@Param name: String)(using request: Request): String =
27 | s"Hello $name"
28 |
29 | @Get("/redirect")
30 | def redirect(using request: Request): Result =
31 | Redirect(sayHello("User"))
32 |
33 | @Get("/path")
34 | def path(request: Request): Result =
35 | val url = request.refererOpt.getOrElse("/redirect")
36 | Redirect(url)
37 |
38 | class EndpointMacro3Suite extends munit.FunSuite:
39 |
40 | test("redirect") {
41 | val server = Server(0)
42 | .addServices(TestServices)
43 | .start()
44 |
45 | val resp = HttpClient.newHttpClient()
46 | .send(
47 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/redirect")).build(),
48 | HttpResponse.BodyHandlers.ofString()
49 | )
50 | assertEquals(resp.statusCode(), 303)
51 | assertNoDiff(resp.headers().firstValue("location").get(), "/sayHello/User")
52 | server.stop()
53 | }
54 |
55 | test("path") {
56 | val server = Server(0)
57 | .addServices(TestServices)
58 | .start()
59 |
60 | val resp = HttpClient.newHttpClient()
61 | .send(
62 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/path")).build(),
63 | HttpResponse.BodyHandlers.ofString()
64 | )
65 | assertEquals(resp.statusCode(), 303)
66 | assertNoDiff(resp.headers().firstValue("location").get(), "/redirect")
67 | server.stop()
68 | }
69 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/EndpointMacroParameterizedServicesSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.{Get, Param}
20 |
21 | /*
22 | *Test path with prefix, regex, glob
23 | */
24 | class EndpointMacroParameterizedServicesSuite extends munit.FunSuite {
25 | object ParameterizedServices {
26 | @Get("prefix:/howdy")
27 | def prefixEndpoint = "prefix - howdy"
28 |
29 | @Get("/braced-params/{name}/{age}/:contact")
30 | def bracedParams(@Param name: String, @Param age: Int, @Param contact: String) =
31 | s"braced-param name:${name} age:${age} contact:${contact}"
32 |
33 | @Get("regex:^/string/(?[^0-9]+)$") //This path is different without ^$ "regex:/string/(?[^0-9]+)"
34 | def regexStringEndpoint(@Param name: String) = s"regexString - name:$name"
35 |
36 | @Get("regex:^/number/(?[0-9]+)$")
37 | def regexNumberEndpoint(@Param n1: Int) = s"regexNumber - $n1"
38 |
39 | @Get("regex:/string2/(?\\w+)/(?\\w+)")
40 | def regexString2Endpoint(@Param min: String, @Param max: String) = s"regexString2 - min:$min - max:$max"
41 |
42 | @Get("regex:/number2/(?\\d+)/(?\\d+)")
43 | def regexNumber2Endpoint(@Param min: Int, @Param max: Int) = s"regexNumber2 - min:$min - max:$max"
44 |
45 | @Get("regex:/mix/(?\\d+)/(?\\w+)")
46 | def regexMix1Endpoint(@Param min: Int, @Param max: String) = s"regexMix1-Int,String - min:$min - max:$max"
47 |
48 | @Get("regex:/mix/(?\\w+)/(?\\d+)")
49 | def regexMix2Endpoint(@Param min: String, @Param max: Int) = s"regexMix2-String,Int - min:$min - max:$max"
50 | }
51 |
52 | test("prefix endpoint") {
53 | val prefixEp = EndpointMcr(ParameterizedServices.prefixEndpoint)
54 | assertNoDiff(prefixEp.url, "/howdy")
55 | assertNoDiff(prefixEp.method, "Get")
56 | }
57 |
58 | test("braced param"){
59 | val bracedEndpoint = EndpointMcr(ParameterizedServices.bracedParams("homer simpson",42, "spring/field"))
60 | assertNoDiff(bracedEndpoint.url, "/braced-params/homer%20simpson/42/spring%2Ffield")
61 | }
62 |
63 | test("regex string endpoint") {
64 | val regexStringEp = EndpointMcr(ParameterizedServices.regexStringEndpoint("homer"))
65 | assertNoDiff(regexStringEp.url, "/string/homer")
66 | assertNoDiff(regexStringEp.method, "Get")
67 | }
68 |
69 | test("regex number endpoint") {
70 | def num = 5
71 | val regexNumEp = EndpointMcr(ParameterizedServices.regexNumberEndpoint(num))
72 | assertNoDiff(regexNumEp.url, "/number/5")
73 | assertNoDiff(regexNumEp.method, "Get")
74 | }
75 |
76 | test("regex string2 endpoint") {
77 | def minValue = "min"
78 | def maxValue = "max"
79 | val regexString2Ep = EndpointMcr(ParameterizedServices.regexString2Endpoint(minValue, maxValue))
80 | assertNoDiff(regexString2Ep.url, "/string2/min/max")
81 | assertNoDiff(regexString2Ep.method, "Get")
82 | }
83 |
84 | test("regex number2 endpoint") {
85 | def arg1 = 10
86 | def arg2 = 20
87 | val regexNumber2Ep = EndpointMcr(ParameterizedServices.regexNumber2Endpoint(arg1, arg2))
88 | assertNoDiff(regexNumber2Ep.url, "/number2/10/20")
89 | assertNoDiff(regexNumber2Ep.method, "Get")
90 | }
91 |
92 | test("regex mix endpoint") {
93 | def arg1 = 10
94 | def arg2 = "last"
95 | val regexMix1Ep = EndpointMcr(ParameterizedServices.regexMix1Endpoint(arg1, arg2))
96 | assertNoDiff(regexMix1Ep.url, "/mix/10/last")
97 | assertNoDiff(regexMix1Ep.method, "Get")
98 | }
99 |
100 | test("regex mix2 endpoint") {
101 | def arg1 = "first"
102 | def arg2 = 20
103 | val regexMix2Ep = EndpointMcr(ParameterizedServices.regexMix2Endpoint(arg1, arg2))
104 | assertNoDiff(regexMix2Ep.url, "/mix/first/20")
105 | assertNoDiff(regexMix2Ep.method, "Get")
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/HeadersSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.{Cookie, HttpStatus}
20 | import com.linecorp.armeria.server.annotation.Get
21 |
22 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
23 | import java.net.{CookieManager, HttpCookie, URI}
24 | import scala.annotation.nowarn
25 |
26 | object HeadersServices:
27 | @Get("/headers") @nowarn
28 | def headers = Action { request =>
29 | Ok("Headers sent")
30 | .withHeaders(
31 | "Access-Control-Allow-Origin" -> "*",
32 | "Access-Control-Allow-Headers" -> "Origin, X-Requested-With, Content-Type, Accept")
33 | .withSession("session" -> "sessionValue")
34 | .flashing("flash" -> "flashValue")
35 | .withCookies(Cookie.of("Cookie1", "Cookie1Value"), Cookie.of("Cookie2", "Cookie2Value"))
36 | }
37 | end HeadersServices
38 |
39 | class HeadersSuite extends munit.FunSuite {
40 | test("header, session, flash"){
41 | val server = Server(0)
42 | .addServices(HeadersServices)
43 | .start()
44 |
45 | val cm = CookieManager()
46 | val resp = HttpClient.newBuilder().cookieHandler(cm).build().send(
47 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/headers")).build(),
48 | HttpResponse.BodyHandlers.ofString()
49 | )
50 | def findCookie(name: String): HttpCookie =
51 | val opt = cm.getCookieStore.getCookies.stream().filter(_.getName == name).findFirst()
52 | opt.orElseGet(() => null)
53 |
54 | assertNoDiff(resp.headers().firstValue("access-control-allow-origin").get, "*")
55 | assertNoDiff(resp.headers().firstValue("access-control-allow-headers").get, "Origin, X-Requested-With, Content-Type, Accept")
56 | assertEquals(resp.statusCode(), HttpStatus.OK.code())
57 | assertEquals(findCookie("APP_SESSION").getSecure, false)
58 | assertEquals(findCookie("APP_FLASH").getSecure, false)
59 | assertEquals(findCookie("Cookie1").getValue, "Cookie1Value")
60 | assertEquals(findCookie("Cookie2").getValue, "Cookie2Value")
61 | assertNoDiff(resp.body(), "Headers sent")
62 | server.stop()
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/InheritedAnnotatedPathSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.greenfossil.thorium.Sub2.foo
20 | import com.linecorp.armeria.server.annotation.Get
21 |
22 | trait Base:
23 | @Get("/base/foo")
24 | def foo = Action: _ =>
25 | "hello foo"
26 |
27 | @Get("/base/bar")
28 | def bar = Action: _ =>
29 | Redirect(foo)
30 |
31 | object Sub1 extends Base:
32 |
33 | @Get("/sub1/foo")
34 | override def foo = Action: _ =>
35 | "hello sub1 foo"
36 |
37 | @Get("/sub1/foobaz")
38 | def foobaz = Action: _ =>
39 | Redirect(foo)
40 |
41 | def foobazRedirect = EndpointMcr(foo).url
42 |
43 | object Sub2 extends Base:
44 |
45 | @Get("/sub2/foobaz")
46 | def foobaz = Action {request =>
47 | Redirect(foo)
48 | }
49 |
50 | def foobazRedirect = EndpointMcr(foo).url
51 |
52 |
53 |
54 | class InheritedAnnotatedPathSuite extends munit.FunSuite:
55 |
56 | test("inherited Impl1.foo"){
57 | val ep = Sub1.foo.endpoint
58 | assertNoDiff(ep.url, "/sub1/foo")
59 | }
60 |
61 | test("inherited Impl1.toFoo"){
62 | val redirect1 = Sub1.foobazRedirect
63 | assertNoDiff(redirect1, "/sub1/foo")
64 |
65 |
66 | val redirect2 = Sub2.foobazRedirect
67 | assertNoDiff(redirect2, "/base/foo")
68 | }
69 |
70 |
71 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/MultiPartFormSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.{HttpStatus, MediaType}
20 | import com.linecorp.armeria.server.annotation.Post
21 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
22 | import munit.FunSuite
23 |
24 | import java.net.URI
25 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
26 | import java.nio.charset.StandardCharsets
27 | import java.nio.file.{Files, Paths}
28 | import scala.sys.process.*
29 |
30 | /*
31 | * Please ensure com.greenfossil.webserver.examples.main is started
32 | */
33 | object FormServices {
34 | @Post("/multipart3")
35 | def multipartForm3: Action = Action.multipart { implicit request =>
36 | val files = request.files
37 | Ok(s"Received multipart request with files: ${files.size}")
38 | }
39 |
40 | @Post("/multipart-bad-request")
41 | def multipartFormBadRequest: Action = Action.multipart { implicit request =>
42 | BadRequest("Bad Request")
43 | }
44 |
45 | @Post("/multipart-bad-request-2")
46 | def multipartForm4: Action = Action.multipart { implicit request =>
47 | "Bad Request 2".as(HttpStatus.BAD_REQUEST, MediaType.JSON)
48 | }
49 |
50 | @Post("/multipart-internal-server-error")
51 | def multipartFormInternalServerError: Action = Action.multipart { implicit request =>
52 | InternalServerError("Internal Server Error")
53 | }
54 |
55 | }
56 |
57 | class MultiPartFormSuite extends FunSuite{
58 |
59 | var server: Server = null
60 |
61 | override def beforeAll(): Unit =
62 | try{
63 | server = Server(0)
64 | .addServices(FormServices)
65 | .start()
66 | }catch {
67 | case ex: Throwable =>
68 | }
69 |
70 | override def afterAll(): Unit =
71 | server.stop()
72 |
73 | test("POST with file content") {
74 | Files.write(Paths.get("/tmp/file.txt"), "Hello world".getBytes(StandardCharsets.UTF_8))
75 |
76 | val result = s"curl http://localhost:${server.port}/multipart3 -F resourceFile=@/tmp/file.txt ".!!.trim
77 | assertEquals(result, "Received multipart request with files: 1")
78 | }
79 |
80 | test("POST without file content but with form param") {
81 | val result = s"curl http://localhost:${server.port}/multipart3 -F name=homer ".!!.trim
82 | assertEquals(result, "Received multipart request with files: 0")
83 | }
84 |
85 | test("POST empty body raw data") {
86 | val result =
87 | (s"curl http://localhost:${server.port}/multipart3 -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryaDaB4MtEkj4a1pYx' " +
88 | "--data-raw $'------WebKitFormBoundaryaDaB4MtEkj4a1pYx--\r\n'").!!.trim
89 | assertEquals(result, "Received multipart request with files: 0")
90 | }
91 |
92 | test("POST without file content but with form param, using web browser's raw data") {
93 | val command = s"curl http://localhost:${server.port}/multipart3 -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryOrtvYGBXE2gxan8t' " +
94 | "--data-raw $'------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"resourceFile\"; " +
95 | "filename=\"\"\r\nContent-Type: application/octet-stream\r\n\r\n\r\n" +
96 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\n\r\n" +
97 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"status\"\r\n\r\nactive\r\n" +
98 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"description\"\r\n\r\n\r\n" +
99 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"tpe\"\r\n\r\n\r\n" +
100 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"spaceId\"\r\n\r\n\r\n" +
101 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"roleFilter\"\r\n\r\non\r\n" +
102 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"dtStart\"\r\n\r\n\r\n" +
103 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"dtEnd\"\r\n\r\n\r\n" +
104 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t--\r\n'"
105 | val result = command.!!.trim
106 | assertEquals(result, "Received multipart request with files: 0")
107 | }
108 |
109 | test("POST with bad request") {
110 | val mpPub = MultipartFormDataBodyPublisher().add("name", "homer")
111 | val resp = HttpClient.newHttpClient()
112 | .send(
113 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-bad-request"))
114 | .POST(mpPub)
115 | .header("Content-Type", mpPub.contentType())
116 | .build(),
117 | HttpResponse.BodyHandlers.ofString()
118 | )
119 | assertEquals(resp.statusCode(), HttpStatus.BAD_REQUEST.code())
120 | assertNoDiff(resp.body(), "Bad Request")
121 | }
122 |
123 | test("POST with bad request 2") {
124 | val mpPub = MultipartFormDataBodyPublisher().add("name", "homer")
125 | val resp = HttpClient.newHttpClient()
126 | .send(
127 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-bad-request-2"))
128 | .POST(mpPub)
129 | .header("Content-Type", mpPub.contentType())
130 | .build(),
131 | HttpResponse.BodyHandlers.ofString()
132 | )
133 | assertEquals(resp.statusCode(), HttpStatus.BAD_REQUEST.code())
134 | assertNoDiff(resp.body(), "Bad Request 2")
135 | }
136 |
137 | test("POST with internal server error") {
138 | val mpPub = MultipartFormDataBodyPublisher()
139 | .add("name", "homer")
140 | val resp = HttpClient.newHttpClient()
141 | .send(
142 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-internal-server-error"))
143 | .POST(mpPub)
144 | .header("Content-Type", mpPub.contentType())
145 | .build(),
146 | HttpResponse.BodyHandlers.ofString()
147 | )
148 | assertEquals(resp.statusCode(), HttpStatus.INTERNAL_SERVER_ERROR.code())
149 | assertNoDiff(resp.body(), "Internal Server Error")
150 | }
151 |
152 | }
153 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/MultipartFileDataBindingSuite.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.greenfossil.data.mapping.Mapping.*
4 | import com.linecorp.armeria.common.multipart.MultipartFile
5 | import com.linecorp.armeria.server.annotation.Post
6 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
7 |
8 | import java.net.URI
9 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
10 | import java.nio.file.Paths
11 |
12 | class MultipartFileDataBindingSuite extends munit.FunSuite:
13 |
14 |
15 | object FormServices:
16 | @Post("/multipart")
17 | def multipartForm: Action = Action.multipart { implicit request =>
18 | //Test the Multipart File data-binding using NotEmptyText then followed by Transformation[String, MultipartFile]
19 | val nameField = nonEmptyText.name("name")
20 | val fileField = nonEmptyText.name("file")
21 | .verifying("File name must be 'logback-test.xml'", _ == "logback-test.xml2")
22 | .transform[MultipartFile](name => request.findFileOfFileName(name).orNull, file => file.filename())
23 | .verifying("File size cannot be more than 10 bytes", f => f.sizeInBytes < 10)
24 |
25 | val nameValueOpt = nameField.bindFromRequest().typedValueOpt
26 | assertNoDiff(nameValueOpt.orNull, "homer")
27 |
28 | //file assertions
29 | val fileBindForm = fileField.bindFromRequest()
30 | assertEquals(fileBindForm.errors.size, 2)
31 |
32 | assert(fileBindForm.typedValueOpt.nonEmpty)
33 |
34 | "Received"
35 | }
36 |
37 | test("POST with file content") {
38 | //Start
39 | val server = Server(0)
40 | .addServices(FormServices)
41 | .start()
42 |
43 | val path = Paths.get("src/test/resources/logback-test.xml").toAbsolutePath
44 | val mpPub = MultipartFormDataBodyPublisher()
45 | .add("name", "homer")
46 | .addFile("file", path)
47 | val resp = HttpClient.newHttpClient()
48 | .send(
49 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart"))
50 | .POST(mpPub)
51 | .header("Content-Type", mpPub.contentType())
52 | .build(),
53 | HttpResponse.BodyHandlers.ofString()
54 | )
55 | assertNoDiff(resp.body(), "Received")
56 | //Stop server
57 | server.stop()
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/MultipartFileSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.MediaType
20 | import com.linecorp.armeria.common.multipart.MultipartFile
21 |
22 | import java.io.File
23 |
24 | class MultipartFileSuite extends munit.FunSuite {
25 |
26 | test("MultipartFile extensions for image-no-ext/png") {
27 | val r = this.getClass.getResource("/favicon.png")
28 | assert(r != null, "Resource not found")
29 | val mpFile = MultipartFile.of("file", "file",new File(r.getFile))
30 | assert(mpFile.contentType.is(MediaType.ANY_IMAGE_TYPE))
31 | assert(mpFile.contentType.is(MediaType.PNG))
32 | assertEquals(mpFile.sizeInBytes, 3711L)
33 | assertEquals(mpFile.sizeInKB, 3L)
34 | assertEquals(mpFile.sizeInMB, 0L)
35 | assertEquals(mpFile.sizeInGB, 0L)
36 | }
37 |
38 | test("MultipartFile extensions for image-no-ext without extension") {
39 | val r = this.getClass.getResource("/image-no-ext")
40 | assert(r != null, "Resource not found")
41 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile))
42 | assert(mpFile.contentType.is(MediaType.ANY_IMAGE_TYPE))
43 | assert(mpFile.contentType.is(MediaType.PNG))
44 | assertEquals(mpFile.sizeInBytes, 3711L)
45 | }
46 |
47 | test("MultipartFile extensions for text/xml") {
48 | val r = this.getClass.getResource("/logback-test.xml")
49 | assert(r != null, "Resource not found")
50 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile))
51 | assert(mpFile.contentType.is(MediaType.parse("application/xml")))
52 | }
53 |
54 | test("MultipartFile extensions for conf") {
55 | val r = this.getClass.getResource("/logback-test.xml")
56 | assert(r != null, "Resource not found")
57 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile))
58 | assert(mpFile.contentType.is(MediaType.parse("application/xml")))
59 | }
60 |
61 | test("Multipart inputStream") {
62 | val r = this.getClass.getResource("/favicon.png")
63 | assert(r != null, "Resource not found")
64 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile))
65 | 1 to 5 foreach { i =>
66 | val is = mpFile.inputStream
67 | val size = is.available()
68 | val bytes = is.readAllBytes()
69 | assertEquals(bytes.length, size)
70 | assertEquals(is.available(), 0)
71 | is.close()
72 | }
73 | }
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/NestedRedirectMacroSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.Get
20 |
21 | object NestedRedirectServices {
22 |
23 | @Get("/nestedRedirect")
24 | def nestedRedirect = Action { request2 =>
25 | //Reference class' action
26 | nestedRedirectFn(actionEnd.endpoint)
27 | }
28 |
29 | def nestedRedirectFn(ep: Endpoint): Result =
30 | Redirect(ep)
31 |
32 | @Get("/actionEnd")
33 | def actionEnd = Action{ request =>
34 | Ok("End")
35 | }
36 |
37 | }
38 |
39 | class NestedRedirectMacroSuite extends munit.FunSuite {
40 |
41 | test("Endpoint.apply()") {
42 | val ep = NestedRedirectServices.actionEnd.endpoint
43 | assertNoDiff(ep.url, "/actionEnd")
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/RecaptchaFormPage.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.greenfossil.htmltags.*
21 |
22 | import scala.language.implicitConversions
23 |
24 | object RecaptchaFormPage:
25 |
26 | def apply(endpoint: Endpoint, isMultipart: Boolean, useRecaptcha: Boolean = false)(using request: Request): Tag =
27 | val siteKey = request.httpConfiguration.recaptchaConfig.siteKey
28 | html(
29 | head(
30 | ifTrue(useRecaptcha, script(src:="https://www.google.com/recaptcha/api.js" /*async defer ? how to add this to htmltag?*/)),
31 | ifTrue(useRecaptcha, script(
32 | s"""function onSubmit(token){
33 | | document.getElementById('demo-form').submit();
34 | |}""".stripMargin
35 | ))
36 | ),
37 | body(
38 | ifTrue(request.flash.data.nonEmpty,
39 | div(style:="background:lightblue;height:40px;", "Recaptcha response:" + request.flash.data.values.mkString(","))
40 | ),
41 | hr,
42 | form(action := endpoint.url, method:="POST", id:="demo-form", ifTrue(isMultipart, enctype := "multipart/form-data"))(
43 | div(
44 | label("Blog"),
45 | input(tpe:="text", name:="blog", placeholder:="Write your blog")
46 | ),
47 | button(cls:="ui button g-recaptcha", cls:="g-recaptcha", tpe:="submit", name:="action", value:="submit", data.sitekey:=siteKey, data.callback:="onSubmit", data.action:="submit", "Submit"),
48 | button(cls:="ui button g-recaptcha", cls:="g-recaptcha", tpe:="submit", name:="action", value:="cancel", data.sitekey:=siteKey, data.callback:="onSubmit", data.action:="cancel", "Cancel")
49 | )
50 | )
51 | )
52 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/RecaptchaMainTestService.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule
4 | import com.linecorp.armeria.server.ServiceRequestContext
5 |
6 | import java.time.Duration
7 |
8 | object RecaptchaMainTestService:
9 |
10 | def recaptchaPathVerificationFn(path: String, ctx: ServiceRequestContext):Boolean =
11 | //prone to misconfigurations and not good for refactoring
12 | path.matches("/recaptcha/guarded-form|/recaptcha/multipart-form")
13 |
14 | @main
15 | def recaptchaMain =
16 | val server = Server(8080)
17 | .addServices(RecaptchaServices)
18 | .addThreatGuardModule(RecaptchaGuardModule(recaptchaPathVerificationFn))
19 | .serverBuilderSetup(_.requestTimeout(Duration.ofHours(1)))
20 | .start()
21 |
22 | server.serviceConfigs foreach { c =>
23 | println(s"c.route() = ${c.route()}")
24 | }
25 | println(s"Server started... ${Thread.currentThread()}")
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/RecaptchaPlaywright_Stress_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 |
21 | import munit.IgnoreSuite
22 |
23 | import scala.util.Random
24 |
25 | @IgnoreSuite
26 | class RecaptchaPlaywright_Stress_Suite extends munit.FunSuite:
27 |
28 | /**
29 | * Before running this testsuite, ensures that RecaptchaMainTestService is running.
30 | */
31 | import com.microsoft.playwright.*
32 |
33 | import java.util.concurrent.CountDownLatch
34 | import concurrent.ExecutionContext.Implicits.global
35 | import scala.concurrent.Future
36 |
37 | test("playwright test") {
38 | val browserType = BrowserType.LaunchOptions() //.setTimeout(2000).setHeadless(false).setSlowMo(2000)
39 | val browserCnt = 5
40 | val loop = 2
41 | val browsersTup2: List[(Browser, Int)] =
42 | (1 to browserCnt).map(j => (Playwright.create().chromium().launch(browserType), j)).toList
43 |
44 | val startLatch = CountDownLatch(1)
45 | val endLatch = CountDownLatch(loop * browserCnt)
46 |
47 | browsersTup2.foreach { (browser, index) =>
48 | submitActionPerBrowser(browser, index, loop, startLatch, endLatch)
49 | }
50 |
51 | println(s"start all requests")
52 | startLatch.countDown()
53 | println("Waiting for all requests to finish")
54 | endLatch.await()
55 | browsersTup2.foreach(_._1.close())
56 | println("Terminating test")
57 | }
58 |
59 | private def submitActionPerBrowser(browser: Browser, i: Int, loop: Int, startLatch: CountDownLatch, endLatch: CountDownLatch): Future[Unit] =
60 | Future:
61 | startLatch.await()
62 | println(s"Starting creating submit page for browser $i...")
63 | 1 to loop foreach { j =>
64 | val index: String = s"$i-$j"
65 | try {
66 | val bc = browser.newContext()
67 | val page = bc.newPage()
68 | page.navigate("http://localhost:8080/recaptcha/form-stress-test2")
69 | println(s"page index:${index} = ${page.url()}")
70 | val tag = s"N:$index"
71 | page.querySelector("input[name=blog]").fill(tag)
72 | val action = if Random().nextBoolean() then "submit" else "cancel"
73 | println(s"action = ${action}")
74 | val submitButtonSelector = s"button[data-action='$action']"
75 | page.querySelector(submitButtonSelector).click()
76 | endLatch.countDown()
77 | } catch {
78 | case ex: Throwable =>
79 | ex.printStackTrace()
80 | browser.close()
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/RecaptchaServices.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.greenfossil.thorium.{*, given}
4 | import com.linecorp.armeria.server.annotation.{Get, Post}
5 |
6 | import scala.language.implicitConversions
7 |
8 | object RecaptchaServices:
9 | /*
10 | * Note: recaptcha sign up information is in the link below, and copied here http://www.google.com/recaptcha/admin
11 | * https://developers.google.com/recaptcha
12 | * https://cloud.google.com/recaptcha-enterprise/docs/setup-overview-web
13 | * https://www.youtube.com/watch?v=dyA_Pbtbn_E - How to use Google reCaptcha v3 in your Spring Boot application
14 | * vue-recaptcha v3
15 | * Recaptcha V2 Invisible
16 | * Client side integration - https://developers.google.com/recaptcha/docs/invisible
17 | * Server side integration - https://developers.google.com/recaptcha/docs/verify
18 | */
19 |
20 | /**
21 | * explicit Recaptcha verification Recaptcha#verify done in the post method
22 | * @return
23 | */
24 | @Get("/recaptcha/form")
25 | def recaptchaForm: Action = Action { implicit request =>
26 | RecaptchaFormPage(postRecaptchaForm.endpoint, isMultipart = false)
27 | }
28 |
29 | /**
30 | * explicit Recaptcha verification Recaptcha#verify done in the post method
31 | * @return
32 | */
33 | @Post("/recaptcha/form")
34 | def postRecaptchaForm: Action = Action { implicit request =>
35 | Recaptcha.verify
36 | .fold(
37 | ex => Unauthorized(s"Recaptcha exception - ${ex.getMessage}"),
38 | recaptcha =>
39 | if recaptcha.success then Redirect(recaptchaForm).flashing("success" -> s"Recaptcha response:[$recaptcha]")
40 | else Unauthorized(s"Unauthorize access - $recaptcha")
41 | )
42 | }
43 |
44 | /**
45 | * explicit Recaptcha verification Recaptcha#onVerify done in the post method
46 | *
47 | * @return
48 | */
49 | @Get("/recaptcha/form2")
50 | def recaptchaForm2: Action = Action { implicit request =>
51 | RecaptchaFormPage(postRecaptchaForm2.endpoint, isMultipart = false)
52 | }
53 |
54 | /**
55 | * explicit Recaptcha verification Recaptcha#onVerify done in the post method
56 | *
57 | * @return
58 | */
59 | @Post("/recaptcha/form2")
60 | def postRecaptchaForm2: Action = Action { implicit request =>
61 | Recaptcha.onVerified(r => r.success){
62 | Redirect(recaptchaForm2).flashing("success" -> s"Recaptcha response success:${request.recaptchaResponse}")
63 | }
64 | }
65 |
66 | @Get("/recaptcha/form-stress-test")
67 | def recaptchaFormStressTest: Action = Action { implicit request =>
68 | RecaptchaFormPage(postRecaptchaFormStressTest.endpoint, isMultipart = false)
69 | }
70 |
71 | /**
72 | * No Recaptcha guarding
73 | * @return
74 | */
75 | @Post("/recaptcha/form-stress-test")
76 | def postRecaptchaFormStressTest: Action = Action { implicit request =>
77 | import com.greenfossil.data.mapping.Mapping.*
78 | tuple("blog" -> text, "action" -> text).bindFromRequest()
79 | .fold(
80 | error => BadRequest("Bad Request"),
81 | (blog, action) =>
82 | s"Success - request #${blog} action:$action"
83 | )
84 | }
85 |
86 | @Get("/recaptcha/form-stress-test2")
87 | def recaptchaFormStressTest2: Action = Action { implicit request =>
88 | RecaptchaFormPage(postRecaptchaFormStressTest2.endpoint, isMultipart = false, useRecaptcha = true)
89 | }
90 |
91 | /**
92 | * explicit Recaptcha verification Recaptcha#onVerify done in the post method
93 | * @return
94 | */
95 | @Post("/recaptcha/form-stress-test2")
96 | def postRecaptchaFormStressTest2: Action = Action { implicit request =>
97 | Recaptcha.onVerified(r => r.success) {
98 | import com.greenfossil.data.mapping.Mapping.*
99 | tuple("blog" -> text, "g-recaptcha-response" -> seq[String]).bindFromRequest()
100 | .fold(
101 | error =>
102 | BadRequest("Bad Request " + error),
103 | (blog, xs) =>
104 | s"Success - request #${blog} g-recaptcha-response:${xs.mkString}"
105 | )
106 | }
107 | }
108 |
109 | /**
110 | * RecaptchaGuardModule
111 | *
112 | * @return
113 | */
114 | @Get("/recaptcha/guarded-form")
115 | def recaptchaGuardedForm: Action = Action { implicit request =>
116 | RecaptchaFormPage(postRecaptchaGuardedForm.endpoint, isMultipart = false)
117 | }
118 |
119 | /**
120 | * RecaptchaGuardModule
121 | *
122 | * @return
123 | */
124 | @Post("/recaptcha/guarded-form")
125 | def postRecaptchaGuardedForm: Action = Action { implicit request =>
126 | Redirect(recaptchaGuardedForm).flashing("success" -> s"Recaptcha response success:[${request.recaptchaResponse}]")
127 | }
128 |
129 |
130 | /**
131 | * RecaptchaGuardModule
132 | *
133 | * @return
134 | */
135 | @Get("/recaptcha/multipart-form")
136 | def recaptchaMultipartForm: Action = Action { implicit request =>
137 | RecaptchaFormPage(postRecaptchaMulitpartForm.endpoint, isMultipart = true)
138 | }
139 |
140 | /**
141 | * RecaptchaGuardModule
142 | *
143 | * @return
144 | */
145 | @Post("/recaptcha/multipart-form")
146 | def postRecaptchaMulitpartForm: Action = Action.multipart { implicit request =>
147 | Redirect(recaptchaMultipartForm).flashing("success" -> s"Recaptcha response success:[${request.recaptchaResponse}]")
148 | }
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/RecaptchaSuite.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.greenfossil.commons.json.Json
4 | import munit.IgnoreSuite
5 |
6 | @IgnoreSuite
7 | class RecaptchaSuite extends munit.FunSuite {
8 |
9 | test("GoogleRecaptchaUtil.siteVerify"){
10 | val v2SecretKey = ""
11 | Recaptcha.siteVerify("bad-token", v2SecretKey, 1000)
12 | .fold(
13 | ex => fail("Request should succeed", ex),
14 | recaptchaResponse =>
15 | assertNoDiff(recaptchaResponse.jsValue.stringify, """{"success":false,"error-codes":["invalid-input-secret"]}""")
16 | )
17 | }
18 |
19 | test("V2 parser"){
20 | val json = """{"success":true,"challenge_ts":"2024-01-30T09:22:27Z","hostname":"localhost","action":"submit"}"""
21 | val r = Recaptcha(Json.parse(json))
22 | assertEquals(r.success, true)
23 | assertEquals(r.scoreOpt, None)
24 | assertEquals(r.actionOpt, Option("submit"))
25 | assertEquals(r.challengeTS, Option(java.time.Instant.parse("2024-01-30T09:22:27Z")))
26 | assertNoDiff(r.hostname, "localhost")
27 | }
28 |
29 | test("V3 parser") {
30 | val json = """{"success":true,"challenge_ts":"2024-01-30T09:39:46Z","hostname":"localhost","score":0.9,"action":"submit"}"""
31 | val r = Recaptcha(Json.parse(json))
32 | assertEquals(r.success, true)
33 | assertEquals(r.scoreOpt, Option(0.9))
34 | assertEquals(r.actionOpt, Option("submit"))
35 | assertEquals(r.challengeTS, Option(java.time.Instant.parse("2024-01-30T09:39:46Z")))
36 | assertNoDiff(r.hostname, "localhost")
37 | }
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/Recaptcha_Post_Formdata_Stress_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule
21 | import com.linecorp.armeria.common.MediaType
22 |
23 | import java.net.http.{HttpClient, HttpResponse}
24 | import java.net.{URI, http}
25 | import java.time.Duration
26 |
27 |
28 | class Recaptcha_Post_Formdata_Stress_Suite extends munit.FunSuite:
29 |
30 | private def startServer(addRecaptchaGuardModule: Boolean = true): Server =
31 | val server = Server(0)
32 | .addServices(RecaptchaServices)
33 | .addThreatGuardModule( if !addRecaptchaGuardModule then null else RecaptchaGuardModule(RecaptchaMainTestService.recaptchaPathVerificationFn))
34 | server.start()
35 |
36 | test("/recaptcha/guarded-form with RecaptchaGuardModule"):
37 | val server = startServer(addRecaptchaGuardModule = true)
38 |
39 | val n = 5
40 | val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
41 | val xs = 1 to n map {i =>
42 | val req = http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form"))
43 | .POST(http.HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code"))
44 | .header("content-type", MediaType.FORM_DATA.toString)
45 | .build()
46 | val resp = client.send(req, HttpResponse.BodyHandlers.ofString())
47 | assertEquals(resp.statusCode(), 401)
48 | i
49 | }
50 |
51 | assertEquals(xs.size, n)
52 |
53 | server.stop()
54 |
55 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/Recaptcha_Post_Multipart_Stress_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule
21 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
22 |
23 | import java.net.URI
24 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
25 |
26 | class Recaptcha_Post_Multipart_Stress_Suite extends munit.FunSuite:
27 |
28 | private def startServer(addRecaptchaGuardModule: Boolean = true): Server =
29 | val server = Server(0)
30 | .addServices(RecaptchaServices)
31 | .addThreatGuardModule( if !addRecaptchaGuardModule then null else RecaptchaGuardModule(RecaptchaMainTestService.recaptchaPathVerificationFn))
32 | server.start()
33 |
34 | test("/recaptcha/multipart-form with RecaptchaGuardModule"):
35 | val server = startServer(addRecaptchaGuardModule = true)
36 |
37 | val n = 5
38 | val xs = 1 to n map { i =>
39 | val mpPub = MultipartFormDataBodyPublisher().add("g-recaptcha-response", "bad-code")
40 | val resp = HttpClient.newHttpClient()
41 | .send(
42 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/multipart-form"))
43 | .POST(mpPub)
44 | .header("Content-Type", mpPub.contentType())
45 | .build(),
46 | HttpResponse.BodyHandlers.ofString()
47 | )
48 | assertEquals(resp.statusCode(), 401)
49 | assertNoDiff(resp.body(), """|
50 | |
51 | |
52 | | Unauthorized Access
53 | |
54 | |
55 | | Access Denied
56 | |
57 | |
58 | |""".stripMargin)
59 | }
60 |
61 | assertEquals(xs.size, n)
62 |
63 | server.stop()
64 |
65 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/Recaptcha_Post_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule
21 | import com.linecorp.armeria.common.HttpStatus
22 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
23 | import munit.IgnoreSuite
24 |
25 | import java.net.URI
26 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
27 | import java.time.Duration
28 |
29 | @IgnoreSuite
30 | class Recaptcha_Post_Suite extends munit.FunSuite:
31 |
32 | private def startServer(addRecaptchaGuardModule: Boolean = true): Server =
33 | val server = Server(0)
34 | .addServices(RecaptchaServices)
35 | .addThreatGuardModule( if !addRecaptchaGuardModule then null else RecaptchaGuardModule(RecaptchaMainTestService.recaptchaPathVerificationFn))
36 | server.start()
37 |
38 | test("/recaptcha/form"):
39 | val server = startServer()
40 |
41 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
42 | .send(
43 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/form"))
44 | .POST(HttpRequest.BodyPublishers.noBody())
45 | .build(),
46 | HttpResponse.BodyHandlers.ofString()
47 | )
48 |
49 | server.stop()
50 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code())
51 | assertNoDiff(resp.body(), "Recaptcha exception - No recaptcha token found")
52 |
53 | test("/recaptcha/form - with an invalid g-recaptcha-response"):
54 | val server = startServer()
55 |
56 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
57 | .send(
58 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/form"))
59 | .POST(HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code"))
60 | .build(),
61 | HttpResponse.BodyHandlers.ofString()
62 | )
63 |
64 | server.stop()
65 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code())
66 | assertNoDiff(resp.body(), """Unauthorize access - Recaptcha({"success":false,"error-codes":["invalid-input-secret"]})""")
67 |
68 | test("/recaptcha/guarded-form with RecaptchaGuardModule"):
69 | val server = startServer()
70 |
71 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
72 | .send(
73 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form"))
74 | .POST(HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code"))
75 | .build(),
76 | HttpResponse.BodyHandlers.ofString()
77 | )
78 |
79 | server.stop()
80 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code())
81 | assertNoDiff(resp.body(), """|
82 | |
83 | |
84 | | Unauthorized Access
85 | |
86 | |
87 | | Access Denied
88 | |
89 | |
90 | |""".stripMargin)
91 |
92 | test("/recaptcha/guarded-form with no RecaptchaGuardModule"):
93 | val server = startServer(false)
94 |
95 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
96 | .send(
97 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form"))
98 | .POST(HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code"))
99 | .build(),
100 | HttpResponse.BodyHandlers.ofString()
101 | )
102 |
103 | server.stop()
104 | assertEquals(resp.statusCode(), HttpStatus.SEE_OTHER.code())
105 | assertNoDiff(resp.body(), "")
106 |
107 |
108 | test("/recaptcha/multipart-form with RecaptchaGuardModule"):
109 | val server = startServer()
110 |
111 | val mpPub = MultipartFormDataBodyPublisher().add("g-recaptcha-response", "bad-code")
112 | val resp = HttpClient.newHttpClient()
113 | .send(
114 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form"))
115 | .POST(mpPub)
116 | .header("Content-Type",mpPub.contentType())
117 | .build(),
118 | HttpResponse.BodyHandlers.ofString()
119 | )
120 |
121 | server.stop()
122 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code)
123 | assertNoDiff(resp.body(),
124 | """|
125 | |
126 | |
127 | | Unauthorized Access
128 | |
129 | |
130 | | Access Denied
131 | |
132 | |
133 | |""".stripMargin)
134 |
135 |
136 | test("/recaptcha/multipart-form without RecaptchaGuardModule"):
137 | val server = startServer(addRecaptchaGuardModule = false)
138 |
139 | val mpPub = MultipartFormDataBodyPublisher().add("g-recaptcha-response", "bad-code")
140 | val resp = HttpClient.newHttpClient()
141 | .send(
142 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form"))
143 | .POST(mpPub)
144 | .header("Content-Type", mpPub.contentType())
145 | .build(),
146 | HttpResponse.BodyHandlers.ofString()
147 | )
148 |
149 | server.stop()
150 | assertEquals(resp.statusCode(), HttpStatus.SEE_OTHER.code())
151 | assertNoDiff(resp.body(),"")
152 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/RegexEndpointSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.{Get, Param}
20 |
21 | class RegexEndpointSuite extends munit.FunSuite{
22 | object ParameterizedServices {
23 | @Get("regex:^/ci/(?(?i)Homer|Marge)")
24 | def caseInsensitivity(@Param name: String) = name
25 |
26 | @Get("regex:(?i)^/ci2/(?Homer|Marge)")
27 | def caseInsensitivity2(@Param name: String) = name
28 |
29 | @Get("regex:^/(?i)ci3(?-i)/(?Homer|Marge)")
30 | def caseInsensitivity3(@Param name: String) = name
31 |
32 | @Get("regex:^/ci4/(?(?i)Homer|Marge(?-i))")
33 | def caseInsensitivity4(@Param name: String) = name
34 | }
35 |
36 | test("caseInsensitivity"){
37 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity("homer"))
38 | assertNoDiff(ep.path, "/ci/homer")
39 | }
40 |
41 | test("caseInsensitivity2") {
42 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity2("homer"))
43 | assertNoDiff(ep.path, "/ci2/homer")
44 | }
45 |
46 | test("caseInsensitivity3") {
47 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity3("homer"))
48 | assertNoDiff(ep.path, "/ci3/homer")
49 | }
50 |
51 | test("caseInsensitivity4") {
52 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity4("homer"))
53 | assertNoDiff(ep.path, "/ci4/homer")
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/ResponseHeaderSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | class ResponseHeaderSuite extends munit.FunSuite {
20 |
21 | test("ResponseHeader case insenitivity"){
22 | val header = ResponseHeader(Map("a" -> "apple", "b" -> "basket"))
23 | assertNoDiff(header.headers("a"), "apple")
24 | assertNoDiff(header.headers("A"), "apple")
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/SessionSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.common.HttpStatus
20 | import com.linecorp.armeria.server.annotation.Get
21 |
22 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
23 | import java.net.{CookieManager, URI}
24 |
25 | object SessionServices {
26 | @Get("/s0")
27 | def s0 = Action { request =>
28 | Redirect("/s1", HttpStatus.SEE_OTHER).withNewSession.flashing("News Flash" ->"Flash Value")
29 | }
30 |
31 | @Get("/s1")
32 | def s1 = Action { request =>
33 | Redirect("/s2").withSession(request.session + ("foo" -> "foo"))
34 | }
35 |
36 | @Get("/s2")
37 | def s2 = Action {request =>
38 | Redirect("/s3").withSession(request.session + ("baz" -> "baz"))
39 | }
40 |
41 | @Get("/s3")
42 | def s3 = Action {request =>
43 | Redirect("/s4").withSession(request.session + ("foobaz" -> "foobaz"))
44 | }
45 |
46 | @Get("/s4")
47 | def s4 = Action { request =>
48 | assert(request.session.size == 3)
49 | Ok(s"S4 reached ${request.session}")
50 | }
51 | }
52 |
53 | class SessionSuite extends munit.FunSuite {
54 |
55 | test("Session, Flash propagation") {
56 | val server = Server(0)
57 | .addServices(SessionServices)
58 | .start()
59 |
60 | val cm = CookieManager()
61 | val req = HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/s0")).build()
62 | val resp = HttpClient.newBuilder()
63 | .followRedirects(HttpClient.Redirect.NORMAL)
64 | .cookieHandler(cm)
65 | .build()
66 | .send(req, HttpResponse.BodyHandlers.ofString())
67 | assert(resp.body().startsWith("S4 reached"))
68 | assertEquals(cm.getCookieStore.getCookies.size(), 1)
69 | assertNoDiff(cm.getCookieStore.getCookies.stream().filter(_.getName == "APP_SESSION").findFirst().get.getName, "APP_SESSION")
70 | server.stop()
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/ThreatGuardModule_Stress_Suite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher
21 |
22 | import java.net.URI
23 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
24 |
25 | class ThreatGuardModule_Stress_Suite extends munit.FunSuite:
26 |
27 | val tokenName = "token-name"
28 | val tokenValue = "token-value"
29 | private def startServer: Server =
30 | val server = Server(0)
31 | .addServices(ThreatGuardServices)
32 | .addThreatGuardModule(ThreatGuardTestModule(tokenName, tokenValue))
33 | server.start()
34 |
35 | test("/recaptcha/multipart-form with RecaptchaGuardModule"):
36 | val server = startServer
37 |
38 | val n = 5
39 | val xs = 1 to n map { i =>
40 | val mpPub = MultipartFormDataBodyPublisher().add(tokenName, tokenValue)
41 | val resp = HttpClient.newHttpClient()
42 | .send(
43 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/threat-guard/multipart-form"))
44 | .POST(mpPub)
45 | .header("Content-Type", mpPub.contentType())
46 | .build(),
47 | HttpResponse.BodyHandlers.ofString()
48 | )
49 | assertEquals(resp.statusCode(), 200)
50 | assertNoDiff(resp.body(), "success FormUrlEndcoded(Map(token-name -> List(token-value)))")
51 | }
52 |
53 | assertEquals(xs.size, n)
54 |
55 | server.stop()
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/ThreatGuardServices.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.linecorp.armeria.server.annotation.Post
21 |
22 | /*
23 | * To stress test multipart form
24 | */
25 | object ThreatGuardServices:
26 |
27 | @Post("/threat-guard/multipart-form")
28 | def postMulitpartForm: Action = Action.multipart { implicit request =>
29 | Ok(s"success ${request.asFormUrlEncoded}")
30 | }
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/ThreatGuardTestModule.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | *
16 | */
17 |
18 | package com.greenfossil.thorium
19 |
20 | import com.greenfossil.thorium.decorators.ThreatGuardModule
21 | import com.linecorp.armeria.common.HttpRequest
22 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext}
23 | import org.slf4j.{Logger, LoggerFactory}
24 |
25 | import java.util.concurrent.CompletableFuture
26 |
27 | class ThreatGuardTestModule(tokenName: String, tokenValue:String) extends ThreatGuardModule:
28 | override protected val logger: Logger = LoggerFactory.getLogger("threatguard-test-module")
29 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] =
30 | extractTokenValue(ctx, tokenName)
31 | .thenApply: token =>
32 | token == tokenValue
33 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/UrlPrefixSuite.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 Greenfossil Pte Ltd
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.greenfossil.thorium
18 |
19 | import com.linecorp.armeria.server.annotation.{Get, Param}
20 |
21 | object Service1:
22 | @Get("/redirect1")
23 | def redirect1 = Action{request =>
24 | Redirect(foo)
25 | }
26 |
27 | @Get("/foo")
28 | def foo = Action{request =>
29 | Ok("Foo")
30 | }
31 |
32 | object Service2:
33 | @Get("/bar")
34 | def bar = Action{request =>
35 | Ok("Bar")
36 | }
37 |
38 | @Get("/site/favicon")
39 | def favicon = Action{request =>
40 | Ok("favicon")
41 | }
42 |
43 | @Get("/param/:str")
44 | def param(@Param str: String) = Action{request =>
45 | Ok(str)
46 | }
47 |
48 | @Get("/query")
49 | def query(@Param str: String) = Action{request =>
50 | Ok(str)
51 | }
52 |
53 | @Get("regex:^/(?\\d+)$")
54 | def number(@Param num: Int) = Action{ request =>
55 | Ok(num.toString)
56 | }
57 |
58 | @Get("prefix:/test")
59 | def test = Action{request =>
60 | request.requestContext.mappedPath()
61 | Ok(request.uri.toString)
62 | }
63 |
64 | class UrlPrefixSuite extends munit.FunSuite {
65 | test("route patterns"){
66 | val server = Server(0)
67 | .addServices(Service1)
68 | .addServices(Service2)
69 | .serverBuilderSetup(sb => {
70 | sb
71 | .annotatedService("/ext", Service2)
72 | .serviceUnder("/ext2", Service1.foo)
73 | })
74 | .start()
75 |
76 | server.stop()
77 | val routePatterns = server.serviceConfigs.map(_.route().patternString())
78 | assertEquals(routePatterns.sorted, List(
79 | // direct routes
80 | "/foo", "/redirect1", "/bar",
81 | "^/(?\\d+)$", "/query", "/test/*", "/param/:str",
82 | //annotatedService routes
83 | "/site/favicon",
84 | "/ext/bar",
85 | "/ext/^/(?\\d+)$",
86 | "/ext/query",
87 | "/ext/test/*",
88 | "/ext/param/:str",
89 | "/ext/site/favicon",
90 | // serviceUnder routes
91 | "/ext2/*"
92 | ).sorted)
93 | }
94 |
95 | test("annotatedService with path prefix"){
96 | val server = Server(0)
97 | .addServices(Service1)
98 | .addServices(Service2)
99 | .serverBuilderSetup(sb => {
100 | sb
101 | .annotatedService("/ext", Service2)
102 | })
103 | .start()
104 | server.stop()
105 |
106 | assertEquals(Service2.bar.endpoint.url, "/bar")
107 | assertEquals(Service2.bar.endpoint.prefixedUrl("", server.serviceConfigs), "/ext/bar")
108 |
109 | assertEquals(Service2.param("hello").endpoint.url, "/param/hello")
110 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("", server.serviceConfigs), "/ext/param/hello")
111 |
112 | assertEquals(Service2.query("hello").endpoint.url, "/query?str=hello")
113 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("", server.serviceConfigs), "/ext/query?str=hello")
114 |
115 | assertEquals(Service2.number(1).endpoint.url, "/1")
116 | assertEquals(Service2.number(1).endpoint.prefixedUrl("", server.serviceConfigs), "/ext/1")
117 |
118 | assertEquals(Service2.test.endpoint.url, "/test")
119 | assertEquals(Service2.test.endpoint.prefixedUrl("", server.serviceConfigs), "/ext/test")
120 |
121 | //Test non macro created Endpoint
122 | assertEquals(Endpoint("/test/path").url, "/test/path")
123 | assertEquals(Endpoint("/test/path", "prefix:/test").prefixedUrl("", server.serviceConfigs), "/ext/test/path")
124 | assertEquals(Endpoint("/test/path", Service2.test.endpoint.pathPatternOpt.get).prefixedUrl("", server.serviceConfigs), "/ext/test/path")
125 | }
126 |
127 | test("prefixUrl2 - with prefix routes"){
128 | val server = Server(0)
129 | .addServices(Service2)
130 | .serverBuilderSetup(sb => {
131 | sb.annotatedService("/ext", Service2)
132 | sb.annotatedService("/ext2", Service2)
133 | })
134 | .start()
135 | server.stop()
136 | //Ensures the Endpoint path has prepended with prefix '/ext' depending request path
137 | //1. without prefix
138 | assertEquals(Endpoint("/test/foo", "prefix:/test").prefixedUrl("/test/bar", server.serviceConfigs), "/test/foo")
139 | //2. with prefix /ext
140 | assertEquals(Endpoint("/test/foo", "prefix:/test").prefixedUrl("/ext/test/bar", server.serviceConfigs), "/ext/test/foo")
141 | //3. with prefix /ext2
142 | assertEquals(Endpoint("/test/foo", "prefix:/test").prefixedUrl("/ext2/test/bar", server.serviceConfigs), "/ext2/test/foo")
143 | }
144 |
145 | test("prefixUrl2 - favicon route") {
146 | val server = Server(0)
147 | .addServices(Service2)
148 | .serverBuilderSetup(sb => {
149 | sb.annotatedService("/ext", Service2)
150 | sb.annotatedService("/ext2", Service2)
151 | })
152 | .start()
153 | server.stop()
154 | assertEquals(Service2.favicon.endpoint.prefixedUrl("/bar", server.serviceConfigs), "/site/favicon")
155 | assertEquals(Service2.favicon.endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/site/favicon")
156 | }
157 |
158 | test("prefixUrl2 - with parameters") {
159 | val server = Server(0)
160 | .addServices(Service2)
161 | .serverBuilderSetup(sb => {
162 | sb.annotatedService("/ext", Service2)
163 | sb.annotatedService("/ext2", Service2)
164 | })
165 | .start()
166 | server.stop()
167 |
168 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("/bar", server.serviceConfigs), "/param/hello")
169 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/param/hello")
170 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("/ext2/bar", server.serviceConfigs), "/ext2/param/hello")
171 |
172 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("/bar", server.serviceConfigs), "/query?str=hello")
173 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/query?str=hello")
174 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("/ext2/bar", server.serviceConfigs), "/ext2/query?str=hello")
175 |
176 | assertEquals(Service2.number(1).endpoint.prefixedUrl("/bar", server.serviceConfigs), "/1")
177 | assertEquals(Service2.number(1).endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/1")
178 | assertEquals(Service2.number(1).endpoint.prefixedUrl("/ext2/bar", server.serviceConfigs), "/ext2/1")
179 | }
180 |
181 |
182 | test("serviceUnder with path prefix".ignore){
183 | val server = Server(0)
184 | .addServices(Service1)
185 | .addServices(Service2)
186 | .serverBuilderSetup(sb => {
187 | sb
188 | .serviceUnder("/ext", Service2.bar)
189 | })
190 | .start()
191 | server.stop()
192 |
193 | assertEquals(Service2.bar.endpoint.url, "/bar")
194 | assertEquals(Service2.bar.endpoint.prefixedUrl("", server.serviceConfigs), "/ext/bar")
195 | }
196 |
197 | }
198 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/VirtualHostSuite.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium
2 |
3 | import com.linecorp.armeria.common
4 | import com.linecorp.armeria.common.SessionProtocol
5 |
6 | import java.net.URI
7 | import java.net.http.{HttpClient, HttpRequest, HttpResponse}
8 |
9 | object Host1Services extends AbstractTestServices("host1")
10 |
11 | object Host2Services extends AbstractTestServices("host2")
12 |
13 |
14 | class VirtualHostSuite extends munit.FunSuite:
15 |
16 | private def httpSend(url: String): String =
17 | HttpClient.newBuilder()
18 | .followRedirects(HttpClient.Redirect.NORMAL)
19 | .build()
20 | .send(
21 | HttpRequest.newBuilder(URI.create(url)).build(),
22 | HttpResponse.BodyHandlers.ofString()
23 | ).body()
24 |
25 | test("contextPath") {
26 | val server = Server(8080)
27 | .serverBuilderSetup {
28 | _.port(8081, SessionProtocol.HTTP)
29 | .port(8082, SessionProtocol.HTTP)
30 | .virtualHost(8081)
31 | .annotatedService(Host1Services)
32 | .and
33 | .virtualHost(8082)
34 | .annotatedService(Host2Services)
35 | }
36 |
37 | val started = server.start()
38 | println("Sever started")
39 |
40 | started.printRoutes
41 |
42 | assertNoDiff(httpSend(s"http://localhost:8081/echo/hello"), "host1: hello!")
43 | assertNoDiff(httpSend(s"http://localhost:8081/hi"), "host1: Hello User")
44 | assertNoDiff(httpSend(s"http://localhost:8082/echo/hello"), "host2: hello!")
45 | assertNoDiff(httpSend(s"http://localhost:8082/hi"), "host2: Hello User")
46 |
47 | val stopped = started.stop()
48 | println("Stopped.")
49 | }
50 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/contextPath/ContextPathSuite.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium.contextPath
2 |
3 | class ContextPathSuite extends munit.FunSuite:
4 |
5 | test("ContextPath compilation") {
6 | import com.linecorp.armeria.common.HttpResponse
7 | import com.linecorp.armeria.server.Server
8 |
9 | /*
10 | * Inssue raised -
11 | * https://github.com/scala/scala3/issues/21797
12 | * https://github.com/line/armeria/issues/5947
13 | */
14 | val server = Server.builder()
15 | .contextPath("/v1")
16 | .service("/", (ctx, req) => HttpResponse.of("Hello, world"))
17 | .and()
18 | .build()
19 |
20 | val started = server.start()
21 | started.get()
22 | server.stop()
23 | }
24 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/macros/AnnotatedActionMacroSupportSuite.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium.macros
2 |
3 | import com.greenfossil.thorium.*
4 | import com.linecorp.armeria.server.annotation.decorator.RequestTimeout
5 | import com.linecorp.armeria.server.annotation.{Get, Param, Path, Post}
6 |
7 | import java.util.concurrent.TimeUnit
8 |
9 | class AnnotatedActionMacroSupportSuite extends munit.FunSuite:
10 |
11 | @RequestTimeout(value=10, unit= TimeUnit.MINUTES) /*This is ignored, as only HttpMethod Annotation is processed*/
12 | @Get("/action-ep")
13 | def actionEp = Action { _ => "action-endpoint" }
14 |
15 | @Get("/req-func-ep1")
16 | def reqFuncEp1(request: Request) = s"using endpoint Int:${request.path}"
17 |
18 | @Get("/req-func-ep2/:msg")
19 | def reqFuncEp2(@Param msg: String)(req: Request) = s"${req.path} content:$msg"
20 |
21 | @Get("/req-func-ep3")
22 | def reqFuncEp3(@Param msg: String) = (req: Request) => s"${req.path} content:$msg"
23 |
24 | @Post
25 | @Path("/req-func-ep4/:id/:name")
26 | def reqFuncEp4(@Param id: Long, @Param name: String) = Action { _ => "reqFuncEp4" }
27 |
28 | @Get
29 | @Path("/req-func-ep5/:id/:name")
30 | def reqFuncEp5(@Param id: Long, @Param name: String) = Action { _ => "reqFuncEp5" }
31 |
32 | @Get("/endpoint-params/:int/:str")
33 | def endpointParams(@Param int: Int)(@Param str: String) = "endpoint1"
34 |
35 | def fn = "string"
36 |
37 | val x = "abc"
38 |
39 |
40 | given Request = null
41 |
42 | test("convertPathToPathParts") {
43 | val paths = List(
44 | "/endpoint1" -> List("/endpoint1"),
45 | "/endpoint2/:name" -> List("/endpoint2", ":name"),
46 | "/endpoint3/:name/info/age/:num" -> List("/endpoint3", ":name", "info", "age", ":num"),
47 | "/endpoint5/:id/:name" -> List("/endpoint5", ":id", ":name"),
48 | "prefix:/howdy" -> List("/howdy"),
49 | "/braced-params/{name}/{age}/:contact" -> List("/braced-params", ":name", ":age", ":contact"),
50 | "regex:^/string/(?[^0-9]+)$" -> List("/string", ":name"), /*This path is different without ^$ "regex:/string/(?[^0-9]+)"*/
51 | "regex:^/number/(?[0-9]+)$" -> List("/number", ":n1"),
52 | "regex:/string2/(?\\w+)/(?\\w+)" -> List("/string2", ":min", ":max"),
53 | "regex:/number2/(?\\d+)/(?\\d+)" -> List("/number2", ":min", ":max"),
54 | "regex:/mix/(?\\d+)/(?\\w+)" -> List("/mix", ":min", ":max"),
55 | "regex:/mix/(?\\w+)/(?\\d+)" -> List("/mix", ":min", ":max"),
56 | "regex:/mix/(?\\w+)/max/(?\\d+)" -> List("/mix", ":min", "max", ":max")
57 | )
58 |
59 | val results = paths.map { (path, expectation) =>
60 | (path, expectation, AnnotatedActionMacroSupport.convertPathToPathParts(path))
61 | }
62 |
63 | results.foreach{ (path, expectation, result) =>
64 | assertEquals(result, expectation)
65 | }
66 | }
67 |
68 | test("verifyActionTypeMcr"){
69 | val results = List(
70 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(actionEp) -> "com.greenfossil.thorium.Action",
71 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp1) -> "scala.Function1[com.greenfossil.thorium.Request, scala.Predef.String]",
72 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp2) -> "scala.Function1[scala.Predef.String, scala.Function1[com.greenfossil.thorium.Request, scala.Predef.String]]",
73 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp3) -> "scala.Function1[scala.Predef.String, scala.Function1[com.greenfossil.thorium.Request, scala.Predef.String]]",
74 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp4) -> "scala.Function2[scala.Long, scala.Predef.String, com.greenfossil.thorium.Action]",
75 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp5) -> "scala.Function2[scala.Long, scala.Predef.String, com.greenfossil.thorium.Action]",
76 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(endpointParams) -> "scala.Function1[scala.Int, scala.Function1[scala.Predef.String, java.lang.String]]",
77 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(fn) -> "java.lang.String",
78 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(x) -> "java.lang.String",
79 | )
80 |
81 | results.zipWithIndex.foreach{(x, index) =>
82 | val (result, expectation) = x
83 | assertNoDiff(result, expectation)
84 | }
85 | }
86 |
87 | test("extractActionAnnotationsMcr"){
88 | val results = List(
89 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(actionEp) -> (2, 0),
90 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp1) -> (1, 0),
91 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp2) -> (1, 1),
92 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp3) -> (1, 1),
93 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp4) -> (2, 2),
94 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp5) -> (2, 2),
95 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(endpointParams) -> (1, 2),
96 | )
97 |
98 | results.zipWithIndex.foreach { (result, index) =>
99 | val (obtained, expected) = result
100 | assertEquals(obtained, expected)
101 | }
102 | }
103 |
104 | test("extractHttpVerbMcr") {
105 | val results = List(
106 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(actionEp) -> "Method: Get, Path: /action-ep" ,
107 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp1) -> "Method: Get, Path: /req-func-ep1",
108 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp2) -> "Method: Get, Path: /req-func-ep2/:msg",
109 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp3) -> "Method: Get, Path: /req-func-ep3",
110 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp4) -> "Method: Post, Path: /req-func-ep4/:id/:name",
111 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp5) -> "Method: Get, Path: /req-func-ep5/:id/:name",
112 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(endpointParams) -> "Method: Get, Path: /endpoint-params/:int/:str",
113 | )
114 |
115 | results.zipWithIndex.foreach { (result, index) =>
116 | val (obtained, expected) = result
117 | assertEquals(obtained, expected)
118 | }
119 | }
120 |
121 | test("computeActionAnnotatedPathMcr") {
122 | val results = List(
123 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(actionEp) -> "/action-ep",
124 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp1) -> "/req-func-ep1",
125 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp2("homer!")) -> "/req-func-ep2/homer%21",
126 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp3("howdy!")) -> "/req-func-ep3?msg=howdy%21",
127 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp4(1, "hello")) -> "/req-func-ep4/1/hello",
128 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp5(1, "marge")) -> "/req-func-ep5/1/marge",
129 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(endpointParams(1)("bart")) -> "/endpoint-params/1/bart",
130 | )
131 |
132 | results.zipWithIndex.foreach { (result, index) =>
133 | val (endpoint, expected) = result
134 | assertNoDiff(endpoint.url, expected)
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/test/scala/com/greenfossil/thorium/macros/AnnotatedActionMacroSupportTestImpl.scala:
--------------------------------------------------------------------------------
1 | package com.greenfossil.thorium.macros
2 |
3 | import com.greenfossil.thorium.*
4 |
5 | object AnnotatedActionMacroSupportTestImpl:
6 |
7 | import quoted.*
8 |
9 | inline def verifyActionTypeMcr[A](inline ep: A): String =
10 | ${ verifyActionTypeImpl( '{ep} )}
11 |
12 | def verifyActionTypeImpl[A: Type](epExpr: Expr[A])(using q: Quotes): Expr[String] =
13 | import q.reflect.*
14 | AnnotatedActionMacroSupport.verifyActionType(epExpr)
15 | val _expr: String = TypeRepr.of[A].show
16 | Expr( _expr )
17 |
18 | inline def extractActionAnnotationsMcr[A](inline fnExpr: A): (Int, Int) =
19 | ${ extractActionAnnotationsImpl( '{ fnExpr } ) }
20 |
21 | def extractActionAnnotationsImpl[A: Type](fnExpr: Expr[A])(using q: Quotes): Expr[(Int, Int)] =
22 | val (methodAnnotations, paramAnnotations) = AnnotatedActionMacroSupport.extractActionAnnotations(fnExpr)
23 | Expr( (methodAnnotations.size, paramAnnotations.size) )
24 |
25 | inline def extractHttpVerbMcr[A](inline ep: A): String =
26 | ${ extractHttpVerbImpl( '{ep} ) }
27 |
28 | def extractHttpVerbImpl[A: Type](epExpr: Expr[A])(using q: Quotes): Expr[String] =
29 | val (methodAnnotations, paramAnnotations) = AnnotatedActionMacroSupport.extractActionAnnotations(epExpr)
30 | val (method, pathPattern) = AnnotatedActionMacroSupport.extractHttpVerb(methodAnnotations).getOrElse(("[]", "[]"))
31 | Expr(s"Method: $method, Path: $pathPattern")
32 |
33 | inline def computeActionAnnotatedPathMcr[A](inline ep:A): Endpoint =
34 | ${ computeActionAnnotatedPathImpl( '{ep} ) }
35 |
36 | def computeActionAnnotatedPathImpl[A: Type](epExpr: Expr[A])(using q: Quotes): Expr[Endpoint] =
37 | AnnotatedActionMacroSupport.computeActionAnnotatedPath(
38 | epExpr = epExpr,
39 | onSuccessCallback = (exprMethod, exprPathPartList, exprQueryParamKeys, exprQueryParamValues, exprPathPattern) =>
40 | '{
41 | Endpoint(
42 | ${exprPathPartList}.mkString("/"),
43 | ${exprMethod},
44 | ${exprQueryParamKeys}.zip(${exprQueryParamValues}),
45 | Some(${exprPathPattern})
46 | )
47 | },
48 | supportQueryStringPost = true
49 | )
50 |
51 |
--------------------------------------------------------------------------------
/thorium-buildinfo.sbt:
--------------------------------------------------------------------------------
1 | Compile / sourceGenerators += Def.task {
2 | val file = ( Compile / sourceManaged ).value / "com" / "greenfossil" / "thorium" / "ThoriumBuildInfo.scala"
3 | IO.write (
4 | file,
5 | s"""package com.greenfossil.thorium
6 | |private [thorium] object ThoriumBuildInfo {
7 | | val version = "${version.value}"
8 | |}
9 | |""".stripMargin
10 | )
11 | Seq(file)
12 | }.taskValue
--------------------------------------------------------------------------------