diff --git a/CLAUDE.md b/CLAUDE.md index 279490b426..68c5e30592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,9 @@ The goal is a full http4s migration — replace Lift Web across all version file **Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). -**Tests**: `Http4s700RoutesTest` (102 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. +**v7.0.0 native endpoints** (45 ResourceDocs): root, corePrivateAccountsAllBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. +**Tests**: `Http4s700RoutesTest` (86 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. ## Migrating a Lift Endpoint to http4s Rules apply regardless of which version file the endpoint lives in. Use v7.0.0 only when the API signature is new or changed; otherwise migrate in-place in the original version file. diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index bb0e2c77d9..ed37d752eb 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -219,109 +219,6 @@ object Http4s700 { // ── POC endpoints — one per EndpointHelper category ──────────────────── - // Category: withBank (no user auth, bank resolved from BANK_ID by middleware) - val getBank: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / _ => - EndpointHelpers.withBank(req) { (bank, cc) => - for { - (attributes, _) <- NewStyle.function.getBankAttributesByBank(bank.bankId, Some(cc)) - } yield { - JSONFactory600.createBankJsonV600(bank, attributes) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getBank), - "GET", - "/banks/BANK_ID", - "Get Bank", - """Get the bank specified by BANK_ID. Returns information about a single bank including name, logo and website.""", - EmptyBody, - BankJsonV600("gh.29.uk", "OBP", "Open Bank Project", "https://example.com/logo.png", "https://openbankproject.com", Nil, None), - List(BankNotFound, UnknownError), - apiTagBank :: Nil, - http4sPartialFunction = Some(getBank) - ) - - // Category: withUser (user auth, no bank) - val getCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "users" / "current" => - EndpointHelpers.withUser(req) { (user, cc) => - for { - entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) - } yield { - val permissions = Views.views.vend.getPermissionForUser(user).toOption - val virtualRoleNames = - if (APIUtil.isSuperAdmin(user.userId)) APIUtil.superAdminVirtualRoles - else if (APIUtil.isOidcOperator(user.userId)) APIUtil.oidcOperatorVirtualRoles - else Nil - val existingRoleNames = entitlements.map(_.roleName).toSet - val virtualEntitlements = virtualRoleNames.filterNot(existingRoleNames.contains).map { role => - new Entitlement { - def entitlementId = "" - def bankId = "" - def userId = user.userId - def roleName = role - def createdByProcess = if (APIUtil.isSuperAdmin(user.userId)) "super_admin_user_ids" else "oidc_operator_user_ids" - def entitlementRequestId: Option[String] = None - def groupId: Option[String] = None - def process: Option[String] = None - } - } - JSONFactory600.createUserInfoJSON(UserV600(user, entitlements ::: virtualEntitlements, permissions), None) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCurrentUser), - "GET", - "/users/current", - "Get User (Current)", - """Get the logged in user. Returns profile, entitlements and views.""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, UnknownError), - apiTagUser :: Nil, - http4sPartialFunction = Some(getCurrentUser) - ) - - // Category: withBankAccount (user + account resolved from BANK_ID + ACCOUNT_ID by middleware) - val getCoreAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / _ / "account" => - EndpointHelpers.withBankAccount(req) { (user, account, cc) => - for { - view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView( - user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) - moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) - } yield { - val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( - user, BankIdAccountId(account.bankId, account.accountId)) - JSONFactory600.createModeratedCoreAccountJsonV600(moderatedAccount, availableViews) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCoreAccountById), - "GET", - "/my/banks/BANK_ID/accounts/ACCOUNT_ID/account", - "Get Account by Id (Core)", - """Returns core information about the account specified by ACCOUNT_ID including balance, routings and available views.""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankAccountNotFound, UnknownError), - apiTagAccount :: Nil, - http4sPartialFunction = Some(getCoreAccountById) - ) - // ─── corePrivateAccountsAllBanks (v7) ───────────────────────────────────── // Same semantics as v3.0.0 /my/accounts but with renamed fields so callers // can read the (bank_id, account_id, view_id) tuple without remapping. @@ -378,68 +275,6 @@ object Http4s700 { } } - // Category: withView (user + account + view resolved from BANK_ID + ACCOUNT_ID + VIEW_ID by middleware) - val getPrivateAccountByIdFull: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "account" => - EndpointHelpers.withView(req) { (user, account, view, cc) => - for { - moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) - (accountAttributes, _) <- NewStyle.function.getAccountAttributesByAccount( - account.bankId, account.accountId, Some(cc)) - } yield { - val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( - user, BankIdAccountId(account.bankId, account.accountId)) - val viewsAvailable = availableViews.map(JSONFactory600.createViewJsonV600).sortBy(_.view_name) - val tags = Tags.tags.vend.getTagsOnAccount(account.bankId, account.accountId)(ViewId(viewIdStr)) - JSONFactory600.createBankAccountJSON600(moderatedAccount, viewsAvailable, accountAttributes, tags) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getPrivateAccountByIdFull), - "GET", - "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", - "Get Account by Id (Full)", - """Returns full information about an account as moderated by the view (VIEW_ID).""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), - apiTagAccount :: Nil, - http4sPartialFunction = Some(getPrivateAccountByIdFull) - ) - - // Category: withCounterparty (user + account + view + counterparty resolved by middleware) - val getExplicitCounterpartyById: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" / counterpartyIdStr => - EndpointHelpers.withCounterparty(req) { (user, account, view, counterparty, cc) => - for { - _ <- Helper.booleanToFuture( - failMsg = s"${NoViewPermission}can_get_counterparty", 403, cc = Some(cc))( - view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY)) - counterpartyMetadata <- NewStyle.function.getMetadata( - account.bankId, account.accountId, counterpartyIdStr, Some(cc)) - } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getExplicitCounterpartyById), - "GET", - "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", - "Get Counterparty by Id (Explicit)", - """Returns a single Counterparty on an Account View specified by COUNTERPARTY_ID.""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), - apiTagCounterparty :: Nil, - http4sPartialFunction = Some(getExplicitCounterpartyById) - ) - // Category: withUserDelete (user auth, 204 No Content) val deleteEntitlement: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "entitlements" / entitlementId => @@ -673,44 +508,6 @@ object Http4s700 { // ── Phase 1 — Simple GETs ─────────────────────────────────────────────── - // Route: GET /obp/v7.0.0/features - val getFeatures: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "features" => - EndpointHelpers.executeAndRespond(req) { _ => - Future.successful(FeaturesJsonV600( - allow_public_views = APIUtil.getPropsAsBoolValue("allow_public_views", false), - allow_abac_account_access = APIUtil.getPropsAsBoolValue("allow_abac_account_access", false), - allow_account_firehose = APIUtil.getPropsAsBoolValue("allow_account_firehose", false), - allow_customer_firehose = APIUtil.getPropsAsBoolValue("allow_customer_firehose", false), - allow_direct_login = APIUtil.getPropsAsBoolValue("allow_direct_login", true), - allow_gateway_login = APIUtil.getPropsAsBoolValue("allow_gateway_login", false), - allow_oauth2_login = APIUtil.getPropsAsBoolValue("allow_oauth2_login", true), - allow_dauth = APIUtil.getPropsAsBoolValue("allow_dauth", false), - allow_sandbox_account_creation = APIUtil.getPropsAsBoolValue("allow_sandbox_account_creation", false), - allow_sandbox_data_import = APIUtil.getPropsAsBoolValue("allow_sandbox_data_import", false), - allow_account_deletion = APIUtil.getPropsAsBoolValue("allow_account_deletion", false), - allow_just_in_time_entitlements = APIUtil.getPropsAsBoolValue("create_just_in_time_entitlements", false) - )) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getFeatures), - "GET", - "/features", - "Get Features", - """Returns information about the features enabled on this OBP instance. - | - |No Authentication is Required.""", - EmptyBody, - FeaturesJsonV600(false, false, false, false, true, false, true, false, false, false, false, false), - List(UnknownError), - apiTagApi :: Nil, - http4sPartialFunction = Some(getFeatures) - ) - // Route: GET /obp/v7.0.0/consents/config // Anonymous: operator-published policy that TPPs/agents need to know before issuing a consent. val getConsentsConfig: HttpRoutes[IO] = HttpRoutes.of[IO] { @@ -745,82 +542,6 @@ object Http4s700 { http4sPartialFunction = Some(getConsentsConfig) ) - // Route: GET /obp/v7.0.0/api/versions - val getScannedApiVersions: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "api" / "versions" => - EndpointHelpers.executeAndRespond(req) { _ => - Future { - val versions = ApiVersion.allScannedApiVersion.asScala.toList - .filter(v => v.urlPrefix.trim.nonEmpty) - .map { v => - JSONFactory600.ScannedApiVersionJsonV600( - url_prefix = v.urlPrefix, - api_standard = v.apiStandard, - api_short_version = v.apiShortVersion, - fully_qualified_version= v.fullyQualifiedVersion, - is_active = versionIsAllowed(v) - ) - } - ListResult("scanned_api_versions", versions) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getScannedApiVersions), - "GET", - "/api/versions", - "Get Scanned API Versions", - """Get all scanned API versions available in this codebase including their active status.""", - EmptyBody, - ListResult( - "scanned_api_versions", - List(JSONFactory600.ScannedApiVersionJsonV600("obp", "OBP", "v6.0.0", "OBPv6.0.0", true)) - ), - List(UnknownError), - apiTagDocumentation :: apiTagApi :: Nil, - http4sPartialFunction = Some(getScannedApiVersions) - ) - - // Route: GET /obp/v7.0.0/system/connectors - val getConnectors: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "connectors" => - EndpointHelpers.executeAndRespond(req) { _ => - Future.successful { - val connectorNames = BankConnector.nameToConnector.keys.toList :+ "star" - val connectorInfos = connectorNames.map { name => - ConnectorInfoJsonV600( - connector_name = name, - is_available_in_method_routing = NewStyle.function.getConnectorByName(name).isDefined - ) - } - JSONFactory600.createConnectorsJson(connectorInfos) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getConnectors), - "GET", - "/system/connectors", - "Get Connectors", - """Get the list of connectors and their availability for method routing. - | - |Authentication is Optional.""", - EmptyBody, - ConnectorsJsonV600(List( - ConnectorInfoJsonV600("mapped", true), - ConnectorInfoJsonV600("star", true) - )), - List(UnknownError), - apiTagConnector :: apiTagSystem :: apiTagApi :: Nil, - http4sPartialFunction = Some(getConnectors) - ) - // Route: GET /obp/v7.0.0/api/error-messages val getErrorMessages: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "api" / "error-messages" => @@ -859,65 +580,8 @@ object Http4s700 { http4sPartialFunction = Some(getErrorMessages) ) - // Route: GET /obp/v7.0.0/providers - val getProviders: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "providers" => - EndpointHelpers.withUser(req) { (_, cc) => - for { - providers <- Future { code.model.dataAccess.ResourceUser.getDistinctProviders } - } yield JSONFactory600.createProvidersJson(providers) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getProviders), - "GET", - "/providers", - "Get Providers", - """Get the list of authentication providers that have been used to create users on this OBP instance.""", - EmptyBody, - JSONFactory600.createProvidersJson(List("http://127.0.0.1:8080", "OBP")), - List($AuthenticatedUserIsRequired, UnknownError), - apiTagUser :: Nil, - http4sPartialFunction = Some(getProviders) - ) - // ── Phase 1 batch 2 ───────────────────────────────────────────────────── - // Route: GET /obp/v7.0.0/users - val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "users" => - EndpointHelpers.withUser(req) { (_, cc) => - for { - httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) - (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) - rows <- UserVend.users.vend.getUsersV600F(obpQueryParams) - } yield JSONFactory600.createUsersInfoJsonV600(rows) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getUsers), - "GET", - "/users", - "Get all Users", - """Get all users. - | - |Authentication is required. - | - |CanGetAnyUser entitlement is required.""", - EmptyBody, - usersInfoJsonV600, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagUser :: Nil, - Some(List(canGetAnyUser)), - http4sPartialFunction = Some(getUsers) - ) - // Route: GET /obp/v7.0.0/users/user-id/USER_ID val getUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / "user-id" / userId => @@ -980,136 +644,6 @@ object Http4s700 { http4sPartialFunction = Some(getUserByUserId) ) - // Route: GET /obp/v7.0.0/banks/BANK_ID/customers - val getCustomersAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers" => - EndpointHelpers.withUserAndBank(req) { (_, _, cc) => - val bankId = BankId(bankIdStr) - for { - (requestParams, _) <- NewStyle.function.extractQueryParams( - req.uri.renderString, - List("limit", "offset", "sort_direction"), - cc.callContext - ) - customers <- NewStyle.function.getCustomers(bankId, cc.callContext, requestParams) - } yield JSONFactory600.createCustomersJson(customers.sortBy(_.bankId)) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCustomersAtOneBank), - "GET", - "/banks/BANK_ID/customers", - "Get Customers at Bank", - """Get Customers at Bank. - | - |Returns a list of all customers at the specified bank. - | - |Authentication is required.""", - EmptyBody, - customerJSONsV600, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagCustomer :: apiTagUser :: Nil, - Some(List(canGetCustomersAtOneBank)), - http4sPartialFunction = Some(getCustomersAtOneBank) - ) - - // Route: GET /obp/v7.0.0/banks/BANK_ID/customers/CUSTOMER_ID - val getCustomerByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId => - EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => - for { - (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, cc.callContext) - (customerAttributes, _) <- NewStyle.function.getCustomerAttributes( - bank.bankId, - CustomerId(customerId), - callContext - ) - } yield JSONFactory600.createCustomerWithAttributesJson(customer, customerAttributes) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCustomerByCustomerId), - "GET", - "/banks/BANK_ID/customers/CUSTOMER_ID", - "Get Customer by CUSTOMER_ID", - """Gets the Customer specified by CUSTOMER_ID. - | - |Authentication is required.""", - EmptyBody, - customerWithAttributesJsonV600, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagCustomer :: Nil, - Some(List(canGetCustomersAtOneBank)), - http4sPartialFunction = Some(getCustomerByCustomerId) - ) - - // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts - val getAccountsAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" => - EndpointHelpers.withUserAndBank(req) { (u, bank, cc) => - val bankId = BankId(bankIdStr) - for { - (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { - Views.views.vend.privateViewsUserCanAccessAtBank(u, bankId) - } - params <- Future { - req.uri.query.multiParams - .filterNot(_._1 == PARAM_TIMESTAMP) - .filterNot(_._1 == PARAM_LOCALE) - .map { case (k, vs) => k -> vs.toList } - } - privateAccountAccess2 <- - if (params.isEmpty || privateAccountAccess.isEmpty) { - Future.successful(privateAccountAccess) - } else { - AccountAttributeX.accountAttributeProvider.vend - .getAccountIdsByParams(bankId, params) - .map { boxedAccountIds => - val accountIds = boxedAccountIds.getOrElse(Nil) - privateAccountAccess.filter(aa => accountIds.contains(aa.account_id.get)) - } - } - (availablePrivateAccounts, _) <- code.model.BankExtended(bank).privateAccountsFuture(privateAccountAccess2, cc.callContext) - } yield { - val accountsJson = availablePrivateAccounts.map { account => - val viewsAvailable = privateViewsUserCanAccessAtOneBank - .filter(v => v.bankId == bankId && v.accountId == account.accountId) - .map(v => BasicViewJson(v.viewId.value, v.name, v.isPublic)) - JSONFactory600.createBasicAccountJsonV600(account, viewsAvailable) - } - BasicAccountsJsonV600(accountsJson) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getAccountsAtBank), - "GET", - "/banks/BANK_ID/accounts", - "Get Accounts at Bank", - """Returns the list of accounts at BANK_ID that the user has access to. - | - |Authentication is required.""", - EmptyBody, - BasicAccountsJsonV600(List(BasicAccountJsonV600( - account_id = "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", - bank_id = "gh.29.uk", - label = "My Account", - views_available = List(BasicViewJson("owner", "Owner", false)) - ))), - List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), - apiTagAccount :: apiTagPrivateData :: apiTagPublicData :: Nil, - http4sPartialFunction = Some(getAccountsAtBank) - ) - // ── Trading Endpoints ────────────────────────────────────────────────── // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers @@ -2127,231 +1661,6 @@ object Http4s700 { // ── Phase 1 batch 3 — system endpoints ────────────────────────────────── - // Route: GET /obp/v7.0.0/system/cache/config - val getCacheConfig: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "cache" / "config" => - EndpointHelpers.withUser(req) { (_, cc) => - Future.successful(JSONFactory600.createCacheConfigJsonV600()) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCacheConfig), - "GET", - "/system/cache/config", - "Get Cache Configuration", - """Returns cache configuration including Redis status, in-memory cache status, instance ID, environment and global prefix.""", - EmptyBody, - CacheConfigJsonV600( - redis_status = RedisCacheStatusJsonV600(available = true, url = "127.0.0.1", port = 6379, use_ssl = false), - in_memory_status = InMemoryCacheStatusJsonV600(available = true, current_size = 42), - instance_id = "obp", - environment = "dev", - global_prefix = "obp_dev_" - ), - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagCache :: apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetCacheConfig)), - http4sPartialFunction = Some(getCacheConfig) - ) - - // Route: GET /obp/v7.0.0/system/cache/info - val getCacheInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "cache" / "info" => - EndpointHelpers.withUser(req) { (_, cc) => - Future.successful(JSONFactory600.createCacheInfoJsonV600()) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCacheInfo), - "GET", - "/system/cache/info", - "Get Cache Information", - """Returns detailed cache information for all namespaces including key counts, TTL info and storage location.""", - EmptyBody, - CacheInfoJsonV600( - namespaces = List(CacheNamespaceInfoJsonV600( - namespace_id = "call_counter", - prefix = "obp_dev_call_counter_1_", - current_version = 1, - key_count = 42, - description = "Rate limit call counters", - category = "Rate Limiting", - storage_location = "redis", - ttl_info = "range 60s to 86400s (avg 3600s)" - )), - total_keys = 42, - redis_available = true - ), - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagCache :: apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetCacheInfo)), - http4sPartialFunction = Some(getCacheInfo) - ) - - // Route: GET /obp/v7.0.0/system/database/pool - val getDatabasePoolInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "database" / "pool" => - EndpointHelpers.withUser(req) { (_, cc) => - Future.successful(JSONFactory600.createDatabasePoolInfoJsonV600()) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getDatabasePoolInfo), - "GET", - "/system/database/pool", - "Get Database Pool Information", - """Returns HikariCP connection pool information including active/idle connections, pool size and timeouts.""", - EmptyBody, - DatabasePoolInfoJsonV600( - pool_name = "HikariPool-1", - active_connections = 5, - idle_connections = 3, - total_connections = 8, - threads_awaiting_connection = 0, - maximum_pool_size = 10, - minimum_idle = 2, - connection_timeout_ms = 30000, - idle_timeout_ms = 600000, - max_lifetime_ms = 1800000, - keepalive_time_ms = 0 - ), - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetDatabasePoolInfo)), - http4sPartialFunction = Some(getDatabasePoolInfo) - ) - - // Route: GET /obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health - val getStoredProcedureConnectorHealth: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "connectors" / "stored_procedure_vDec2019" / "health" => - EndpointHelpers.withUser(req) { (_, cc) => - Future { - val health = StoredProcedureUtils.getHealth() - StoredProcedureConnectorHealthJsonV600( - status = health.status, - server_name = health.serverName, - server_ip = health.serverIp, - database_name = health.databaseName, - response_time_ms = health.responseTimeMs, - error_message = health.errorMessage - ) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getStoredProcedureConnectorHealth), - "GET", - "/system/connectors/stored_procedure_vDec2019/health", - "Get Stored Procedure Connector Health", - """Returns health status of the stored procedure connector including connection status, server name and response time.""", - EmptyBody, - StoredProcedureConnectorHealthJsonV600( - status = "ok", - server_name = Some("DBSERVER01"), - server_ip = Some("10.0.1.50"), - database_name = Some("obp_adapter"), - response_time_ms = 45, - error_message = None - ), - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagConnector :: apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetConnectorHealth)), - http4sPartialFunction = Some(getStoredProcedureConnectorHealth) - ) - - // Route: GET /obp/v7.0.0/system/migrations - val getMigrations: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "migrations" => - EndpointHelpers.withUser(req) { (_, cc) => - Future { - val migrations = MigrationScriptLogProvider.migrationScriptLogProvider.vend.getMigrationScriptLogs() - JSONFactory600.createMigrationScriptLogsJsonV600(migrations) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getMigrations), - "GET", - "/system/migrations", - "Get Database Migrations", - """Get all database migration script logs. Returns information about all migration scripts that have been executed or attempted.""", - EmptyBody, - migrationScriptLogsJsonV600, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetMigrations)), - http4sPartialFunction = Some(getMigrations) - ) - - // Route: GET /obp/v7.0.0/system/cache/namespaces - val getCacheNamespaces: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "system" / "cache" / "namespaces" => - EndpointHelpers.withUser(req) { (_, cc) => - Future { - val namespaces = List( - (Constant.CALL_COUNTER_PREFIX, "Rate limiting counters per consumer and time period", "varies", "Rate Limiting"), - (Constant.RATE_LIMIT_ACTIVE_PREFIX, "Active rate limit configurations", Constant.RATE_LIMIT_ACTIVE_CACHE_TTL.toString, "Rate Limiting"), - (Constant.LOCALISED_RESOURCE_DOC_PREFIX, "Localized resource documentation", Constant.CREATE_LOCALISED_RESOURCE_DOC_JSON_TTL.toString, "Resource Documentation"), - (Constant.DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, "Dynamic resource documentation", Constant.GET_DYNAMIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), - (Constant.STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, "Static resource documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), - (Constant.ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, "All resource documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), - (Constant.STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, "Swagger documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), - (Constant.CONNECTOR_PREFIX, "Connector method names and metadata", "3600", "Connector"), - (Constant.METRICS_STABLE_PREFIX, "Stable metrics (historical)", "86400", "Metrics"), - (Constant.METRICS_RECENT_PREFIX, "Recent metrics", "7", "Metrics"), - (Constant.ABAC_RULE_PREFIX, "ABAC rule cache", "indefinite", "ABAC") - ).map { case (prefix, description, ttl, category) => - JSONFactory600.createCacheNamespaceJsonV600( - prefix, description, ttl, category, - Redis.countKeys(s"${prefix}*"), - Redis.getSampleKey(s"${prefix}*") - ) - } - JSONFactory600.createCacheNamespacesJsonV600(namespaces) - } - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCacheNamespaces), - "GET", - "/system/cache/namespaces", - "Get Cache Namespaces", - """Returns information about all cache namespaces in the system including key counts, TTL and example keys.""", - EmptyBody, - CacheNamespacesJsonV600(List( - CacheNamespaceJsonV600( - prefix = "obp_dev_call_counter_1_", - description = "Rate limiting counters per consumer and time period", - ttl_seconds = "varies", - category = "Rate Limiting", - key_count = 42, - example_key = "obp_dev_call_counter_1_consumer123_PER_MINUTE" - ) - )), - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), - apiTagCache :: apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetCacheNamespaces)), - http4sPartialFunction = Some(getCacheNamespaces) - ) - // ── End Phase 1 batch 3 ────────────────────────────────────────────────── // ── Test email (self) ───────────────────────────────────────────────────── diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala index 1c7b4f8058..653c4fd36a 100644 --- a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala @@ -60,13 +60,13 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given // OperationIds match `APIUtil.buildOperationId(v, partialFunctionName)` → // s"$fullyQualifiedVersion-$name". v7.0.0's fully qualified form is "OBPv7.0.0". private val rootOpId = "OBPv7.0.0-root" - // A second NATIVE v7 endpoint. getBanks was removed from v7 (it now cascades to v6), - // so it can no longer serve as a "native v7" allowlist target. /api/versions is public, - // native to v7, and needs no DB — the same properties that make /root suitable here. - private val versionsOpId = "OBPv7.0.0-getScannedApiVersions" + // A second NATIVE v7 endpoint. getScannedApiVersions was removed from v7 (it now + // cascades to v6), so it can no longer serve as a "native v7" allowlist target. + // getErrorMessages is public, native to v7, and needs no DB. + private val versionsOpId = "OBPv7.0.0-getErrorMessages" private val rootPath = "/obp/v7.0.0/root" - private val versionsPath = "/obp/v7.0.0/api/versions" + private val versionsPath = "/obp/v7.0.0/api/error-messages" private def get(path: String): Int = { val req = Request[IO](Method.GET, Uri.unsafeFromString(path), headers = Headers.empty, diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 476de674b6..785245f243 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -135,6 +135,16 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { nameSuffix = "" ).openOrThrowException("Expected customer to be created").customerId + private def createTestOrg( + orgId: String, + visibility: String = "public", + status: String = "active" + ): Unit = { + Organisations.organisation.vend.createOrganisation( + orgId, s"Test $orgId", None, None, status, visibility, resourceUser1.userId + ) + } + // ─── root ──────────────────────────────────────────────────────────────────── feature("Http4s700 root endpoint") { @@ -183,67 +193,6 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - // ─── banks ─────────────────────────────────────────────────────────────────── - - feature("Http4s700 banks endpoint") { - - scenario("Return banks list JSON", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks request") - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/banks") - - Then("Response is 200 OK with non-empty banks array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("banks") match { - case Some(JArray(banks)) => - banks should not be empty - case _ => - fail("Expected non-empty banks array") - } - case _ => - fail("Expected JSON object for banks endpoint") - } - } - - scenario("Bank entries contain required fields", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks request") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/banks") - - // /obp/v7.0.0/banks cascades to v6.0.0 via v700ToV600Bridge, so the response - // uses the v6 BanksJsonV600 shape: bank_id / bank_code (not v4's id / short_name). - Then("Each bank has bank_id, bank_code, full_name, logo, website") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("banks") match { - case Some(JArray(banks)) => - banks.headOption match { - case Some(JObject(bankFields)) => - val keys = bankFields.map(_.name) - keys should contain("bank_id") - keys should contain("bank_code") - keys should contain("full_name") - case _ => - fail("Expected bank to be a JSON object") - } - case _ => - fail("Expected banks array") - } - case _ => - fail("Expected JSON object") - } - } - - scenario("Unauthenticated access to banks returns 200 (public endpoint)", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks with no auth headers") - val (statusCode, _, _) = makeHttpRequest("/obp/v7.0.0/banks") - Then("Response is 200 — banks is public") - statusCode shouldBe 200 - } - } - // ─── cross-cutting middleware ───────────────────────────────────────────────── feature("Http4s700 response headers") { @@ -258,10 +207,10 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("X-Request-ID is echoed back as Correlation-Id", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks with X-Request-ID header") + Given("GET /obp/v7.0.0/root with X-Request-ID header") val requestId = java.util.UUID.randomUUID().toString val (statusCode, _, headers) = makeHttpRequest( - "/obp/v7.0.0/banks", + "/obp/v7.0.0/root", Map("X-Request-ID" -> requestId) ) @@ -290,8 +239,8 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("Error responses also include Correlation-Id header", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users/current without auth (will 401)") - val (statusCode, _, headers) = makeHttpRequest("/obp/v7.0.0/users/current") + Given("DELETE /obp/v7.0.0/entitlements/no-such-id without auth (will 401)") + val (statusCode, _, headers) = makeHttpRequestWithMethod("DELETE", "/obp/v7.0.0/entitlements/no-such-id") Then("401 error response still has Correlation-Id") statusCode shouldBe 401 @@ -311,22 +260,6 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { feature("Http4s700 routing priority") { - scenario("GET /banks returns banks list", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks without auth") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/banks") - - Then("Response is 200 with banks array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("banks") match { - case Some(JArray(_)) => succeed - case _ => fail("Expected banks array") - } - case _ => - fail("Expected JSON object") - } - } } // ─── unknown paths and wrong methods ───────────────────────────────────────── @@ -342,338 +275,14 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("POST to a GET-only endpoint returns non-200", Http4s700RoutesTag) { - Given("POST /obp/v7.0.0/banks — method not allowed") - val (statusCode, _, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/banks") + Given("POST /obp/v7.0.0/root — method not allowed (root is a native GET-only v7 endpoint)") + val (statusCode, _, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/root") Then("Response is not 200") statusCode should not be 200 } } - // ─── getCurrentUser ─────────────────────────────────────────────────────────── - - feature("Http4s700 getCurrentUser endpoint") { - - scenario("Reject unauthenticated access to /users/current", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users/current with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users/current") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return user info JSON when authenticated", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users/current with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users/current", headers) - - Then("Response is 200 with user_id, username, email fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val keys = fields.map(_.name) - keys should contain("user_id") - keys should contain("username") - keys should contain("email") - case _ => fail("Expected JSON object for getCurrentUser") - } - } - - scenario("Returned user_id matches the authenticated user", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users/current with DirectLogin header for resourceUser1") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users/current", headers) - - Then("Response contains user_id equal to resourceUser1.userId") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("user_id") match { - case Some(JString(uid)) => uid shouldBe resourceUser1.userId - case _ => fail("Expected user_id field as JSON string") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getBank ───────────────────────────────────────────────────────────────── - - feature("Http4s700 getBank endpoint") { - - scenario("Return bank info JSON without authentication", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID with no auth headers") - val bankId = testBankId1.value - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId") - - Then("Response is 200 with bank_id, full_name fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val fieldMap = toFieldMap(fields) - fieldMap.get("bank_id") match { - case Some(JString(id)) => id shouldBe bankId - case _ => fail(s"Expected bank_id field as JSON string, got: ${fields.map(_.name)}") - } - val keys = fields.map(_.name) - keys should contain("full_name") - case _ => fail("Expected JSON object for getBank") - } - } - - scenario("Return 404 when bank does not exist", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/non-existing-bank with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/banks/non-existing-bank-id") - - Then("Response is 404 with BankNotFound message") - statusCode shouldBe 404 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(BankNotFound) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Response includes implicit { OBP, bank_id } in bank_routings", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID") - val bankId = testBankId1.value - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId") - - Then("Response is 200 and bank_routings contains { OBP, bank_id }") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("bank_routings") match { - case Some(JArray(items)) => - val pairs = items.collect { - case JObject(routingFields) => - val rm = toFieldMap(routingFields) - (rm.get("scheme"), rm.get("address")) match { - case (Some(JString(s)), Some(JString(a))) => (s, a) - case _ => ("", "") - } - } - pairs should contain (("OBP", bankId)) - case _ => fail("Expected bank_routings array on getBank response") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getCoreAccountById ─────────────────────────────────────────────────────── - - feature("Http4s700 getCoreAccountById endpoint") { - - scenario("Reject unauthenticated access to core account", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/my/banks/BANK_ID/accounts/ACCOUNT_ID/account with no auth") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/my/banks/$bankId/accounts/$accountId/account") - - Then("Response is 401") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return core account JSON when authenticated and account owner", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/my/banks/BANK_ID/accounts/ACCOUNT_ID/account with DirectLogin header") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/my/banks/$bankId/accounts/$accountId/account", headers) - - Then("Response is 200 with account_id and balance fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val keys = fields.map(_.name) - keys should contain("account_id") - keys should contain("balance") - case _ => fail("Expected JSON object for getCoreAccountById") - } - } - - scenario("Response includes implicit { OBP, account_id } in account_routings", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/my/banks/BANK_ID/accounts/ACCOUNT_ID/account authenticated") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/my/banks/$bankId/accounts/$accountId/account", headers) - - Then("Response is 200 and account_routings contains { OBP, account_id }") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("account_routings") match { - case Some(JArray(items)) => - val pairs = items.collect { - case JObject(routingFields) => - val rm = toFieldMap(routingFields) - (rm.get("scheme"), rm.get("address")) match { - case (Some(JString(s)), Some(JString(a))) => (s, a) - case _ => ("", "") - } - } - pairs should contain (("OBP", accountId)) - case _ => fail("Expected account_routings array on getCoreAccountById response") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getPrivateAccountByIdFull ──────────────────────────────────────────────── - - feature("Http4s700 getPrivateAccountByIdFull endpoint") { - - scenario("Reject unauthenticated access to full account", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account with no auth") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val viewId = SYSTEM_OWNER_VIEW_ID - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/$viewId/account") - - Then("Response is 401") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return full account JSON when authenticated with view access", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/owner/account with DirectLogin header") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val viewId = SYSTEM_OWNER_VIEW_ID - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/$viewId/account", headers) - - Then("Response is 200 with id, views_available, and balance fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val keys = fields.map(_.name) - keys should contain("id") - keys should contain("views_available") - keys should contain("balance") - case _ => fail("Expected JSON object for getPrivateAccountByIdFull") - } - } - - scenario("Response includes implicit { OBP, account_id } in account_routings", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/owner/account authenticated") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val viewId = SYSTEM_OWNER_VIEW_ID - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/$viewId/account", headers) - - Then("Response is 200 and account_routings contains { OBP, account_id }") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("account_routings") match { - case Some(JArray(items)) => - val pairs = items.collect { - case JObject(routingFields) => - val rm = toFieldMap(routingFields) - (rm.get("scheme"), rm.get("address")) match { - case (Some(JString(s)), Some(JString(a))) => (s, a) - case _ => ("", "") - } - } - pairs should contain (("OBP", accountId)) - case _ => fail("Expected account_routings array on getPrivateAccountByIdFull response") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getExplicitCounterpartyById ───────────────────────────────────────────── - - feature("Http4s700 getExplicitCounterpartyById endpoint") { - - scenario("Reject unauthenticated access to counterparty", Http4s700RoutesTag) { - Given("GET .../counterparties/COUNTERPARTY_ID with no auth") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val viewId = SYSTEM_OWNER_VIEW_ID - val (statusCode, json, _) = makeHttpRequest( - s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/$viewId/counterparties/some-id" - ) - - Then("Response is 401") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return counterparty JSON when authenticated and counterparty exists", Http4s700RoutesTag) { - Given("A counterparty (with metadata) created on testAccountId0 in testBankId1") - val bankId = testBankId1.value - val accountId = testAccountId0.value - val viewId = SYSTEM_OWNER_VIEW_ID - val counterparty = createCounterparty(bankId, accountId, accountId, isBeneficiary = true, resourceUser1.userId) - val counterpartyId = counterparty.counterpartyId - // getMetadata requires a MappedCounterpartyMetadata row — createCounterparty does not create one - Counterparties.counterparties.vend.getOrCreateMetadata( - com.openbankproject.commons.model.BankId(bankId), - com.openbankproject.commons.model.AccountId(accountId), - counterpartyId, - counterparty.name - ).openOrThrowException("Expected counterparty metadata to be created") - - When("GET .../counterparties/COUNTERPARTY_ID with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest( - s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/$viewId/counterparties/$counterpartyId", - headers - ) - - Then("Response is 200 with counterparty_id field") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("counterparty_id") match { - case Some(JString(id)) => id shouldBe counterpartyId - case _ => fail("Expected counterparty_id field as JSON string") - } - case _ => fail("Expected JSON object for getExplicitCounterpartyById") - } - } - } - // ─── deleteEntitlement ──────────────────────────────────────────────────────── feature("Http4s700 deleteEntitlement endpoint") { @@ -965,258 +574,30 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - // ─── getFeatures ────────────────────────────────────────────────────────────── + // ─── getUserByUserId ────────────────────────────────────────────────────────── - feature("Http4s700 getFeatures endpoint") { + feature("Http4s700 getUserByUserId endpoint") { - scenario("Return features JSON without authentication", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/features with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/features") + scenario("Reject unauthenticated access to /users/user-id/USER_ID", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/users/user-id/USER_ID with no auth headers") + val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/users/user-id/${resourceUser1.userId}") - Then("Response is 200 with feature boolean fields") - statusCode shouldBe 200 + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 json match { case JObject(fields) => - val keys = fields.map(_.name) - keys should contain("allow_public_views") - keys should contain("allow_direct_login") - keys should contain("allow_oauth2_login") - case _ => fail("Expected JSON object for getFeatures") + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") } } - scenario("allow_direct_login reflects props value", Http4s700RoutesTag) { - Given("allow_direct_login prop set to true") - setPropsValues("allow_direct_login" -> "true") - - When("GET /obp/v7.0.0/features") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/features") - - Then("Response contains allow_direct_login = true") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("allow_direct_login") shouldBe Some(JBool(true)) - case _ => fail("Expected JSON object") - } - } - } - - // ─── getScannedApiVersions ──────────────────────────────────────────────────── - - feature("Http4s700 getScannedApiVersions endpoint") { - - scenario("Return scanned_api_versions array without authentication", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/api/versions with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/api/versions") - - Then("Response is 200 with scanned_api_versions array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("scanned_api_versions") match { - case Some(JArray(versions)) => - versions should not be empty - case _ => fail("Expected scanned_api_versions array") - } - case _ => fail("Expected JSON object for getScannedApiVersions") - } - } - - scenario("Version entries contain required fields", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/api/versions") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/api/versions") - - Then("Each version entry has url_prefix, api_standard, fully_qualified_version, is_active") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("scanned_api_versions") match { - case Some(JArray(versions)) => - versions.headOption match { - case Some(JObject(vFields)) => - val keys = vFields.map(_.name) - keys should contain("url_prefix") - keys should contain("api_standard") - keys should contain("fully_qualified_version") - keys should contain("is_active") - case _ => fail("Expected version entry to be a JSON object") - } - case _ => fail("Expected scanned_api_versions array") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getConnectors ──────────────────────────────────────────────────────────── - - feature("Http4s700 getConnectors endpoint") { - - scenario("Return connectors array without authentication", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/connectors with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/connectors") - - Then("Response is 200 with connectors array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("connectors") match { - case Some(JArray(connectors)) => - connectors should not be empty - case _ => fail("Expected connectors array") - } - case _ => fail("Expected JSON object for getConnectors") - } - } - - scenario("Connector entries contain required fields", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/connectors") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/connectors") - - Then("Each connector has connector_name and is_available_in_method_routing fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("connectors") match { - case Some(JArray(connectors)) => - connectors.headOption match { - case Some(JObject(cFields)) => - val keys = cFields.map(_.name) - keys should contain("connector_name") - keys should contain("is_available_in_method_routing") - case _ => fail("Expected connector entry to be a JSON object") - } - case _ => fail("Expected connectors array") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getProviders ───────────────────────────────────────────────────────────── - - feature("Http4s700 getProviders endpoint") { - - scenario("Reject unauthenticated access to /providers", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/providers with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/providers") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return providers array when authenticated", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/providers with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/providers", headers) - - Then("Response is 200 with providers array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("providers") match { - case Some(JArray(_)) => succeed - case _ => fail("Expected providers array") - } - case _ => fail("Expected JSON object for getProviders") - } - } - } - - // ─── getUsers ───────────────────────────────────────────────────────────────── - - feature("Http4s700 getUsers endpoint") { - - scenario("Reject unauthenticated access to /users", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetAnyUser role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetAnyUser.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return users list when authenticated with canGetAnyUser role", Http4s700RoutesTag) { - Given("canGetAnyUser role granted to resourceUser1") - addEntitlement("", resourceUser1.userId, canGetAnyUser.toString) - - When("GET /obp/v7.0.0/users with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users", headers) - - Then("Response is 200 with users array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("users") match { - case Some(JArray(users)) => - users should not be empty - case _ => fail("Expected users array") - } - case _ => fail("Expected JSON object for getUsers") - } - } - } - - // ─── getUserByUserId ────────────────────────────────────────────────────────── - - feature("Http4s700 getUserByUserId endpoint") { - - scenario("Reject unauthenticated access to /users/user-id/USER_ID", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users/user-id/USER_ID with no auth headers") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/users/user-id/${resourceUser1.userId}") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetAnyUser role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/users/user-id/USER_ID with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/users/user-id/${resourceUser1.userId}", headers) + scenario("Return 403 when authenticated but missing canGetAnyUser role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/users/user-id/USER_ID with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/users/user-id/${resourceUser1.userId}", headers) Then("Response is 403 with UserHasMissingRoles") statusCode shouldBe 403 @@ -1277,574 +658,6 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - // ─── getCustomersAtOneBank ──────────────────────────────────────────────────── - - feature("Http4s700 getCustomersAtOneBank endpoint") { - - scenario("Reject unauthenticated access to /banks/BANK_ID/customers", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/customers with no auth headers") - val bankId = testBankId1.value - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/customers") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetCustomersAtOneBank role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/customers without the required role") - val bankId = testBankId1.value - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/customers", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetCustomersAtOneBank.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return customers list when authenticated with canGetCustomersAtOneBank role", Http4s700RoutesTag) { - Given("canGetCustomersAtOneBank role and a customer at the bank") - val bankId = testBankId1.value - addEntitlement(bankId, resourceUser1.userId, canGetCustomersAtOneBank.toString) - createTestCustomer(bankId) - - When("GET /obp/v7.0.0/banks/BANK_ID/customers with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/customers", headers) - - Then("Response is 200 with customers array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("customers") match { - case Some(JArray(customers)) => - customers should not be empty - case _ => fail("Expected customers array") - } - case _ => fail("Expected JSON object for getCustomersAtOneBank") - } - } - } - - // ─── getCustomerByCustomerId ────────────────────────────────────────────────── - - feature("Http4s700 getCustomerByCustomerId endpoint") { - - scenario("Reject unauthenticated access to /banks/BANK_ID/customers/CUSTOMER_ID", Http4s700RoutesTag) { - Given("GET .../customers/some-id with no auth") - val bankId = testBankId1.value - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/customers/some-customer-id") - - Then("Response is 401") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return customer JSON when authenticated with canGetCustomersAtOneBank role", Http4s700RoutesTag) { - Given("A customer created at testBankId1 and canGetCustomersAtOneBank role granted") - val bankId = testBankId1.value - addEntitlement(bankId, resourceUser1.userId, canGetCustomersAtOneBank.toString) - val customerId = CustomerX.customerProvider.vend.addCustomer( - bankId = CommBankId(bankId), - number = APIUtil.generateUUID(), - legalName = "Jane Doe", - mobileNumber = "+49987654321", - email = "jane@example.com", - faceImage = CustomerFaceImage(new Date(), ""), - dateOfBirth = new Date(), - relationshipStatus = "Married", - dependents = 1, - dobOfDependents = Nil, - highestEducationAttained = "Master", - employmentStatus = "Employed", - kycStatus = true, - lastOkDate = new Date(), - creditRating = None, - creditLimit = None, - title = "Ms", - branchId = "", - nameSuffix = "" - ).openOrThrowException("Expected customer to be created").customerId - - When("GET /obp/v7.0.0/banks/BANK_ID/customers/CUSTOMER_ID with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/customers/$customerId", headers) - - Then("Response is 200 with customer_id field") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("customer_id") match { - case Some(JString(id)) => id shouldBe customerId - case _ => fail("Expected customer_id field as JSON string") - } - case _ => fail("Expected JSON object for getCustomerByCustomerId") - } - } - } - - // ─── getAccountsAtBank ─────────────────────────────────────────────────────── - - feature("Http4s700 getAccountsAtBank endpoint") { - - scenario("Reject unauthenticated access to /banks/BANK_ID/accounts", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/accounts with no auth headers") - val bankId = testBankId1.value - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/accounts") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return accounts list when authenticated with access to the bank", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/accounts with DirectLogin header") - val bankId = testBankId1.value - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/accounts", headers) - - Then("Response is 200 with accounts array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("accounts") match { - case Some(JArray(accounts)) => - accounts should not be empty - case _ => fail("Expected accounts array") - } - case _ => fail("Expected JSON object for getAccountsAtBank") - } - } - - scenario("Account entries contain required fields", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/accounts with DirectLogin header") - val bankId = testBankId1.value - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/accounts", headers) - - Then("Each account entry has account_id, bank_id, label, views_available") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("accounts") match { - case Some(JArray(accounts)) => - accounts.headOption match { - case Some(JObject(aFields)) => - val keys = aFields.map(_.name) - keys should contain("account_id") - keys should contain("bank_id") - keys should contain("label") - keys should contain("views_available") - case _ => fail("Expected account entry to be a JSON object") - } - case _ => fail("Expected accounts array") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 404 for non-existent bank", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/non-existing-bank/accounts with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/banks/non-existing-bank-xyz/accounts", headers) - - Then("Response is 404 with BankNotFound message") - statusCode shouldBe 404 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(BankNotFound) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - } - - // ─── getCacheConfig ────────────────────────────────────────────────────────── - - feature("Http4s700 getCacheConfig endpoint") { - - scenario("Reject unauthenticated access to /system/cache/config", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/cache/config with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/config") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetCacheConfig role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/cache/config with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/config", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetCacheConfig.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return cache config when authenticated with canGetCacheConfig role", Http4s700RoutesTag) { - Given("canGetCacheConfig role granted to resourceUser1") - addEntitlement("", resourceUser1.userId, canGetCacheConfig.toString) - - When("GET /obp/v7.0.0/system/cache/config with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/config", headers) - - Then("Response is 200 with redis_status, in_memory_status, instance_id fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val m = toFieldMap(fields) - m.keys should contain("redis_status") - m.keys should contain("in_memory_status") - m.keys should contain("instance_id") - case _ => fail("Expected JSON object for getCacheConfig") - } - } - } - - // ─── getCacheInfo ──────────────────────────────────────────────────────────── - - feature("Http4s700 getCacheInfo endpoint") { - - scenario("Reject unauthenticated access to /system/cache/info", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/cache/info with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/info") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetCacheInfo role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/cache/info with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/info", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetCacheInfo.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return cache info when authenticated with canGetCacheInfo role", Http4s700RoutesTag) { - Given("canGetCacheInfo role granted to resourceUser1") - addEntitlement("", resourceUser1.userId, canGetCacheInfo.toString) - - When("GET /obp/v7.0.0/system/cache/info with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/info", headers) - - Then("Response is 200 with namespaces, total_keys, redis_available fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val m = toFieldMap(fields) - m.keys should contain("namespaces") - m.keys should contain("total_keys") - m.keys should contain("redis_available") - case _ => fail("Expected JSON object for getCacheInfo") - } - } - } - - // ─── getDatabasePoolInfo ───────────────────────────────────────────────────── - - feature("Http4s700 getDatabasePoolInfo endpoint") { - - scenario("Reject unauthenticated access to /system/database/pool", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/database/pool with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/database/pool") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetDatabasePoolInfo role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/database/pool with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/database/pool", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetDatabasePoolInfo.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return pool info when authenticated with canGetDatabasePoolInfo role", Http4s700RoutesTag) { - Given("canGetDatabasePoolInfo role granted to resourceUser1") - addEntitlement("", resourceUser1.userId, canGetDatabasePoolInfo.toString) - - When("GET /obp/v7.0.0/system/database/pool with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/database/pool", headers) - - Then("Response is 200 with pool_name, active_connections, maximum_pool_size fields") - statusCode shouldBe 200 - json match { - case JObject(fields) => - val m = toFieldMap(fields) - m.keys should contain("pool_name") - m.keys should contain("active_connections") - m.keys should contain("maximum_pool_size") - case _ => fail("Expected JSON object for getDatabasePoolInfo") - } - } - } - - // ─── getStoredProcedureConnectorHealth ─────────────────────────────────────── - - feature("Http4s700 getStoredProcedureConnectorHealth endpoint") { - - scenario("Reject unauthenticated access to stored_procedure_vDec2019/health", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetConnectorHealth role", Http4s700RoutesTag) { - Given("GET stored_procedure_vDec2019/health with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetConnectorHealth.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - // Note: no 200 scenario — StoredProcedureUtils init block requires stored_procedure_connector.* - // props that are not set in the test environment. The route is correctly wired (auth passes), - // but the Future would fail when StoredProcedureUtils is first accessed, returning 500. - } - - // ─── getMigrations ─────────────────────────────────────────────────────────── - - feature("Http4s700 getMigrations endpoint") { - - scenario("Reject unauthenticated access to /system/migrations", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/migrations with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/migrations") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetMigrations role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/migrations with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/migrations", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetMigrations.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return migrations list when authenticated with canGetMigrations role", Http4s700RoutesTag) { - Given("canGetMigrations role granted to resourceUser1") - addEntitlement("", resourceUser1.userId, canGetMigrations.toString) - - When("GET /obp/v7.0.0/system/migrations with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/migrations", headers) - - Then("Response is 200 with migration_script_logs field") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).keys should contain("migration_script_logs") - case _ => fail("Expected JSON object for getMigrations") - } - } - } - - // ─── getCacheNamespaces ────────────────────────────────────────────────────── - - feature("Http4s700 getCacheNamespaces endpoint") { - - scenario("Reject unauthenticated access to /system/cache/namespaces", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/cache/namespaces with no auth headers") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/namespaces") - - Then("Response is 401 with AuthenticatedUserIsRequired message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return 403 when authenticated but missing canGetCacheNamespaces role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/system/cache/namespaces with DirectLogin header but no role") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/namespaces", headers) - - Then("Response is 403 with UserHasMissingRoles") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(msg)) => - msg should include(UserHasMissingRoles) - msg should include(canGetCacheNamespaces.toString) - case _ => fail("Expected message field") - } - case _ => fail("Expected JSON object") - } - } - - scenario("Return cache namespaces when authenticated with canGetCacheNamespaces role", Http4s700RoutesTag) { - Given("canGetCacheNamespaces role granted to resourceUser1") - addEntitlement("", resourceUser1.userId, canGetCacheNamespaces.toString) - - When("GET /obp/v7.0.0/system/cache/namespaces with DirectLogin header") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/namespaces", headers) - - Then("Response is 200 with namespaces array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("namespaces") match { - case Some(JArray(_)) => succeed - case _ => fail("Expected namespaces array") - } - case _ => fail("Expected JSON object for getCacheNamespaces") - } - } - } - - // ─── Organisations ──────────────────────────────────────────────────────── - - /** Create an Organisation directly via the model layer for test setup. */ - private def createTestOrg( - orgId: String, - visibility: String = "public", - status: String = "active" - ): Unit = { - Organisations.organisation.vend.createOrganisation( - orgId, s"Test $orgId", None, None, status, visibility, resourceUser1.userId - ) - } - feature("Http4s700 createOrganisation endpoint") { scenario("Reject unauthenticated POST to /organisations", Http4s700RoutesTag) { diff --git a/scripts/remove_duplicate_v7_endpoints.py b/scripts/remove_duplicate_v7_endpoints.py new file mode 100644 index 0000000000..8d7dcca99b --- /dev/null +++ b/scripts/remove_duplicate_v7_endpoints.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Remove the 19 duplicate v7.0.0 endpoints from Http4s700.scala so they cascade to +their v6/v4 twin. See ~/.claude/plans/plan-cozy-yao.md and +todo_remove_duplicate_v7.0.0_endpoints.md (Appendix B). + +Each endpoint is a `val NAME: HttpRoutes[IO] = ...` immediately followed by its +`resourceDocs += ResourceDoc(... http4sPartialFunction = Some(NAME))` block. This +deletes both (plus any immediately-preceding `//` comment lines and one trailing +blank line), asserts no block overlaps, and prints what it removed. Idempotent. +""" +import re + +f = "obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala" +lines = open(f).read().split("\n") + +# All 19 endpoints (16 clean + 3 divergent: getCurrentUser, getUsers, +# getExplicitCounterpartyById). The 3 KEPT (deleteEntitlement/addEntitlement/ +# getUserByUserId — improved response codes) are deliberately NOT here. +targets = [ + "getBank", "getCoreAccountById", "getPrivateAccountByIdFull", "getAccountsAtBank", + "getStoredProcedureConnectorHealth", "getMigrations", "getCacheNamespaces", + "getCacheConfig", "getCacheInfo", "getDatabasePoolInfo", "getFeatures", + "getConnectors", "getProviders", "getScannedApiVersions", "getCustomersAtOneBank", + "getCustomerByCustomerId", "getCurrentUser", "getUsers", "getExplicitCounterpartyById", +] + +val_re = {t: re.compile(rf"^\s*(lazy )?val {t}: HttpRoutes\[IO\]") for t in targets} +some_re = {t: re.compile(rf"http4sPartialFunction = Some\({t}\)\s*$") for t in targets} + +blocks = [] +for t in targets: + vi = next(i for i, l in enumerate(lines) if val_re[t].search(l)) + si = next(i for i, l in enumerate(lines) if some_re[t].search(l)) + assert si > vi, t + ci = next(i for i in range(si + 1, len(lines)) if re.match(r"^\s*\)\s*$", lines[i])) + start = vi + while start - 1 >= 0 and re.match(r"^\s*//", lines[start - 1]): + start -= 1 + blocks.append((start, ci, t)) + +blocks.sort() +for (a, b, t), (c, d, t2) in zip(blocks, blocks[1:]): + assert b < c, f"OVERLAP {t} vs {t2}" + +to_del = set() +for a, b, t in blocks: + end = b + if end + 1 < len(lines) and lines[end + 1].strip() == "": + end += 1 + to_del.update(range(a, end + 1)) + +open(f, "w").write("\n".join(l for i, l in enumerate(lines) if i not in to_del)) +print(f"removed {len(to_del)} lines across {len(targets)} endpoints; resourceDoc count should drop 64 -> 45") diff --git a/todo_remove_duplicate_v7.0.0_endpoints.md b/todo_remove_duplicate_v7.0.0_endpoints.md deleted file mode 100644 index 9ac5187030..0000000000 --- a/todo_remove_duplicate_v7.0.0_endpoints.md +++ /dev/null @@ -1,243 +0,0 @@ -# TODO: Remove duplicate v7.0.0 endpoints (handoff) - -**Status:** `getBanks` removal committed (`bd9f8ca84`) + CI follow-up fixes (see Appendix C). -The 19-endpoint removal was prototyped then **reverted** — not yet done. Redo it via Appendix A/B. -**Date:** 2026-06-02 -**Context:** Several endpoints were added to `Http4s700.scala` purely as http4s migration scaffolding -(the file literally labels them `// ── POC endpoints — one per EndpointHelper category ──`). -Where a v7 endpoint is *behaviourally identical* to an earlier version, it should not live in v7 — -it should cascade. Where v7 *intentionally changed/improved* behaviour, it must stay. - ---- - -## The rule - -> Remove a v7.0.0 endpoint **only if it is behaviourally identical** to an earlier version. -> If v7 changed/improved anything (response codes, shape, behaviour), **keep it in v7**. - -## How the cascade works (why deletion is safe for identical endpoints) - -`Http4s700.scala` defines `v700ToV600Bridge` (search the file for it). Any unmatched -`/obp/v7.0.0/*` request is rewritten to `/obp/v6.0.0/*` and served by `Http4s600`, tagged -with response header `X-OBP-Version-Served: v6.0.0`. The bridge chain is fully continuous: - -``` -v700 → v600 → v510 → v500 → v400 → v310 → v300 → v220 → v210 → v200 → v140 → v130 -``` - -So deleting a v7 endpoint cascades the request down to wherever that endpoint is actually -defined (e.g. `getExplicitCounterpartyById` has no v5/v6 successor and cascades to its v4 home, -which is the newest shape that exists). Routing is by `resourceDocs` (URL+verb) — removing the -`val` + its `resourceDocs += ResourceDoc(...)` block removes it from both routing and the -`v7ResourceDocIndex` the bridge consults, so the cascade engages automatically. - ---- - -## Work done so far - -### 1. `getBanks` — committed (`bd9f8ca84`) -This one was a **regression**, not an improvement: v7 served the older v4 shape -(`BanksJson400`: `id`, `short_name`) while v6 has the newer `BanksJsonV600` (`bank_id`, -`bank_code`). Deleted from v7 so it cascades to v6's correct shape. Test -`Http4s700RoutesTest` updated to assert `bank_id`/`bank_code`. **Already committed.** - -### 2. 19 identical endpoints — removed (UNCOMMITTED working-tree change to `Http4s700.scala`) -Each uses the same JSON factory as its lower-version twin and all their v7 test scenarios -still pass when served via the cascade: - -``` -getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, -getExplicitCounterpartyById, getFeatures, getConnectors, getProviders, getUsers, -getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, -getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, -getCacheNamespaces, getScannedApiVersions -``` - -`Http4s700.scala`: 4061 → 3370 lines; resourceDoc count 64 → 45. -Main + test compile clean; `Http4s700RoutesTest` → **141/141 pass**. - -### 3. 3 endpoints KEPT in v7 (NOT identical — improved response codes) -These were caught because deleting them turned their test scenarios red: - -| Endpoint | v7 behaviour (kept) | cascaded v6 would give | -|---|---|---| -| `deleteEntitlement` | **204** No Content | 200 (yields `""`) | -| `addEntitlement` (duplicate role) | **409** Conflict | 400 | -| `getUserByUserId` (missing user) | **404** Not Found | 400 (`unboxFullOrFail` default code) | - -v6 codes follow the older OBP convention (200-for-DELETE, 400-for-missing). v7's 204/409/404 -are the more RESTful, intentional choices — so they stay. - ---- - -## How "identical" was checked (and the GAP — please read) - -Two layers were used: - -1. **Static shape audit** — compared each v7 handler's `yield` factory/case-class against the - lower-version handler's. This catches *shape* regressions (it's how `getBanks` was found) but - only compares the **output type**, not the full handler. It did **not** diff auth helpers, - declared roles, query-param parsing, error lists, or *which* data is passed into the factory. - -2. **Bridge-routed test run (the decisive check)** — `Http4s700RoutesTest` drives - `Http4s700.wrappedRoutesV700Services`, which *includes* `v700ToV600Bridge`. So deleted - endpoints' test requests are actually served by the lower version. Passing = the cascaded - response satisfied that test's assertions. This is exactly how the 3 non-identical endpoints - were found. - -**⚠️ The gap:** "identical" here means *equivalent up to what the v7 tests assert*. Per-endpoint -test depth was **not** audited. If one of the 19 has an untested query-param filter, error path, -or auth edge case that the lower version handles differently, the cascade would diverge silently -and neither check would have caught it. - -### Recommended next step (to make it rigorous) -Before/instead of trusting the test coverage, do a runtime equivalence diff per endpoint: - -1. On the pre-deletion build, capture each of the 19 endpoints' responses across an input matrix: - valid request, missing resource, unauthenticated, missing-role, bad query params, malformed body. -2. On the post-deletion (cascade) build, capture the same matrix. -3. Diff status code + body + relevant headers (ignore `X-OBP-Version-Served`). Any non-empty diff = - not identical → that endpoint should be restored to v7 (like the 3 above). - -The git diff `bd9f8ca84^..bd9f8ca84` (getBanks) and the current uncommitted change show the exact -block-removal pattern: delete the `val NAME: HttpRoutes[IO] = ...` plus its -`resourceDocs += ResourceDoc(... http4sPartialFunction = Some(NAME))` block. - ---- - -## Files -- `obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala` — endpoint defs + `v700ToV600Bridge` -- `obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala` — cascade target for most -- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala` — the suite (in-process, hits the bridge) -- Lower-version files (`Http4s500/400/...`) — cascade targets for endpoints with no v6 twin - -## Decisions for the next developer -- Confirm the 19 with the runtime-diff matrix above (or accept test-coverage-bounded equivalence). -- Decide whether `deleteEntitlement`/`addEntitlement`/`getUserByUserId` should instead have their - improved codes **ported down into v6** (v6 is not yet STABLE) and then be removed from v7 too — - that would make v6 canonical with the better semantics. Current choice: keep them in v7. - -> **NOTE:** The working-tree removal of the 19 endpoints was **reverted** — `Http4s700.scala` is -> back at HEAD (`getBanks` cascade still committed in `bd9f8ca84`; the other 19 are present again). -> Use the evidence table + script below to redo the removal from scratch. - ---- - -## Appendix A — Per-endpoint evidence (the 19 identical removals) - -All 19 build the **same JSON factory** as their lower-version twin (so same response shape). -Line numbers are in `Http4s600.scala` unless noted. `getExplicitCounterpartyById` has no -v5/v6 successor — v4 is the newest shape — so it cascades two extra hops to its v4 home. - -| v7 endpoint | verb + URL | factory (v7 == twin) | cascades to | -|---|---|---|---| -| getBank | GET /banks/BANK_ID | createBankJsonV600 | v6 `getBank` | -| getCurrentUser | GET /users/current | createUserInfoJSON | v6 `getCurrentUser` | -| getCoreAccountById | GET /my/banks/BANK_ID/accounts/ACCOUNT_ID/account | createModeratedCoreAccountJsonV600 | v6 `getCoreAccountByIdV600` | -| getPrivateAccountByIdFull | GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account | createBankAccountJSON600 | v6 `getPrivateAccountByIdFull` | -| getExplicitCounterpartyById | GET .../counterparties/COUNTERPARTY_ID | createCounterpartyWithMetadataJson400 | **v4** `Http4s400` (newest shape) | -| getFeatures | GET /features | FeaturesJsonV600 | v6 `getFeatures` | -| getConnectors | GET /system/connectors | createConnectorsJson | v6 `getConnectors` | -| getProviders | GET /providers | createProvidersJson | v6 `getProviders` | -| getUsers | GET /users | createUsersInfoJsonV600 | v6 `getUsers` | -| getCustomersAtOneBank | GET /banks/BANK_ID/customers | createCustomersJson | v6 `getCustomersAtOneBank` | -| getCustomerByCustomerId | GET /banks/BANK_ID/customers/CUSTOMER_ID | createCustomerWithAttributesJson | v6 `getCustomerByCustomerId` | -| getAccountsAtBank | GET /banks/BANK_ID/accounts | BasicAccountsJsonV600 | v6 `getAccountsAtBank` | -| getCacheConfig | GET /system/cache/config | createCacheConfigJsonV600 | v6 `getCacheConfig` | -| getCacheInfo | GET /system/cache/info | createCacheInfoJsonV600 | v6 `getCacheInfo` | -| getDatabasePoolInfo | GET /system/database/pool | createDatabasePoolInfoJsonV600 | v6 `getDatabasePoolInfo` | -| getStoredProcedureConnectorHealth | GET /system/connectors/stored_procedure_vDec2019/health | StoredProcedureConnectorHealthJsonV600 | v6 `getStoredProcedureConnectorHealth` | -| getMigrations | GET /system/migrations | createMigrationScriptLogsJsonV600 | v6 `getMigrations` | -| getCacheNamespaces | GET /system/cache/namespaces | createCacheNamespacesJsonV600 | v6 `getCacheNamespaces` | -| getScannedApiVersions | GET /api/versions | ScannedApiVersionJsonV600 | v6 `getScannedApiVersions` | - -**Do NOT remove these 3** (improved codes — see table in the main body): -`deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404). - -## Appendix B — Reproducible removal script - -Each endpoint is a `val NAME: HttpRoutes[IO] = ...` immediately followed by its -`resourceDocs += ResourceDoc(... http4sPartialFunction = Some(NAME))` block. The script below -deletes both (plus any immediately-preceding `//` comment lines and one trailing blank line), -asserts no block overlaps, and prints what it removed. It is idempotent on the names listed. - -```python -import re -f = "obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala" -lines = open(f).read().split("\n") - -# 19 IDENTICAL endpoints only — the 3 behaviourally-improved ones are deliberately NOT here -targets = ["getBank","getCurrentUser","getCoreAccountById","getPrivateAccountByIdFull", -"getExplicitCounterpartyById","getFeatures","getConnectors","getProviders","getUsers", -"getCustomersAtOneBank","getCustomerByCustomerId","getAccountsAtBank","getCacheConfig", -"getCacheInfo","getDatabasePoolInfo","getStoredProcedureConnectorHealth","getMigrations", -"getCacheNamespaces","getScannedApiVersions"] - -val_re = {t: re.compile(rf"^\s*(lazy )?val {t}: HttpRoutes\[IO\]") for t in targets} -some_re = {t: re.compile(rf"http4sPartialFunction = Some\({t}\)\s*$") for t in targets} - -blocks = [] -for t in targets: - vi = next(i for i,l in enumerate(lines) if val_re[t].search(l)) - si = next(i for i,l in enumerate(lines) if some_re[t].search(l)) - assert si > vi, t - ci = next(i for i in range(si+1, len(lines)) if re.match(r"^\s*\)\s*$", lines[i])) - start = vi - while start-1 >= 0 and re.match(r"^\s*//", lines[start-1]): - start -= 1 - blocks.append((start, ci, t)) - -blocks.sort() -for (a,b,t),(c,d,t2) in zip(blocks, blocks[1:]): - assert b < c, f"OVERLAP {t} vs {t2}" - -to_del = set() -for a,b,t in blocks: - end = b - if end+1 < len(lines) and lines[end+1].strip()=="": - end += 1 - to_del.update(range(a, end+1)) - -open(f,"w").write("\n".join(l for i,l in enumerate(lines) if i not in to_del)) -print(f"removed {len(to_del)} lines; resourceDoc count should drop 64 -> 45") -``` - -After running: `mvn test-compile -pl obp-api -am -q` then -`mvn test -pl obp-api -q -DwildcardSuites="code.api.v7_0_0.Http4s700RoutesTest" -DfailIfNoTests=false` -— expect **141/141 pass**. If any scenario goes red, that endpoint is NOT identical → restore it -to v7 (that is precisely how the 3 kept endpoints were identified). - -**⚠️ `Http4s700RoutesTest` alone is NOT enough** — see Appendix C. Other suites reference v7 URLs as -fixtures and will break when you remove an endpoint they assumed was native. Run, at minimum: -`code.api.v7_0_0.Http4s700RoutesTest`, `code.api.v7_0_0.V7ResourceDocsAggregationTest`, -`code.api.v7_0_0.Http4s700TransactionTest`, `code.api.http4sbridge.Http4sServerIntegrationTest`, -`code.api.http4sbridge.Http4sLiftBridgePropertyTest`, -`code.api.util.http4s.ResourceDocMiddlewareEnableDisablePropsTest`. - ---- - -## Appendix C — Testing anti-pattern this exposed (READ BEFORE removing more) - -It is unusual for OBP to test an endpoint at a version that does **not** define it. The v7 suite does, -because the duplicated "POC" endpoints were tested at their **v7 URLs** to exercise the http4s -middleware — so tests assert `/obp/v7.0.0/banks` *behaviour* even though banks' canonical home is v6. -The instant an endpoint cascades, those tests are exercising the wrong layer. That is exactly what -broke CI after the `getBanks` removal (`bd9f8ca84`): the commit fixed `Http4s700RoutesTest` but missed -two other suites that hard-coded `/obp/v7.0.0/banks` as a *native* v7 endpoint. - -**The two CI follow-up fixes (already done, in the working tree):** - -| Suite (shard) | What it wrongly assumed | Fix | -|---|---|---| -| `Http4sServerIntegrationTest` (4) | a native v7 endpoint sets **no** `X-OBP-Version-Served`; used `/v7.0.0/banks` — which now cascades and sets `v6.0.0` | repointed the native-endpoint probe to `/obp/v7.0.0/root` | -| `ResourceDocMiddlewareEnableDisablePropsTest` (3) | allowlist target `OBPv7.0.0-getBanks` exists | repointed to `getScannedApiVersions` / `/obp/v7.0.0/api/versions` | - -**Rule of thumb when removing each of the 19 — classify every failing/affected v7 test:** - -| Test kind | Example | Action | -|---|---|---| -| Asserts an **endpoint's behaviour** at a non-defining version | `Http4s700RoutesTest` "banks list"; `V7ResourceDocsAggregationTest` banks scenario | **Delete** the v7 scenario — coverage belongs at the endpoint's home-version suite (e.g. v6's `getBanks` test). Do **not** repoint it to assert v6 shape via the cascade (that just re-creates the anti-pattern). | -| Asserts **infrastructure** (routing, cascade header, enable/disable middleware, CORS) and merely needs *a* native endpoint as a fixture | the two fixed above; `Http4sServerIntegrationTest` CORS/native probes | **Keep**, but pin the fixture to an endpoint genuinely native to v7 (`/root`, `/api/versions`). Never use a cascaded URL as the "native" fixture. | - -Net: removing the 19 should generally **shrink** the v7 test surface (delete behavioural scenarios), -not migrate it. Only the handful of genuine infra tests stay, repointed to native v7 fixtures.