Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
.settings
.metals
.vscode
.claude/settings.local.json
*.code-workspace
.zed
.cursor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
case ApiVersion.v7_0_0 => resourceDocs
case ConstantsBG.`berlinGroupVersion2` => resourceDocs
case ApiVersion.v1_2_1 => resourceDocs
case ApiVersion.v6_0_0 => resourceDocs // fully on http4s — no Lift route filter
case _ => resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ object RateLimitingUtil extends MdcLoggable {
}

private def incrementConsumerCounters(consumerKey: String, period: LimitCallPeriod, limit: Long): (Long, Long) = {
if (useConsumerLimits) {
if (useConsumerLimits && limit > 0) {
incrementCounter(createUniqueKey(consumerKey, period), period)
} else {
(-1, -1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ object JSONFactory1_4_0 extends MdcLoggable{
jsonRequestBodyFieldsI18n:String,
jsonResponseBodyFieldsI18n:String
): ResourceDocJson = {
val cacheKey = LOCALISED_RESOURCE_DOC_PREFIX + s"operationId:${operationId}-locale:$locale- isVersion4OrHigher:$isVersion4OrHigher- includeTechnology:$includeTechnology".intern()
val cacheKey = LOCALISED_RESOURCE_DOC_PREFIX + s"operationId:${operationId}-locale:$locale- isVersion4OrHigher:$isVersion4OrHigher- includeTechnology:$includeTechnology-specifiedUrl:${resourceDocUpdatedTags.specifiedUrl.getOrElse("")}".intern()
Caching.memoizeSyncWithImMemory(Some(cacheKey))(CREATE_LOCALISED_RESOURCE_DOC_JSON_TTL.seconds) {
val fieldsDescription =
if (resourceDocUpdatedTags.tags.toString.contains("Dynamic-Entity")
Expand Down
34,326 changes: 17,170 additions & 17,156 deletions obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala

Large diffs are not rendered by default.

78 changes: 65 additions & 13 deletions obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection, Res
import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps}
import code.api.util.newstyle.ViewNewStyle
import code.api.v2_0_0.JSONFactory200
import code.api.v5_0_0.Http4s500
import code.api.v5_1_0.{Http4s510, JSONFactory510}
import code.api.v6_0_0.JSONFactory600.ScannedApiVersionJsonV600
import code.accountattribute.AccountAttributeX
Expand Down Expand Up @@ -80,12 +81,11 @@ import scala.concurrent.Future
* v6.0.0 http4s endpoints — Phase 1 in progress.
*
* Wire-in into `Http4sApp.baseServices` is performed alongside this object.
* The v600→v510 bridge (`v600ToV510Bridge`) is intentionally NOT appended to
* `allRoutes`: unmigrated v6 paths must fall through the http4s chain to the
* Lift fallback, which still serves the v6 Lift handlers. Adding the bridge
* would let v6 *overrides* be hijacked into v5.1 handlers (CLAUDE.md →
* "Bridge-cascade hijack"). The bridge val is kept here so it can be enabled
* later if the team decides to short-circuit Lift for v6 originals.
* The v600→v500 bridge (`v600ToV500Bridge`) rewrites unhandled v6.0.0 paths
* to v5.0.0 and delegates to Http4s500.wrappedRoutesV500Services, which has a
* working cascade chain (v5.0.0 → v4.0.0 → v3.1.0 → v3.0.0). The bridge
* skips v5.1.0 because Http4s510's own bridge to v5.0.0 is disabled due to
* MetricTest / VRPConsentRequestTest regressions.
*/
object Http4s600 {

Expand Down Expand Up @@ -1790,6 +1790,7 @@ object Http4s600 {
.orElse(getConnectorTraces(req))
.orElse(getDynamicEntityDiagnostics(req))
.orElse(cleanupOrphanedDynamicEntityRecords(req))
.orElse(createWebUiProps(req))
.orElse(createOrUpdateWebUiProps(req))
.orElse(deleteWebUiProps(req))
.orElse(createCustomViewManagement(req))
Expand Down Expand Up @@ -2249,6 +2250,36 @@ object Http4s600 {
Some(canCleanupOrphanedDynamicEntityRecords :: Nil),
http4sPartialFunction = Some(cleanupOrphanedDynamicEntityRecords))

// POST /obp/v6.0.0/management/webui_props
lazy val createWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> `prefixPath` / "management" / "webui_props" =>
EndpointHelpers.withUserAndBodyCreated[WebUiPropsCommons, Any](req) { (user, postedData, cc) =>
for {
_ <- NewStyle.function.hasEntitlement("", user.userId, canCreateWebUiProps, Some(cc))
_ <- NewStyle.function.tryons(
s"""$InvalidWebUiProps name must be start with webui_, but current post name is: ${postedData.name} """,
400, Some(cc)) { require(postedData.name.startsWith("webui_")) }
webUiProps <- Future(MappedWebUiPropsProvider.createOrUpdate(postedData)) map {
unboxFullOrFail(_, Some(cc))
}
} yield (webUiProps: WebUiPropsCommons)
}
}

resourceDocs += ResourceDoc(
null, implementedInApiVersion, nameOf(createWebUiProps), "POST",
"/management/webui_props",
"Create WebUiProps",
s"""Create a WebUiProps.
|
|${APIUtil.userAuthenticationMessage(true)}
|""",
WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com"),
WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id")),
List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError),
List(apiTagWebUiProps), Some(List(canCreateWebUiProps)),
http4sPartialFunction = Some(createWebUiProps))

// PUT /obp/v6.0.0/management/webui_props/WEBUI_PROP_NAME
lazy val createOrUpdateWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ PUT -> `prefixPath` / "management" / "webui_props" / webUiPropName =>
Expand Down Expand Up @@ -3179,7 +3210,11 @@ object Http4s600 {
case req @ GET -> `prefixPath` / "products" =>
EndpointHelpers.withUser(req) { (_, cc) =>
val params = req.uri.query.multiParams.toList.map { case (k, vs) => GetProductsParam(k, vs.toList) }
val cacheKey = APIMethods600.productsCacheKey("__all__", params)
val cacheKey = {
val canonical = params.map(p => p.name -> p.value.sorted).sortBy(_._1)
.map { case (n, vs) => s"$n=${vs.mkString(",")}" }.mkString("&")
s"productsV600:__all__:$canonical"
}
val cacheTTL = APIUtil.getPropsAsIntValue("getAllProductsV600.cache.ttl.seconds", 60)
val hit = code.api.cache.Caching.getFinancialProductsCache(cacheKey, cacheTTL)
.flatMap(s => try Some(net.liftweb.json.parse(s).extract[ProductsJsonV600])
Expand Down Expand Up @@ -8470,21 +8505,38 @@ object Http4s600 {
val allRoutesWithMiddleware: HttpRoutes[IO] =
ResourceDocMiddleware.apply(resourceDocs)(allRoutes)

// ─── path-rewriting bridge: /obp/v6.0.0/… → /obp/v5.1.0/… ─────────────
// ─── path-rewriting bridge: /obp/v6.0.0/… → /obp/v5.0.0/… ─────────────
// Targets v5.0.0 (not v5.1.0) because Http4s510's bridge to v5.0.0 is
// disabled (MetricTest / VRPConsentRequestTest regressions). Http4s500 has
// its own working cascade: v5.0.0 → v4.0.0 → v3.1.0 → v3.0.0.
// NOT appended to allRoutes — see object-level scaladoc.
val v600ToV510Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req =>
val v600ToV500Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req =>
val rawPath = req.uri.path.renderString
if (rawPath.startsWith("/obp/v6.0.0/")) {
val rewritten = rawPath.replaceFirst("/obp/v6\\.0\\.0/", "/obp/v5.1.0/")
val rewritten = rawPath.replaceFirst("/obp/v6\\.0\\.0/", "/obp/v5.0.0/")
val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten))
val rewrittenReq = req.withUri(newUri)
Http4s510.wrappedRoutesV510Services.run(rewrittenReq)
Http4s500.wrappedRoutesV500Services.run(rewrittenReq)
} else {
OptionT.none[IO, Response[IO]]
}
}
}

val wrappedRoutesV600Services: HttpRoutes[IO] =
Implementations6_0_0.allRoutesWithMiddleware
// `lazy val`, not `val`: `OBPAPI6_0_0` and `APIMethods600` reference
// `Http4s600.Implementations6_0_0` directly via getstatic. When either is loaded
// first (during Lift's Boot), the JVM triggers `Implementations6_0_0.<clinit>`
// before `Http4s600.<clinit>`. Resource-doc registrations inside Impl6.<init>
// reference `Http4s600.MODULE$`, triggering `Http4s600.<clinit>` recursively on
// the same thread. JVM allows recursive class init; the partially-initialised
// `Impl6.MODULE$` is returned. The strict-val `wrappedRoutesV600Services =
// Impl6.allRoutesWithMiddleware` then reads the not-yet-assigned
// `allRoutesWithMiddleware` field (still null) and writes null permanently.
// A `lazy val` defers the read until first access (from Http4sApp after Boot
// completes), by which time Impl6 is fully initialised.
lazy val wrappedRoutesV600Services: HttpRoutes[IO] =
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
Implementations6_0_0.allRoutesWithMiddleware.run(req)
.orElse(Implementations6_0_0.v600ToV500Bridge.run(req))
}
}
114 changes: 29 additions & 85 deletions obp-api/src/main/scala/code/api/v6_0_0/OBPAPI6_0_0.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ package code.api.v6_0_0

import scala.language.reflectiveCalls
import code.api.OBPRestHelper
import code.api.util.APIUtil.{OBPEndpoint, getAllowedEndpoints}
import code.api.util.APIUtil.OBPEndpoint
import code.api.util.VersionedOBPApis
import code.api.v1_3_0.APIMethods130
import code.api.v1_4_0.APIMethods140
Expand All @@ -45,118 +45,62 @@ import code.api.v5_1_0.{APIMethods510, OBPAPI5_1_0}
import code.util.Helper.MdcLoggable
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus}
import net.liftweb.common.{Box, Full}
import net.liftweb.http.{LiftResponse, PlainTextResponse}
import org.apache.http.HttpStatus

/*
This file defines which endpoints from all the versions are available in v5.0.0
This file defines which endpoints from all the versions are available in v6.0.0.
All v6.0.0 endpoints have been migrated to Http4s600 — this object is retained
only for resource-doc aggregation and the Lift dispatch registry.
*/
object OBPAPI6_0_0 extends OBPRestHelper
with APIMethods130
with APIMethods140
with APIMethods200
with APIMethods210
with APIMethods220
with APIMethods300
with CustomAPIMethods300
with APIMethods310
with APIMethods400
with APIMethods500
with APIMethods510
with APIMethods600
object OBPAPI6_0_0 extends OBPRestHelper
with APIMethods130
with APIMethods140
with APIMethods200
with APIMethods210
with APIMethods220
with APIMethods300
with CustomAPIMethods300
with APIMethods310
with APIMethods400
with APIMethods500
with APIMethods510
with MdcLoggable
with VersionedOBPApis{

val version : ApiVersion = ApiVersion.v6_0_0

val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString

// Possible Endpoints from 5.1.0, exclude one endpoint use - method,exclude multiple endpoints use -- method,
// e.g getEndpoints(Implementations5_0_0) -- List(Implementations5_0_0.genericEndpoint, Implementations5_0_0.root)
lazy val endpointsOf6_0_0 = getEndpoints(Implementations6_0_0)

// Exclude v5.1.0 root endpoint since v6.0.0 has its own
lazy val endpointsOf5_1_0_without_root = OBPAPI5_1_0.routes.filterNot(_ == Implementations5_1_0.root)

/*
* IMPORTANT: Endpoint Exclusion Pattern
*
* excludeEndpoints is used to filter out old endpoints when v6.0.0 has a DIFFERENT URL pattern.
*
* WHEN TO EXCLUDE:
* - Old and new endpoints have DIFFERENT URLs (e.g., v4.0.0: /users/:username vs v6.0.0: /providers/:provider/users/:username)
* - The old endpoint should not be accessible via v6.0.0 at all
*
* WHEN NOT TO EXCLUDE:
* - Old and new endpoints have the SAME URL and HTTP method (e.g., GET /api/versions)
* - In this case, collectResourceDocs() automatically deduplicates by (URL, method) and keeps newest version
* - Excluding by function name would remove BOTH versions since they share the same name!
*
* Why? The routing works as follows:
* 1. endpoints list = endpointsOf6_0_0 ++ endpointsOf5_1_0_without_root (contains BOTH old and new)
* 2. allResourceDocs = collectResourceDocs() deduplicates docs by (URL, method), keeps newest
* 3. excludeEndpoints filters ResourceDocs by partialFunctionName (removes by name, not by version)
* 4. getAllowedEndpoints() filters endpoints to only those with matching ResourceDocs
*
* Pattern: Add nameOf(Implementations{version}.endpointName) :: with a comment explaining why
*/
lazy val excludeEndpoints =
nameOf(Implementations3_0_0.getUserByUsername) :: // following 4 endpoints miss Provider parameter in the URL, we introduce new ones in V600.
// Re-export so tests that import OBPAPI6_0_0.Implementations6_0_0 still compile.
val Implementations6_0_0 = Http4s600.Implementations6_0_0

lazy val excludeEndpoints =
nameOf(Implementations3_0_0.getUserByUsername) ::
nameOf(Implementations3_1_0.getBadLoginStatus) ::
nameOf(Implementations3_1_0.unlockUser) ::
nameOf(Implementations4_0_0.lockUser) ::
// NOTE: getScannedApiVersions is NOT excluded here because it has the same URL in both v4.0.0 and v6.0.0
// collectResourceDocs() automatically deduplicates by (URL, HTTP method) and keeps the newest version (v6.0.0)
// Excluding by function name would incorrectly filter out BOTH versions since they share the same function name
nameOf(Implementations4_0_0.createUserWithAccountAccess) :: // following 3 endpoints miss ViewId parameter in the URL, we introduce new ones in V600.
nameOf(Implementations4_0_0.createUserWithAccountAccess) ::
nameOf(Implementations4_0_0.grantUserAccessToView) ::
nameOf(Implementations4_0_0.revokeUserAccessToView) ::
nameOf(Implementations4_0_0.revokeGrantUserAccessToViews) ::// this endpoint is forbidden in V600, we do not support multi views in one endpoint from V600.
// v4.0.0 personal user attribute endpoints replaced by /my/personal-data-fields in v6.0.0
nameOf(Implementations4_0_0.revokeGrantUserAccessToViews) ::
nameOf(Implementations4_0_0.getMyPersonalUserAttributes) ::
nameOf(Implementations4_0_0.createMyPersonalUserAttribute) ::
nameOf(Implementations4_0_0.updateMyPersonalUserAttribute) ::
// v5.1.0 non-personal user attribute endpoints replaced by /users/USER_ID/attributes in v6.0.0
nameOf(Implementations5_1_0.createNonPersonalUserAttribute) ::
nameOf(Implementations5_1_0.getNonPersonalUserAttributes) ::
nameOf(Implementations5_1_0.deleteNonPersonalUserAttribute) ::
Nil
// if old version ResourceDoc objects have the same name endpoint with new version, omit old version ResourceDoc.

// All v6.0.0 endpoints live in Http4s600 — aggregate Http4s600.resourceDocs on top of v5.1.0.
def allResourceDocs = collectResourceDocs(
OBPAPI5_1_0.allResourceDocs,
Implementations6_0_0.resourceDocs
Http4s600.resourceDocs
).filterNot(it => it.partialFunctionName.matches(excludeEndpoints.mkString("|")))

// all endpoints - v6.0.0 endpoints first so they take precedence over v5.1.0
private val endpoints: List[OBPEndpoint] = endpointsOf6_0_0.toList ++ endpointsOf5_1_0_without_root

// Filter the possible endpoints by the disabled / enabled Props settings and add them together
// Make root endpoint mandatory (prepend it)
val routes : List[OBPEndpoint] = Implementations6_0_0.root ::
getAllowedEndpoints(endpoints, allResourceDocs)
// No Lift routes — all v6.0.0 endpoints are served by Http4s600.
val routes: List[OBPEndpoint] = Nil

registerRoutes(routes, allResourceDocs, apiPrefix, true)


logger.info(s"version $version has been run! There are ${routes.length} routes, ${allResourceDocs.length} allResourceDocs.")

// specified response for OPTIONS request.
private val corsResponse: Box[LiftResponse] = Full{
val corsHeaders = List(
"Access-Control-Allow-Origin" -> "*",
"Access-Control-Allow-Methods" -> "GET, POST, OPTIONS, PUT, PATCH, DELETE",
"Access-Control-Allow-Headers" -> "*",
"Access-Control-Allow-Credentials" -> "true",
"Access-Control-Max-Age" -> "1728000" //Tell client that this pre-flight info is valid for 20 days
)
PlainTextResponse("", corsHeaders, HttpStatus.SC_NO_CONTENT)
}
/*
* process OPTIONS http request, just return no content and status is 204
*/
this.serve({
case req if req.requestType.method == "OPTIONS" => corsResponse
})
// CORS for OPTIONS is handled by the http4s corsHandler layer — no Lift serve needed here.
}
Loading
Loading