mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 11:47:18 +00:00
Merge remote-tracking branch 'upstream/develop' into develop
This commit is contained in:
commit
69c8970c27
@ -6,7 +6,9 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- "*"
|
||||
- "**"
|
||||
# - develop
|
||||
|
||||
env:
|
||||
## Sets environment variable
|
||||
@ -128,8 +130,13 @@ jobs:
|
||||
if: github.repository == 'OpenBankProject/OBP-API'
|
||||
run: |
|
||||
echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io
|
||||
docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop
|
||||
docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC
|
||||
if [ "${{ github.ref }}" == "refs/heads/develop" ]; then
|
||||
docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}
|
||||
# docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC
|
||||
else
|
||||
docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}
|
||||
# docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC
|
||||
fi
|
||||
docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags
|
||||
echo docker done
|
||||
|
||||
@ -143,14 +150,16 @@ jobs:
|
||||
if: github.repository == 'OpenBankProject/OBP-API'
|
||||
run: |
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA
|
||||
# cosign sign -y --key cosign.key \
|
||||
# docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC
|
||||
if [ "${{ github.ref }}" == "refs/heads/develop" ]; then
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest
|
||||
# cosign sign -y --key cosign.key \
|
||||
# docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC
|
||||
fi
|
||||
env:
|
||||
COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}"
|
||||
@ -1,152 +0,0 @@
|
||||
name: Build and publish container non develop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
- "!develop"
|
||||
|
||||
env:
|
||||
DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }}
|
||||
DOCKER_HUB_REPOSITORY: obp-api
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# Label used to access the service container
|
||||
redis:
|
||||
# Docker Hub image
|
||||
image: redis
|
||||
ports:
|
||||
# Opens tcp port 6379 on the host and service container
|
||||
- 6379:6379
|
||||
# Set health checks to wait until redis has started
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "11"
|
||||
distribution: "adopt"
|
||||
cache: maven
|
||||
- name: Build with Maven
|
||||
run: |
|
||||
set -o pipefail
|
||||
cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props
|
||||
echo connector=star > obp-api/src/main/resources/props/test.default.props
|
||||
echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props
|
||||
echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props
|
||||
echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props
|
||||
echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props
|
||||
echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props
|
||||
echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props
|
||||
echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props
|
||||
echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props
|
||||
echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props
|
||||
echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props
|
||||
echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props
|
||||
echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props
|
||||
echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props
|
||||
echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props
|
||||
echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props
|
||||
echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props
|
||||
echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props
|
||||
echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props
|
||||
|
||||
echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props
|
||||
|
||||
echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props
|
||||
echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props
|
||||
|
||||
echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props
|
||||
|
||||
echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props
|
||||
MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log
|
||||
|
||||
- name: Report failing tests (if any)
|
||||
if: always()
|
||||
run: |
|
||||
echo "Checking build log for failing tests via grep..."
|
||||
if [ ! -f maven-build.log ]; then
|
||||
echo "No maven-build.log found; skipping failure scan."
|
||||
exit 0
|
||||
fi
|
||||
if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then
|
||||
echo "Failing tests detected above."
|
||||
exit 1
|
||||
else
|
||||
echo "No failing tests detected in maven-build.log."
|
||||
fi
|
||||
|
||||
- name: Upload Maven build log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maven-build-log
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
maven-build.log
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-reports
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
obp-api/target/surefire-reports/**
|
||||
obp-commons/target/surefire-reports/**
|
||||
**/target/scalatest-reports/**
|
||||
**/target/site/surefire-report.html
|
||||
**/target/site/surefire-report/*
|
||||
|
||||
- name: Save .war artifact
|
||||
run: |
|
||||
mkdir -p ./push
|
||||
cp obp-api/target/obp-api-1.*.war ./push/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ github.sha }}
|
||||
path: push/
|
||||
|
||||
- name: Build the Docker image
|
||||
if: github.repository == 'OpenBankProject/OBP-API'
|
||||
run: |
|
||||
echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io
|
||||
docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}
|
||||
docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC
|
||||
docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags
|
||||
echo docker done
|
||||
|
||||
- uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390
|
||||
|
||||
- name: Write signing key to disk (only needed for `cosign sign --key`)
|
||||
if: github.repository == 'OpenBankProject/OBP-API'
|
||||
run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key
|
||||
|
||||
- name: Sign container image
|
||||
if: github.repository == 'OpenBankProject/OBP-API'
|
||||
run: |
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC
|
||||
cosign sign -y --key cosign.key \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA
|
||||
env:
|
||||
COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}"
|
||||
@ -3081,7 +3081,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
|
||||
val reqHeaders = if (cc.requestHeaders.nonEmpty)
|
||||
cc.requestHeaders
|
||||
else
|
||||
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers
|
||||
S.request.map(_.request.headers).openOr(Nil)
|
||||
|
||||
val remoteIpAddress = if (cc.ipAddress.nonEmpty)
|
||||
cc.ipAddress
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
package code.api.util.http4s
|
||||
|
||||
import cats.data.{Kleisli, OptionT}
|
||||
import cats.data.{EitherT, Kleisli, OptionT}
|
||||
import cats.effect._
|
||||
import code.api.v7_0_0.Http4s700
|
||||
import code.api.APIFailureNewStyle
|
||||
import code.api.util.APIUtil.ResourceDoc
|
||||
import code.api.util.ErrorMessages._
|
||||
import code.api.util.{APIUtil, CallContext, NewStyle}
|
||||
import code.api.util.newstyle.ViewNewStyle
|
||||
import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle}
|
||||
import code.util.Helper.MdcLoggable
|
||||
import com.openbankproject.commons.model._
|
||||
import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure}
|
||||
import com.github.dwickern.macros.NameOf.nameOf
|
||||
import net.liftweb.common.{Box, Empty, Full}
|
||||
import org.http4s._
|
||||
import org.http4s.headers.`Content-Type`
|
||||
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
import scala.language.higherKinds
|
||||
|
||||
/**
|
||||
* ResourceDoc-driven validation middleware for http4s.
|
||||
@ -34,10 +35,34 @@ import scala.language.higherKinds
|
||||
*/
|
||||
object ResourceDocMiddleware extends MdcLoggable {
|
||||
|
||||
/** Type alias for http4s OptionT route effect */
|
||||
type HttpF[A] = OptionT[IO, A]
|
||||
type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F]
|
||||
|
||||
/** Type alias for validation effect using EitherT */
|
||||
type Validation[A] = EitherT[IO, Response[IO], A]
|
||||
|
||||
/** JSON content type for responses */
|
||||
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
|
||||
|
||||
/**
|
||||
* Context that accumulates all validated entities during request processing.
|
||||
* This context is passed along the validation chain.
|
||||
*/
|
||||
final case class ValidationContext(
|
||||
user: Box[User] = Empty,
|
||||
callContext: CallContext,
|
||||
bank: Option[Bank] = None,
|
||||
account: Option[BankAccount] = None,
|
||||
view: Option[View] = None,
|
||||
counterparty: Option[CounterpartyTrait] = None
|
||||
)
|
||||
|
||||
/** Simple DSL for success/failure in the validation chain */
|
||||
object DSL {
|
||||
def success[A](a: A): Validation[A] = EitherT.rightT(a)
|
||||
def failure(resp: Response[IO]): Validation[Nothing] = EitherT.leftT(resp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ResourceDoc requires authentication.
|
||||
*
|
||||
@ -47,7 +72,7 @@ object ResourceDocMiddleware extends MdcLoggable{
|
||||
* - Special case: resource-docs endpoint checks resource_docs_requires_role property
|
||||
*/
|
||||
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
|
||||
if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") {
|
||||
if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700)) {
|
||||
APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
|
||||
} else {
|
||||
resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty)
|
||||
@ -55,239 +80,208 @@ object ResourceDocMiddleware extends MdcLoggable{
|
||||
}
|
||||
|
||||
/**
|
||||
* Create middleware that applies ResourceDoc-driven validation.
|
||||
*
|
||||
* @param resourceDocs Collection of ResourceDoc entries for matching
|
||||
* @return Middleware that wraps HttpRoutes with validation
|
||||
* Middleware factory: wraps HttpRoutes with ResourceDoc validation.
|
||||
* Finds the matching ResourceDoc, validates the request, and enriches CallContext.
|
||||
*/
|
||||
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
|
||||
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
|
||||
OptionT(validateAndRoute(req, routes, resourceDocs).map(Option(_)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request and route to handler if validation passes.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Build CallContext from request
|
||||
* 2. Find matching ResourceDoc
|
||||
* 3. Run validation chain
|
||||
* 4. Route to handler with enriched CallContext
|
||||
*/
|
||||
private def validateAndRoute(
|
||||
req: Request[IO],
|
||||
routes: HttpRoutes[IO],
|
||||
resourceDocs: ArrayBuffer[ResourceDoc]
|
||||
): IO[Response[IO]] = {
|
||||
for {
|
||||
cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0")
|
||||
resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs)
|
||||
response <- resourceDocOpt match {
|
||||
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): HttpRoutes[IO] => HttpRoutes[IO] = { routes =>
|
||||
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
|
||||
// Build initial CallContext from request
|
||||
OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, "v7.0.0")).flatMap { cc =>
|
||||
ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) match {
|
||||
case Some(resourceDoc) =>
|
||||
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
|
||||
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
|
||||
runValidationChainForRoutes(req, resourceDoc, ccWithDoc, pathParams, routes)
|
||||
.map(ensureJsonContentType)
|
||||
// Run full validation chain
|
||||
OptionT(validateRequest(req, resourceDoc, pathParams, ccWithDoc, routes).map(Option(_)))
|
||||
|
||||
case None =>
|
||||
routes.run(req).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
|
||||
// No matching ResourceDoc: fallback to original route
|
||||
routes.run(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
} yield response
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure response has JSON content type.
|
||||
* Executes the full validation chain for the request.
|
||||
* Returns either an error Response or enriched request routed to the handler.
|
||||
*/
|
||||
private def validateRequest(
|
||||
req: Request[IO],
|
||||
resourceDoc: ResourceDoc,
|
||||
pathParams: Map[String, String],
|
||||
cc: CallContext,
|
||||
routes: HttpRoutes[IO]
|
||||
): IO[Response[IO]] = {
|
||||
|
||||
// Initial context with just CallContext
|
||||
val initialContext = ValidationContext(callContext = cc)
|
||||
|
||||
// Compose all validation steps using EitherT
|
||||
val result: Validation[ValidationContext] = for {
|
||||
context <- authenticate(req, resourceDoc, initialContext)
|
||||
context <- authorizeRoles(resourceDoc, pathParams, context)
|
||||
context <- validateBank(pathParams, context)
|
||||
context <- validateAccount(pathParams, context)
|
||||
context <- validateView(pathParams, context)
|
||||
context <- validateCounterparty(pathParams, context)
|
||||
} yield context
|
||||
|
||||
// Convert Validation result to Response
|
||||
result.value.flatMap {
|
||||
case Left(errorResponse) => IO.pure(ensureJsonContentType(errorResponse)) // Ensure all error responses are JSON
|
||||
case Right(validCtx) =>
|
||||
// Enrich request with validated CallContext
|
||||
val enrichedReq = req.withAttribute(
|
||||
Http4sRequestAttributes.callContextKey,
|
||||
validCtx.callContext.copy(
|
||||
bank = validCtx.bank,
|
||||
bankAccount = validCtx.account,
|
||||
view = validCtx.view,
|
||||
counterparty = validCtx.counterparty
|
||||
)
|
||||
)
|
||||
routes.run(enrichedReq)
|
||||
.map(ensureJsonContentType) // Ensure routed response has JSON content type
|
||||
.getOrElseF(IO.pure(ensureJsonContentType(Response[IO](org.http4s.Status.NotFound))))
|
||||
}
|
||||
}
|
||||
|
||||
/** Authentication step: verifies user and updates ValidationContext */
|
||||
private def authenticate(req: Request[IO], resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = {
|
||||
val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc)
|
||||
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
|
||||
|
||||
val io =
|
||||
if (needsAuth) IO.fromFuture(IO(APIUtil.authenticatedAccess(ctx.callContext)))
|
||||
else IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext)))
|
||||
|
||||
EitherT(
|
||||
io.attempt.flatMap {
|
||||
case Right((boxUser, Some(updatedCC))) =>
|
||||
IO.pure(Right(ctx.copy(user = boxUser, callContext = updatedCC)))
|
||||
case Right((boxUser, None)) =>
|
||||
IO.pure(Right(ctx.copy(user = boxUser)))
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
||||
case Left(_) =>
|
||||
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext).map(Left(_))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Role authorization step: ensures user has required roles */
|
||||
private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
||||
import DSL._
|
||||
|
||||
val rolesToCheck: Option[List[ApiRole]] =
|
||||
if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700) && APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)) {
|
||||
Some(List(ApiRole.canReadResourceDoc))
|
||||
} else {
|
||||
resourceDoc.roles
|
||||
}
|
||||
|
||||
rolesToCheck match {
|
||||
case Some(roles) if roles.nonEmpty =>
|
||||
ctx.user match {
|
||||
case Full(user) =>
|
||||
val bankId = pathParams.getOrElse("BANK_ID", "")
|
||||
val ok = roles.exists { role =>
|
||||
val checkBankId = if (role.requiresBankId) bankId else ""
|
||||
APIUtil.hasEntitlement(checkBankId, user.userId, role)
|
||||
}
|
||||
if (ok) success(ctx)
|
||||
else EitherT[IO, Response[IO], ValidationContext](
|
||||
ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), ctx.callContext)
|
||||
.map[Either[Response[IO], ValidationContext]](Left(_))
|
||||
)
|
||||
case _ =>
|
||||
EitherT[IO, Response[IO], ValidationContext](
|
||||
ErrorResponseConverter
|
||||
.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext)
|
||||
.map[Either[Response[IO], ValidationContext]](resp => Left(resp))
|
||||
)
|
||||
}
|
||||
case _ => success(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bank validation: checks BANK_ID and fetches bank */
|
||||
private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
||||
|
||||
pathParams.get("BANK_ID") match {
|
||||
case Some(bankId) =>
|
||||
EitherT(
|
||||
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext))))
|
||||
.attempt.flatMap {
|
||||
case Right((bank, Some(updatedCC))) => IO.pure(Right(ctx.copy(bank = Some(bank), callContext = updatedCC)))
|
||||
case Right((bank, None)) => IO.pure(Right(ctx.copy(bank = Some(bank))))
|
||||
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
||||
case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankNotFound + s": $bankId", ctx.callContext).map(Left(_))
|
||||
}
|
||||
)
|
||||
case None => DSL.success(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** Account validation: checks ACCOUNT_ID and fetches bank account */
|
||||
private def validateAccount(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
||||
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
|
||||
case (Some(bankId), Some(accountId)) =>
|
||||
EitherT(
|
||||
IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(ctx.callContext))))
|
||||
.attempt.flatMap {
|
||||
case Right((acc, Some(updatedCC))) => IO.pure(Right(ctx.copy(account = Some(acc), callContext = updatedCC)))
|
||||
case Right((acc, None)) => IO.pure(Right(ctx.copy(account = Some(acc))))
|
||||
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
||||
case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankId, accountId=$accountId", ctx.callContext).map(Left(_))
|
||||
}
|
||||
)
|
||||
case _ => DSL.success(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** View validation: checks VIEW_ID and user access */
|
||||
private def validateView(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
||||
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
|
||||
case (Some(bankId), Some(accountId), Some(viewId)) =>
|
||||
EitherT(
|
||||
IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), ctx.user.toOption, Some(ctx.callContext))))
|
||||
.attempt.flatMap {
|
||||
case Right(view) => IO.pure(Right(ctx.copy(view = Some(view))))
|
||||
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
||||
case Left(_) => ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewId", ctx.callContext).map(Left(_))
|
||||
}
|
||||
)
|
||||
case _ => DSL.success(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** Counterparty validation: checks COUNTERPARTY_ID and fetches counterparty */
|
||||
private def validateCounterparty(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
||||
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match {
|
||||
case (Some(bankId), Some(accountId), Some(counterpartyId)) =>
|
||||
EitherT(
|
||||
IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankId), AccountId(accountId), counterpartyId, Some(ctx.callContext))))
|
||||
.attempt.flatMap {
|
||||
case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC)))
|
||||
case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp))))
|
||||
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
||||
case Left(_) => ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyId", ctx.callContext).map(Left(_))
|
||||
}
|
||||
)
|
||||
case _ => DSL.success(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the response has JSON content type */
|
||||
private def ensureJsonContentType(response: Response[IO]): Response[IO] = {
|
||||
response.contentType match {
|
||||
case Some(contentType) if contentType.mediaType == MediaType.application.json => response
|
||||
case _ => response.withContentType(jsonContentType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run validation chain for HttpRoutes and return Response.
|
||||
*
|
||||
* This method performs all validation steps in order:
|
||||
* 1. Authentication (if required)
|
||||
* 2. Role authorization (if roles specified)
|
||||
* 3. Bank validation (if BANK_ID in path)
|
||||
* 4. Account validation (if ACCOUNT_ID in path)
|
||||
* 5. View validation (if VIEW_ID in path)
|
||||
* 6. Counterparty validation (if COUNTERPARTY_ID in path)
|
||||
*
|
||||
* On success: Enriches CallContext with validated entities and routes to handler
|
||||
* On failure: Returns error response immediately
|
||||
*/
|
||||
private def runValidationChainForRoutes(
|
||||
req: Request[IO],
|
||||
resourceDoc: ResourceDoc,
|
||||
cc: CallContext,
|
||||
pathParams: Map[String, String],
|
||||
routes: HttpRoutes[IO]
|
||||
): IO[Response[IO]] = {
|
||||
|
||||
val needsAuth = needsAuthentication(resourceDoc)
|
||||
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
|
||||
|
||||
// Step 1: Authentication
|
||||
val authResult: IO[Either[Response[IO], (Box[User], CallContext)]] =
|
||||
if (needsAuth) {
|
||||
IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.flatMap {
|
||||
case Right((boxUser, optCC)) =>
|
||||
val updatedCC = optCC.getOrElse(cc)
|
||||
boxUser match {
|
||||
case Full(user) =>
|
||||
IO.pure(Right((boxUser, updatedCC)))
|
||||
case Empty =>
|
||||
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, updatedCC).map(Left(_))
|
||||
case LiftFailure(msg, _, _) =>
|
||||
ErrorResponseConverter.createErrorResponse(401, msg, updatedCC).map(Left(_))
|
||||
}
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc).map(Left(_))
|
||||
case Left(e) =>
|
||||
val (code, msg) = try {
|
||||
import net.liftweb.json._
|
||||
implicit val formats = net.liftweb.json.DefaultFormats
|
||||
val json = parse(e.getMessage)
|
||||
val failCode = (json \ "failCode").extractOpt[Int].getOrElse(401)
|
||||
val failMsg = (json \ "failMsg").extractOpt[String].getOrElse($AuthenticatedUserIsRequired)
|
||||
(failCode, failMsg)
|
||||
} catch {
|
||||
case _: Exception => (401, $AuthenticatedUserIsRequired)
|
||||
}
|
||||
ErrorResponseConverter.createErrorResponse(code, msg, cc).map(Left(_))
|
||||
}
|
||||
} else {
|
||||
IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap {
|
||||
case Right((boxUser, Some(updatedCC))) =>
|
||||
IO.pure(Right((boxUser, updatedCC)))
|
||||
case Right((boxUser, None)) =>
|
||||
IO.pure(Right((boxUser, cc)))
|
||||
case Left(e) =>
|
||||
// For anonymous endpoints, continue with Empty user even if auth fails
|
||||
IO.pure(Right((Empty, cc)))
|
||||
}
|
||||
}
|
||||
|
||||
authResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((boxUser, cc1)) =>
|
||||
// Step 2: Role authorization
|
||||
val rolesResult: IO[Either[Response[IO], CallContext]] =
|
||||
resourceDoc.roles match {
|
||||
case Some(roles) if roles.nonEmpty =>
|
||||
boxUser match {
|
||||
case Full(user) =>
|
||||
val userId = user.userId
|
||||
val bankId = pathParams.get("BANK_ID").getOrElse("")
|
||||
val hasRole = roles.exists { role =>
|
||||
val checkBankId = if (role.requiresBankId) bankId else ""
|
||||
APIUtil.hasEntitlement(checkBankId, userId, role)
|
||||
}
|
||||
if (hasRole) IO.pure(Right(cc1))
|
||||
else ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), cc1).map(Left(_))
|
||||
case _ =>
|
||||
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc1).map(Left(_))
|
||||
}
|
||||
case _ => IO.pure(Right(cc1))
|
||||
}
|
||||
|
||||
rolesResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right(cc2) =>
|
||||
// Step 3: Bank validation
|
||||
val bankResult: IO[Either[Response[IO], (Option[Bank], CallContext)]] =
|
||||
pathParams.get("BANK_ID") match {
|
||||
case Some(bankIdStr) =>
|
||||
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc2)))).attempt.flatMap {
|
||||
case Right((bank, Some(updatedCC))) =>
|
||||
IO.pure(Right((Some(bank), updatedCC)))
|
||||
case Right((bank, None)) =>
|
||||
IO.pure(Right((Some(bank), cc2)))
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc2).map(Left(_))
|
||||
case Left(e) =>
|
||||
ErrorResponseConverter.createErrorResponse(404, BankNotFound + ": " + bankIdStr, cc2).map(Left(_))
|
||||
}
|
||||
case None => IO.pure(Right((None, cc2)))
|
||||
}
|
||||
|
||||
bankResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((bankOpt, cc3)) =>
|
||||
// Step 4: Account validation (if ACCOUNT_ID in path)
|
||||
val accountResult: IO[Either[Response[IO], (Option[BankAccount], CallContext)]] =
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
|
||||
case (Some(bankIdStr), Some(accountIdStr)) =>
|
||||
IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3)))).attempt.flatMap {
|
||||
case Right((account, Some(updatedCC))) => IO.pure(Right((Some(account), updatedCC)))
|
||||
case Right((account, None)) => IO.pure(Right((Some(account), cc3)))
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc3).map(Left(_))
|
||||
case Left(e) =>
|
||||
ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankIdStr, accountId=$accountIdStr", cc3).map(Left(_))
|
||||
}
|
||||
case _ => IO.pure(Right((None, cc3)))
|
||||
}
|
||||
|
||||
|
||||
accountResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((accountOpt, cc4)) =>
|
||||
// Step 5: View validation (if VIEW_ID in path)
|
||||
val viewResult: IO[Either[Response[IO], (Option[View], CallContext)]] =
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
|
||||
case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) =>
|
||||
val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr))
|
||||
IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewIdStr), bankIdAccountId, boxUser.toOption, Some(cc4)))).attempt.flatMap {
|
||||
case Right(view) => IO.pure(Right((Some(view), cc4)))
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc4).map(Left(_))
|
||||
case Left(e) =>
|
||||
ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewIdStr", cc4).map(Left(_))
|
||||
}
|
||||
case _ => IO.pure(Right((None, cc4)))
|
||||
}
|
||||
|
||||
viewResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((viewOpt, cc5)) =>
|
||||
// Step 6: Counterparty validation (if COUNTERPARTY_ID in path)
|
||||
val counterpartyResult: IO[Either[Response[IO], (Option[CounterpartyTrait], CallContext)]] =
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match {
|
||||
case (Some(bankIdStr), Some(accountIdStr), Some(counterpartyIdStr)) =>
|
||||
IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankIdStr), AccountId(accountIdStr), counterpartyIdStr, Some(cc5)))).attempt.flatMap {
|
||||
case Right((counterparty, Some(updatedCC))) => IO.pure(Right((Some(counterparty), updatedCC)))
|
||||
case Right((counterparty, None)) => IO.pure(Right((Some(counterparty), cc5)))
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc5).map(Left(_))
|
||||
case Left(e) =>
|
||||
ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyIdStr", cc5).map(Left(_))
|
||||
}
|
||||
case _ => IO.pure(Right((None, cc5)))
|
||||
}
|
||||
|
||||
counterpartyResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((counterpartyOpt, finalCC)) =>
|
||||
// All validations passed - update CallContext with validated entities
|
||||
val enrichedCC = finalCC.copy(
|
||||
bank = bankOpt,
|
||||
bankAccount = accountOpt,
|
||||
view = viewOpt,
|
||||
counterparty = counterpartyOpt
|
||||
)
|
||||
|
||||
// Store enriched CallContext in request attributes
|
||||
val updatedReq = req.withAttribute(Http4sRequestAttributes.callContextKey, enrichedCC)
|
||||
routes.run(updatedReq).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,12 @@ import code.api.Constant._
|
||||
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._
|
||||
import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil}
|
||||
import code.api.util.APIUtil.{EmptyBody, _}
|
||||
import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc}
|
||||
import code.api.util.ApiRole.canGetCardsForBank
|
||||
import code.api.util.ApiTag._
|
||||
import code.api.util.ErrorMessages._
|
||||
import code.api.util.http4s.{Http4sRequestAttributes, ResourceDocMiddleware}
|
||||
import code.api.util.http4s.Http4sRequestAttributes.{RequestOps, EndpointHelpers}
|
||||
import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
|
||||
import code.api.util.{ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
|
||||
import code.api.v1_3_0.JSONFactory1_3_0
|
||||
import code.api.v1_4_0.JSONFactory1_4_0
|
||||
import code.api.v4_0_0.JSONFactory400
|
||||
@ -201,44 +201,26 @@ object Http4s700 {
|
||||
|
||||
val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] {
|
||||
case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" =>
|
||||
implicit val cc: CallContext = req.callContext
|
||||
EndpointHelpers.executeAndRespond(req) { _ =>
|
||||
val queryParams = req.uri.query.multiParams
|
||||
val tags = queryParams
|
||||
.get("tags")
|
||||
.map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList)
|
||||
val functions = queryParams
|
||||
.get("functions")
|
||||
.map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList)
|
||||
val localeParam = queryParams
|
||||
.get("locale")
|
||||
.flatMap(_.headOption)
|
||||
.orElse(queryParams.get("language").flatMap(_.headOption))
|
||||
.map(_.trim)
|
||||
.filter(_.nonEmpty)
|
||||
for {
|
||||
result <- IO.fromFuture(IO {
|
||||
// Check resource_docs_requires_role property
|
||||
val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false)
|
||||
|
||||
for {
|
||||
// Authentication based on property
|
||||
(boxUser, cc1) <- if (resourceDocsRequireRole)
|
||||
authenticatedAccess(cc)
|
||||
else
|
||||
anonymousAccess(cc)
|
||||
|
||||
// Role check based on property
|
||||
_ <- if (resourceDocsRequireRole) {
|
||||
NewStyle.function.hasAtLeastOneEntitlement(
|
||||
failMsg = UserHasMissingRoles + canReadResourceDoc.toString
|
||||
)("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1)
|
||||
} else {
|
||||
Future.successful(())
|
||||
}
|
||||
|
||||
httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
|
||||
tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption
|
||||
functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption
|
||||
localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption
|
||||
contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption
|
||||
apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption
|
||||
tags = tagsParam.map(_.map(ResourceDocTag(_)))
|
||||
functions = functionsParam.map(_.toList)
|
||||
requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString))
|
||||
resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil)
|
||||
filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions)
|
||||
resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam)
|
||||
} yield convertAnyToJsonString(resourceDocsJson)
|
||||
})
|
||||
response <- Ok(result)
|
||||
} yield response
|
||||
} yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,454 @@
|
||||
package code.api.util.http4s
|
||||
|
||||
import cats.effect.IO
|
||||
import cats.effect.unsafe.implicits.global
|
||||
import net.liftweb.common.{Empty, Full}
|
||||
import org.http4s._
|
||||
import org.http4s.dsl.io._
|
||||
import org.http4s.headers.`Content-Type`
|
||||
import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag}
|
||||
|
||||
/**
|
||||
* Unit tests for Http4sCallContextBuilder
|
||||
*
|
||||
* Tests CallContext building from http4s Request[IO]:
|
||||
* - URL extraction (including query parameters)
|
||||
* - Header extraction and conversion to HTTPParam
|
||||
* - Body extraction for POST requests
|
||||
* - Correlation ID generation/extraction
|
||||
* - IP address extraction (X-Forwarded-For and direct)
|
||||
* - Auth header extraction for all auth types
|
||||
*
|
||||
*/
|
||||
class Http4sCallContextBuilderTest extends FeatureSpec with Matchers with GivenWhenThen {
|
||||
|
||||
object Http4sCallContextBuilderTag extends Tag("Http4sCallContextBuilder")
|
||||
|
||||
feature("Http4sCallContextBuilder - URL extraction") {
|
||||
|
||||
scenario("Extract URL with path only", Http4sCallContextBuilderTag) {
|
||||
Given("A request with path /obp/v7.0.0/banks")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("URL should match the request URI")
|
||||
callContext.url should equal("/obp/v7.0.0/banks")
|
||||
}
|
||||
|
||||
scenario("Extract URL with query parameters", Http4sCallContextBuilderTag) {
|
||||
Given("A request with query parameters")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10&offset=0")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("URL should include query parameters")
|
||||
callContext.url should equal("/obp/v7.0.0/banks?limit=10&offset=0")
|
||||
}
|
||||
|
||||
scenario("Extract URL with path parameters", Http4sCallContextBuilderTag) {
|
||||
Given("A request with path parameters")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("URL should include path parameters")
|
||||
callContext.url should equal("/obp/v7.0.0/banks/gh.29.de/accounts/test1")
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - Header extraction") {
|
||||
|
||||
scenario("Extract headers and convert to HTTPParam", Http4sCallContextBuilderTag) {
|
||||
Given("A request with multiple headers")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"),
|
||||
Header.Raw(org.typelevel.ci.CIString("Accept"), "application/json"),
|
||||
Header.Raw(org.typelevel.ci.CIString("X-Custom-Header"), "test-value")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Headers should be converted to HTTPParam list")
|
||||
callContext.requestHeaders should not be empty
|
||||
callContext.requestHeaders.exists(_.name == "Content-Type") should be(true)
|
||||
callContext.requestHeaders.exists(_.name == "Accept") should be(true)
|
||||
callContext.requestHeaders.exists(_.name == "X-Custom-Header") should be(true)
|
||||
}
|
||||
|
||||
scenario("Extract empty headers list", Http4sCallContextBuilderTag) {
|
||||
Given("A request with no custom headers")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Headers list should be empty or contain only default headers")
|
||||
// http4s may add default headers, so we just check it's a list
|
||||
callContext.requestHeaders should be(a[List[_]])
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - Body extraction") {
|
||||
|
||||
scenario("Extract body from POST request", Http4sCallContextBuilderTag) {
|
||||
Given("A POST request with JSON body")
|
||||
val jsonBody = """{"name": "Test Bank", "id": "test-bank-1"}"""
|
||||
val request = Request[IO](
|
||||
method = Method.POST,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withEntity(jsonBody)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Body should be extracted as Some(string)")
|
||||
callContext.httpBody should be(Some(jsonBody))
|
||||
}
|
||||
|
||||
scenario("Extract empty body from GET request", Http4sCallContextBuilderTag) {
|
||||
Given("A GET request with no body")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Body should be None")
|
||||
callContext.httpBody should be(None)
|
||||
}
|
||||
|
||||
scenario("Extract body from PUT request", Http4sCallContextBuilderTag) {
|
||||
Given("A PUT request with JSON body")
|
||||
val jsonBody = """{"name": "Updated Bank"}"""
|
||||
val request = Request[IO](
|
||||
method = Method.PUT,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks/test-bank-1")
|
||||
).withEntity(jsonBody)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Body should be extracted")
|
||||
callContext.httpBody should be(Some(jsonBody))
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - Correlation ID") {
|
||||
|
||||
scenario("Extract correlation ID from X-Request-ID header", Http4sCallContextBuilderTag) {
|
||||
Given("A request with X-Request-ID header")
|
||||
val requestId = "test-correlation-id-12345"
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), requestId)
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Correlation ID should match the header value")
|
||||
callContext.correlationId should equal(requestId)
|
||||
}
|
||||
|
||||
scenario("Generate correlation ID when header missing", Http4sCallContextBuilderTag) {
|
||||
Given("A request without X-Request-ID header")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Correlation ID should be generated (UUID format)")
|
||||
callContext.correlationId should not be empty
|
||||
// UUID format: 8-4-4-4-12 hex digits
|
||||
callContext.correlationId should fullyMatch regex "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - IP address extraction") {
|
||||
|
||||
scenario("Extract IP from X-Forwarded-For header", Http4sCallContextBuilderTag) {
|
||||
Given("A request with X-Forwarded-For header")
|
||||
val clientIp = "192.168.1.100"
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp)
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("IP address should match the header value")
|
||||
callContext.ipAddress should equal(clientIp)
|
||||
}
|
||||
|
||||
scenario("Extract first IP from X-Forwarded-For with multiple IPs", Http4sCallContextBuilderTag) {
|
||||
Given("A request with X-Forwarded-For containing multiple IPs")
|
||||
val forwardedFor = "192.168.1.100, 10.0.0.1, 172.16.0.1"
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), forwardedFor)
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("IP address should be the first IP in the list")
|
||||
callContext.ipAddress should equal("192.168.1.100")
|
||||
}
|
||||
|
||||
scenario("Handle missing IP address", Http4sCallContextBuilderTag) {
|
||||
Given("A request without X-Forwarded-For or remote address")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("IP address should be empty string")
|
||||
callContext.ipAddress should equal("")
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - Authentication header extraction") {
|
||||
|
||||
scenario("Extract DirectLogin token from DirectLogin header (new format)", Http4sCallContextBuilderTag) {
|
||||
Given("A request with DirectLogin header")
|
||||
val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test"
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("DirectLogin params should contain token")
|
||||
callContext.directLoginParams should contain key "token"
|
||||
callContext.directLoginParams("token") should equal(token)
|
||||
}
|
||||
|
||||
scenario("Extract DirectLogin token from Authorization header (old format)", Http4sCallContextBuilderTag) {
|
||||
Given("A request with Authorization: DirectLogin header")
|
||||
val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test"
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("Authorization"), s"DirectLogin token=$token")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("DirectLogin params should contain token")
|
||||
callContext.directLoginParams should contain key "token"
|
||||
callContext.directLoginParams("token") should equal(token)
|
||||
|
||||
And("Authorization header should be stored")
|
||||
callContext.authReqHeaderField should equal(Full(s"DirectLogin token=$token"))
|
||||
}
|
||||
|
||||
scenario("Extract DirectLogin with username and password", Http4sCallContextBuilderTag) {
|
||||
Given("A request with DirectLogin username and password")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), """username="testuser", password="testpass", consumer_key="key123"""")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("DirectLogin params should contain all parameters")
|
||||
callContext.directLoginParams should contain key "username"
|
||||
callContext.directLoginParams should contain key "password"
|
||||
callContext.directLoginParams should contain key "consumer_key"
|
||||
callContext.directLoginParams("username") should equal("testuser")
|
||||
callContext.directLoginParams("password") should equal("testpass")
|
||||
callContext.directLoginParams("consumer_key") should equal("key123")
|
||||
}
|
||||
|
||||
scenario("Extract OAuth parameters from Authorization header", Http4sCallContextBuilderTag) {
|
||||
Given("A request with OAuth Authorization header")
|
||||
val oauthHeader = """OAuth oauth_consumer_key="consumer123", oauth_token="token456", oauth_signature="sig789""""
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("Authorization"), oauthHeader)
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("OAuth params should be extracted")
|
||||
callContext.oAuthParams should contain key "oauth_consumer_key"
|
||||
callContext.oAuthParams should contain key "oauth_token"
|
||||
callContext.oAuthParams should contain key "oauth_signature"
|
||||
callContext.oAuthParams("oauth_consumer_key") should equal("consumer123")
|
||||
callContext.oAuthParams("oauth_token") should equal("token456")
|
||||
callContext.oAuthParams("oauth_signature") should equal("sig789")
|
||||
|
||||
And("Authorization header should be stored")
|
||||
callContext.authReqHeaderField should equal(Full(oauthHeader))
|
||||
}
|
||||
|
||||
scenario("Extract Bearer token from Authorization header", Http4sCallContextBuilderTag) {
|
||||
Given("A request with Bearer token")
|
||||
val bearerToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature"
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("Authorization"), s"Bearer $bearerToken")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Authorization header should be stored")
|
||||
callContext.authReqHeaderField should equal(Full(s"Bearer $bearerToken"))
|
||||
}
|
||||
|
||||
scenario("Handle missing Authorization header", Http4sCallContextBuilderTag) {
|
||||
Given("A request without Authorization header")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Auth header field should be Empty")
|
||||
callContext.authReqHeaderField should equal(Empty)
|
||||
|
||||
And("DirectLogin params should be empty")
|
||||
callContext.directLoginParams should be(empty)
|
||||
|
||||
And("OAuth params should be empty")
|
||||
callContext.oAuthParams should be(empty)
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - Request metadata") {
|
||||
|
||||
scenario("Extract HTTP verb", Http4sCallContextBuilderTag) {
|
||||
Given("A POST request")
|
||||
val request = Request[IO](
|
||||
method = Method.POST,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("Verb should be POST")
|
||||
callContext.verb should equal("POST")
|
||||
}
|
||||
|
||||
scenario("Set implementedInVersion from parameter", Http4sCallContextBuilderTag) {
|
||||
Given("A request with API version v7.0.0")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext with version parameter")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("implementedInVersion should match the parameter")
|
||||
callContext.implementedInVersion should equal("v7.0.0")
|
||||
}
|
||||
|
||||
scenario("Set startTime to current date", Http4sCallContextBuilderTag) {
|
||||
Given("A request")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Building CallContext")
|
||||
val beforeTime = new java.util.Date()
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
val afterTime = new java.util.Date()
|
||||
|
||||
Then("startTime should be set and within reasonable range")
|
||||
callContext.startTime should be(defined)
|
||||
callContext.startTime.get.getTime should be >= beforeTime.getTime
|
||||
callContext.startTime.get.getTime should be <= afterTime.getTime
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4sCallContextBuilder - Complete integration") {
|
||||
|
||||
scenario("Build complete CallContext with all fields", Http4sCallContextBuilderTag) {
|
||||
Given("A complete POST request with all headers and body")
|
||||
val jsonBody = """{"name": "Test Bank"}"""
|
||||
val token = "test-token-123"
|
||||
val correlationId = "correlation-123"
|
||||
val clientIp = "192.168.1.100"
|
||||
|
||||
val request = Request[IO](
|
||||
method = Method.POST,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10")
|
||||
).withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"),
|
||||
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token"),
|
||||
Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), correlationId),
|
||||
Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp)
|
||||
).withEntity(jsonBody)
|
||||
|
||||
When("Building CallContext")
|
||||
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
|
||||
|
||||
Then("All fields should be populated correctly")
|
||||
callContext.url should equal("/obp/v7.0.0/banks?limit=10")
|
||||
callContext.verb should equal("POST")
|
||||
callContext.implementedInVersion should equal("v7.0.0")
|
||||
callContext.correlationId should equal(correlationId)
|
||||
callContext.ipAddress should equal(clientIp)
|
||||
callContext.httpBody should be(Some(jsonBody))
|
||||
callContext.directLoginParams should contain key "token"
|
||||
callContext.directLoginParams("token") should equal(token)
|
||||
callContext.requestHeaders should not be empty
|
||||
callContext.startTime should be(defined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,545 @@
|
||||
package code.api.util.http4s
|
||||
|
||||
import code.api.util.APIUtil.ResourceDoc
|
||||
import code.api.util.ApiTag.ResourceDocTag
|
||||
import com.openbankproject.commons.util.ApiVersion
|
||||
import net.liftweb.json.JsonAST.JObject
|
||||
import org.http4s._
|
||||
import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag}
|
||||
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
|
||||
/**
|
||||
* Unit tests for ResourceDocMatcher
|
||||
*
|
||||
* Tests ResourceDoc matching and path parameter extraction:
|
||||
* - Matching by verb and exact path
|
||||
* - Matching with BANK_ID variable
|
||||
* - Matching with BANK_ID + ACCOUNT_ID variables
|
||||
* - Matching with BANK_ID + ACCOUNT_ID + VIEW_ID variables
|
||||
* - Matching with COUNTERPARTY_ID variable
|
||||
* - Non-matching requests return None
|
||||
* - Path parameter extraction for all variable types
|
||||
*
|
||||
*/
|
||||
class ResourceDocMatcherTest extends FeatureSpec with Matchers with GivenWhenThen {
|
||||
|
||||
object ResourceDocMatcherTag extends Tag("ResourceDocMatcher")
|
||||
|
||||
// Helper to create minimal ResourceDoc for testing
|
||||
private def createResourceDoc(
|
||||
verb: String,
|
||||
url: String,
|
||||
operationId: String = "testOperation"
|
||||
): ResourceDoc = {
|
||||
ResourceDoc(
|
||||
partialFunction = null, // Not needed for matching tests
|
||||
implementedInApiVersion = ApiVersion.v7_0_0,
|
||||
partialFunctionName = operationId,
|
||||
requestVerb = verb,
|
||||
requestUrl = url,
|
||||
summary = "Test endpoint",
|
||||
description = "Test description",
|
||||
exampleRequestBody = JObject(Nil),
|
||||
successResponseBody = JObject(Nil),
|
||||
errorResponseBodies = List.empty,
|
||||
tags = List(ResourceDocTag("test")),
|
||||
roles = None
|
||||
)
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - Exact path matching") {
|
||||
|
||||
scenario("Match GET request with exact path", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBanks")
|
||||
}
|
||||
|
||||
scenario("Match POST request with exact path", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for POST /banks")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("POST", "/banks", "createBank")
|
||||
)
|
||||
|
||||
When("Matching a POST request to /obp/v7.0.0/banks")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("POST", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("createBank")
|
||||
}
|
||||
|
||||
scenario("Match request with multi-segment path", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /management/metrics")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/management/metrics", "getMetrics")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/management/metrics")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/management/metrics")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getMetrics")
|
||||
}
|
||||
|
||||
scenario("Verb mismatch returns None", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks")
|
||||
)
|
||||
|
||||
When("Matching a POST request to /obp/v7.0.0/banks")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("POST", path, resourceDocs)
|
||||
|
||||
Then("Should return None")
|
||||
result should be(None)
|
||||
}
|
||||
|
||||
scenario("Path mismatch returns None", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/accounts")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should return None")
|
||||
result should be(None)
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - BANK_ID variable matching") {
|
||||
|
||||
scenario("Match request with BANK_ID variable", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID", "getBank")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBank")
|
||||
}
|
||||
|
||||
scenario("Match request with BANK_ID and additional segments", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks/test-bank-1/accounts")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank-1/accounts")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBankAccounts")
|
||||
}
|
||||
|
||||
scenario("Extract BANK_ID parameter value", ResourceDocMatcherTag) {
|
||||
Given("A matched ResourceDoc with BANK_ID")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank")
|
||||
|
||||
When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should extract BANK_ID value")
|
||||
params should contain key "BANK_ID"
|
||||
params("BANK_ID") should equal("gh.29.de")
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - BANK_ID + ACCOUNT_ID variables") {
|
||||
|
||||
scenario("Match request with BANK_ID and ACCOUNT_ID variables", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID", "getBankAccount")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de/accounts/test1")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBankAccount")
|
||||
}
|
||||
|
||||
scenario("Extract BANK_ID and ACCOUNT_ID parameter values", ResourceDocMatcherTag) {
|
||||
Given("A matched ResourceDoc with BANK_ID and ACCOUNT_ID")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID", "getBankAccount")
|
||||
|
||||
When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de/accounts/test1")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should extract both BANK_ID and ACCOUNT_ID values")
|
||||
params should contain key "BANK_ID"
|
||||
params should contain key "ACCOUNT_ID"
|
||||
params("BANK_ID") should equal("gh.29.de")
|
||||
params("ACCOUNT_ID") should equal("test1")
|
||||
}
|
||||
|
||||
scenario("Match request with BANK_ID, ACCOUNT_ID and additional segments", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/transactions")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions", "getTransactions")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks/test-bank/accounts/acc-123/transactions")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank/accounts/acc-123/transactions")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getTransactions")
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - BANK_ID + ACCOUNT_ID + VIEW_ID variables") {
|
||||
|
||||
scenario("Match request with BANK_ID, ACCOUNT_ID and VIEW_ID variables", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "getTransactionsForView")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getTransactionsForView")
|
||||
}
|
||||
|
||||
scenario("Extract BANK_ID, ACCOUNT_ID and VIEW_ID parameter values", ResourceDocMatcherTag) {
|
||||
Given("A matched ResourceDoc with BANK_ID, ACCOUNT_ID and VIEW_ID")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "getTransactionsForView")
|
||||
|
||||
When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should extract all three parameter values")
|
||||
params should contain key "BANK_ID"
|
||||
params should contain key "ACCOUNT_ID"
|
||||
params should contain key "VIEW_ID"
|
||||
params("BANK_ID") should equal("gh.29.de")
|
||||
params("ACCOUNT_ID") should equal("test1")
|
||||
params("VIEW_ID") should equal("owner")
|
||||
}
|
||||
|
||||
scenario("Match request with VIEW_ID in different position", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", "getAccountForView")
|
||||
)
|
||||
|
||||
When("Matching a GET request to /obp/v7.0.0/banks/test-bank/accounts/acc-1/public/account")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank/accounts/acc-1/public/account")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getAccountForView")
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - COUNTERPARTY_ID variable") {
|
||||
|
||||
scenario("Match request with COUNTERPARTY_ID variable", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "getCounterparty")
|
||||
)
|
||||
|
||||
When("Matching a GET request with counterparty ID")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/counterparties/ff010868-ac7d-4f96-9fc5-70dd5757e891")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getCounterparty")
|
||||
}
|
||||
|
||||
scenario("Extract COUNTERPARTY_ID parameter value", ResourceDocMatcherTag) {
|
||||
Given("A matched ResourceDoc with COUNTERPARTY_ID")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "getCounterparty")
|
||||
|
||||
When("Extracting path parameters")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/counterparties/ff010868-ac7d-4f96-9fc5-70dd5757e891")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should extract all parameter values including COUNTERPARTY_ID")
|
||||
params should contain key "BANK_ID"
|
||||
params should contain key "ACCOUNT_ID"
|
||||
params should contain key "VIEW_ID"
|
||||
params should contain key "COUNTERPARTY_ID"
|
||||
params("BANK_ID") should equal("gh.29.de")
|
||||
params("ACCOUNT_ID") should equal("test1")
|
||||
params("VIEW_ID") should equal("owner")
|
||||
params("COUNTERPARTY_ID") should equal("ff010868-ac7d-4f96-9fc5-70dd5757e891")
|
||||
}
|
||||
|
||||
scenario("Match request with COUNTERPARTY_ID in different URL structure", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for DELETE /management/counterparties/COUNTERPARTY_ID")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("DELETE", "/management/counterparties/COUNTERPARTY_ID", "deleteCounterparty")
|
||||
)
|
||||
|
||||
When("Matching a DELETE request")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/management/counterparties/counterparty-123")
|
||||
val result = ResourceDocMatcher.findResourceDoc("DELETE", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("deleteCounterparty")
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - Non-matching requests") {
|
||||
|
||||
scenario("Return None when no ResourceDoc matches", ResourceDocMatcherTag) {
|
||||
Given("ResourceDocs for specific endpoints")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks"),
|
||||
createResourceDoc("GET", "/banks/BANK_ID", "getBank"),
|
||||
createResourceDoc("POST", "/banks", "createBank")
|
||||
)
|
||||
|
||||
When("Matching a request that doesn't match any ResourceDoc")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should return None")
|
||||
result should be(None)
|
||||
}
|
||||
|
||||
scenario("Return None when verb doesn't match", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks")
|
||||
)
|
||||
|
||||
When("Matching a DELETE request to /obp/v7.0.0/banks")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("DELETE", path, resourceDocs)
|
||||
|
||||
Then("Should return None")
|
||||
result should be(None)
|
||||
}
|
||||
|
||||
scenario("Return None when path segment count doesn't match", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts")
|
||||
)
|
||||
|
||||
When("Matching a request with different segment count")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should return None")
|
||||
result should be(None)
|
||||
}
|
||||
|
||||
scenario("Return None when literal segments don't match", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for GET /banks/BANK_ID/accounts")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts")
|
||||
)
|
||||
|
||||
When("Matching a request with different literal segment")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/transactions")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should return None")
|
||||
result should be(None)
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - Path parameter extraction edge cases") {
|
||||
|
||||
scenario("Extract parameters from path with no variables", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc with no path variables")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks", "getBanks")
|
||||
|
||||
When("Extracting path parameters")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should return empty map")
|
||||
params should be(empty)
|
||||
}
|
||||
|
||||
scenario("Extract parameters with special characters in values", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc with BANK_ID")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank")
|
||||
|
||||
When("Extracting path parameters with special characters")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de-test_bank")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should extract the full value including special characters")
|
||||
params should contain key "BANK_ID"
|
||||
params("BANK_ID") should equal("gh.29.de-test_bank")
|
||||
}
|
||||
|
||||
scenario("Return empty map when path doesn't match template", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for /banks/BANK_ID")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank")
|
||||
|
||||
When("Extracting parameters from path with different segment count")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts")
|
||||
val params = ResourceDocMatcher.extractPathParams(path, resourceDoc)
|
||||
|
||||
Then("Should return empty map due to segment count mismatch")
|
||||
params should be(empty)
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - attachToCallContext") {
|
||||
|
||||
scenario("Attach ResourceDoc to CallContext", ResourceDocMatcherTag) {
|
||||
Given("A CallContext and a matched ResourceDoc")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks", "getBanks")
|
||||
val callContext = code.api.util.CallContext(
|
||||
correlationId = "test-correlation-id"
|
||||
)
|
||||
|
||||
When("Attaching ResourceDoc to CallContext")
|
||||
val updatedContext = ResourceDocMatcher.attachToCallContext(callContext, resourceDoc)
|
||||
|
||||
Then("CallContext should have resourceDocument set")
|
||||
updatedContext.resourceDocument should be(defined)
|
||||
updatedContext.resourceDocument.get should equal(resourceDoc)
|
||||
}
|
||||
|
||||
scenario("Attach ResourceDoc sets operationId", ResourceDocMatcherTag) {
|
||||
Given("A CallContext and a matched ResourceDoc")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank")
|
||||
val callContext = code.api.util.CallContext(
|
||||
correlationId = "test-correlation-id"
|
||||
)
|
||||
|
||||
When("Attaching ResourceDoc to CallContext")
|
||||
val updatedContext = ResourceDocMatcher.attachToCallContext(callContext, resourceDoc)
|
||||
|
||||
Then("CallContext should have operationId set")
|
||||
updatedContext.operationId should be(defined)
|
||||
updatedContext.operationId.get should equal(resourceDoc.operationId)
|
||||
}
|
||||
|
||||
scenario("Preserve other CallContext fields when attaching ResourceDoc", ResourceDocMatcherTag) {
|
||||
Given("A CallContext with existing fields")
|
||||
val resourceDoc = createResourceDoc("GET", "/banks", "getBanks")
|
||||
val originalContext = code.api.util.CallContext(
|
||||
correlationId = "test-correlation-id",
|
||||
url = "/obp/v7.0.0/banks",
|
||||
verb = "GET",
|
||||
implementedInVersion = "v7.0.0"
|
||||
)
|
||||
|
||||
When("Attaching ResourceDoc to CallContext")
|
||||
val updatedContext = ResourceDocMatcher.attachToCallContext(originalContext, resourceDoc)
|
||||
|
||||
Then("Other fields should be preserved")
|
||||
updatedContext.correlationId should equal(originalContext.correlationId)
|
||||
updatedContext.url should equal(originalContext.url)
|
||||
updatedContext.verb should equal(originalContext.verb)
|
||||
updatedContext.implementedInVersion should equal(originalContext.implementedInVersion)
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - Multiple ResourceDocs selection") {
|
||||
|
||||
scenario("Select correct ResourceDoc from multiple candidates", ResourceDocMatcherTag) {
|
||||
Given("Multiple ResourceDocs with different paths")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks"),
|
||||
createResourceDoc("GET", "/banks/BANK_ID", "getBank"),
|
||||
createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts"),
|
||||
createResourceDoc("POST", "/banks", "createBank")
|
||||
)
|
||||
|
||||
When("Matching a specific request")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should select the most specific matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBankAccounts")
|
||||
}
|
||||
|
||||
scenario("Match first ResourceDoc when multiple exact matches exist", ResourceDocMatcherTag) {
|
||||
Given("Multiple ResourceDocs with same path and verb")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks1"),
|
||||
createResourceDoc("GET", "/banks", "getBanks2")
|
||||
)
|
||||
|
||||
When("Matching a request")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should return the first matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBanks1")
|
||||
}
|
||||
}
|
||||
|
||||
feature("ResourceDocMatcher - Case sensitivity") {
|
||||
|
||||
scenario("HTTP verb matching is case-insensitive", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc with uppercase GET")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks")
|
||||
)
|
||||
|
||||
When("Matching with lowercase get")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("get", path, resourceDocs)
|
||||
|
||||
Then("Should find the matching ResourceDoc")
|
||||
result should be(defined)
|
||||
result.get.partialFunctionName should equal("getBanks")
|
||||
}
|
||||
|
||||
scenario("Path matching is case-sensitive for literal segments", ResourceDocMatcherTag) {
|
||||
Given("A ResourceDoc for /banks")
|
||||
val resourceDocs = ArrayBuffer(
|
||||
createResourceDoc("GET", "/banks", "getBanks")
|
||||
)
|
||||
|
||||
When("Matching with different case /Banks")
|
||||
val path = Uri.Path.unsafeFromString("/obp/v7.0.0/Banks")
|
||||
val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs)
|
||||
|
||||
Then("Should not match (case-sensitive)")
|
||||
result should be(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
420
obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
Normal file
420
obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
Normal file
@ -0,0 +1,420 @@
|
||||
package code.api.v7_0_0
|
||||
|
||||
import cats.effect.IO
|
||||
import cats.effect.unsafe.implicits.global
|
||||
import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc}
|
||||
import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles}
|
||||
import code.setup.ServerSetupWithTestData
|
||||
import net.liftweb.json.JValue
|
||||
import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString}
|
||||
import net.liftweb.json.JsonParser.parse
|
||||
import org.http4s._
|
||||
import org.http4s.dsl.io._
|
||||
import org.http4s.implicits._
|
||||
import org.scalatest.Tag
|
||||
|
||||
class Http4s700RoutesTest extends ServerSetupWithTestData {
|
||||
|
||||
object Http4s700RoutesTag extends Tag("Http4s700Routes")
|
||||
|
||||
private def runAndParseJson(request: Request[IO]): (Status, JValue) = {
|
||||
val response = Http4s700.wrappedRoutesV700Services.orNotFound.run(request).unsafeRunSync()
|
||||
val body = response.as[String].unsafeRunSync()
|
||||
val json = if (body.trim.isEmpty) JObject(Nil) else parse(body)
|
||||
(response.status, json)
|
||||
}
|
||||
|
||||
private def withDirectLoginToken(request: Request[IO], token: String): Request[IO] = {
|
||||
request.withHeaders(
|
||||
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token")
|
||||
)
|
||||
}
|
||||
|
||||
private def toFieldMap(fields: List[JField]): Map[String, JValue] = {
|
||||
fields.map(field => field.name -> field.value).toMap
|
||||
}
|
||||
|
||||
feature("Http4s700 root endpoint") {
|
||||
|
||||
scenario("Return API info JSON", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/root request")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/root")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK with API info fields")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
val keys = fields.map(_.name)
|
||||
keys should contain("version")
|
||||
keys should contain("version_status")
|
||||
keys should contain("git_commit")
|
||||
keys should contain("connector")
|
||||
case _ =>
|
||||
fail("Expected JSON object for root endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4s700 banks endpoint") {
|
||||
|
||||
scenario("Return banks list JSON", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/banks request")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK with banks array")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
val valueOpt = toFieldMap(fields).get("banks")
|
||||
valueOpt should not be empty
|
||||
valueOpt.get match {
|
||||
case JArray(_) =>
|
||||
succeed
|
||||
case _ =>
|
||||
fail("Expected banks field to be an array")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for banks endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4s700 cards endpoint") {
|
||||
|
||||
scenario("Reject unauthenticated access to cards", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/cards request without auth headers")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/cards")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 401 Unauthorized with appropriate error message")
|
||||
status.code shouldBe 401
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("message") match {
|
||||
case Some(JString(message)) =>
|
||||
message should include(AuthenticatedUserIsRequired)
|
||||
case _ =>
|
||||
fail("Expected message field as JSON string for cards unauthorized response")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for cards unauthorized response")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Return cards list JSON when authenticated", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/cards request with DirectLogin header")
|
||||
val baseRequest = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/cards")
|
||||
)
|
||||
val request = withDirectLoginToken(baseRequest, token1.value)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK with cards array")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("cards") match {
|
||||
case Some(JArray(_)) => succeed
|
||||
case _ => fail("Expected cards field to be an array")
|
||||
}
|
||||
case _ => fail("Expected JSON object for cards endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4s700 bank cards endpoint") {
|
||||
|
||||
scenario("Return bank cards list JSON when authenticated and entitled", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header and role")
|
||||
val bankId = testBankId1.value
|
||||
addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString)
|
||||
|
||||
val baseRequest = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards?limit=10&offset=0")
|
||||
)
|
||||
val request = withDirectLoginToken(baseRequest, token1.value)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK with cards array")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("cards") match {
|
||||
case Some(JArray(_)) => succeed
|
||||
case _ => fail("Expected cards field to be an array")
|
||||
}
|
||||
case _ => fail("Expected JSON object for bank cards endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Reject bank cards access when missing required role", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header but no role")
|
||||
val bankId = testBankId1.value
|
||||
val baseRequest = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards")
|
||||
)
|
||||
val request = withDirectLoginToken(baseRequest, token1.value)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 403 Forbidden")
|
||||
status.code shouldBe 403
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("message") match {
|
||||
case Some(JString(message)) =>
|
||||
message should include(UserHasMissingRoles)
|
||||
message should include(canGetCardsForBank.toString)
|
||||
case _ =>
|
||||
fail("Expected message field as JSON string for missing-role response")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for missing-role response")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Return BankNotFound when bank does not exist and user is entitled", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/banks/BANK_ID/cards request for non-existing bank")
|
||||
val bankId = "non-existing-bank-id"
|
||||
addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString)
|
||||
|
||||
val baseRequest = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards")
|
||||
)
|
||||
val request = withDirectLoginToken(baseRequest, token1.value)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 404 Not Found with BankNotFound message")
|
||||
status.code shouldBe 404
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("message") match {
|
||||
case Some(JString(message)) =>
|
||||
message should include(BankNotFound)
|
||||
case _ =>
|
||||
fail("Expected message field as JSON string for BankNotFound response")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for BankNotFound response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feature("Http4s700 resource-docs endpoint") {
|
||||
|
||||
scenario("Allow public access when resource docs role is not required", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers")
|
||||
setPropsValues("resource_docs_requires_role" -> "false")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK with resource_docs array")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("resource_docs") match {
|
||||
case Some(JArray(_)) =>
|
||||
succeed
|
||||
case _ =>
|
||||
fail("Expected resource_docs field to be an array")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for resource-docs endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Reject unauthenticated access when resource docs role is required", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers and role required")
|
||||
setPropsValues("resource_docs_requires_role" -> "true")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 401 Unauthorized")
|
||||
status.code shouldBe 401
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("message") match {
|
||||
case Some(JString(message)) =>
|
||||
message should include(AuthenticatedUserIsRequired)
|
||||
case _ =>
|
||||
fail("Expected message field as JSON string for resource-docs unauthorized response")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for resource-docs unauthorized response")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Reject access when authenticated but missing canReadResourceDoc role", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth but no canReadResourceDoc role")
|
||||
setPropsValues("resource_docs_requires_role" -> "true")
|
||||
val baseRequest = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp")
|
||||
)
|
||||
val request = withDirectLoginToken(baseRequest, token1.value)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 403 Forbidden")
|
||||
status.code shouldBe 403
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("message") match {
|
||||
case Some(JString(message)) =>
|
||||
message should include(UserHasMissingRoles)
|
||||
message should include(canReadResourceDoc.toString)
|
||||
case _ =>
|
||||
fail("Expected message field as JSON string for missing-role response")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for missing-role response")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Return docs when authenticated and entitled with canReadResourceDoc", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth and canReadResourceDoc role")
|
||||
setPropsValues("resource_docs_requires_role" -> "true")
|
||||
addEntitlement("", resourceUser1.userId, canReadResourceDoc.toString)
|
||||
|
||||
val baseRequest = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp")
|
||||
)
|
||||
val request = withDirectLoginToken(baseRequest, token1.value)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK with resource_docs array")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("resource_docs") match {
|
||||
case Some(JArray(_)) =>
|
||||
succeed
|
||||
case _ =>
|
||||
fail("Expected resource_docs field to be an array")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for resource-docs endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Filter docs by tags parameter", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card request")
|
||||
setPropsValues("resource_docs_requires_role" -> "false")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK and all returned docs contain Card tag")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("resource_docs") match {
|
||||
case Some(JArray(resourceDocs)) =>
|
||||
resourceDocs.foreach {
|
||||
case JObject(rdFields) =>
|
||||
toFieldMap(rdFields).get("tags") match {
|
||||
case Some(JArray(tags)) =>
|
||||
tags.exists {
|
||||
case JString(tag) => tag == "Card"
|
||||
case _ => false
|
||||
} shouldBe true
|
||||
case _ =>
|
||||
fail("Expected tags field to be an array")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected resource doc to be a JSON object")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected resource_docs field to be an array")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for resource-docs endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
scenario("Filter docs by functions parameter", Http4s700RoutesTag) {
|
||||
Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks request")
|
||||
setPropsValues("resource_docs_requires_role" -> "false")
|
||||
val request = Request[IO](
|
||||
method = Method.GET,
|
||||
uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks")
|
||||
)
|
||||
|
||||
When("Running through wrapped routes")
|
||||
val (status, json) = runAndParseJson(request)
|
||||
|
||||
Then("Response is 200 OK and includes GET /banks")
|
||||
status shouldBe Status.Ok
|
||||
json match {
|
||||
case JObject(fields) =>
|
||||
toFieldMap(fields).get("resource_docs") match {
|
||||
case Some(JArray(resourceDocs)) =>
|
||||
resourceDocs.foreach {
|
||||
case JObject(rdFields) =>
|
||||
val fieldMap = toFieldMap(rdFields)
|
||||
(fieldMap.get("request_verb"), fieldMap.get("request_url")) match {
|
||||
case (Some(JString(verb)), Some(JString(url))) =>
|
||||
verb shouldBe "GET"
|
||||
url should endWith("/banks")
|
||||
case _ =>
|
||||
fail("Expected request_verb and request_url fields as JSON strings")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected resource doc to be a JSON object")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected resource_docs field to be an array")
|
||||
}
|
||||
case _ =>
|
||||
fail("Expected JSON object for resource-docs endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user