Skip to content

Commit 59351d2

Browse files
authored
Merge pull request #2930 from simonredfern/develop
Dynamic Entity joins and updates
2 parents 0ff6d1f + 4237e74 commit 59351d2

43 files changed

Lines changed: 3817 additions & 212 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
.settings
99
.metals
1010
.vscode
11-
.claude/settings.local.json
11+
.claude/
1212
*.code-workspace
1313
.zed
1414
.cursor

‎obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ TESOBE (http://www.tesobe.com/)
2727

2828
package code.api.ResourceDocs1_4_0
2929

30-
import code.api.Constant.{GET_DYNAMIC_RESOURCE_DOCS_TTL, GET_STATIC_RESOURCE_DOCS_TTL, HostName, PARAM_LOCALE}
30+
import code.api.Constant.{DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, GET_DYNAMIC_RESOURCE_DOCS_TTL, GET_STATIC_RESOURCE_DOCS_TTL, HostName, PARAM_LOCALE}
3131
import code.api.OBPRestHelper
3232
import code.api.cache.Caching
3333
import code.api.util.APIUtil._
@@ -297,6 +297,54 @@ trait ResourceDocsAPIMethods extends MdcLoggable {
297297
List(apiTagDocumentation, apiTagApi)
298298
)
299299

300+
localResourceDocs += ResourceDoc(
301+
implementedInApiVersion,
302+
"getBankLevelDynamicResourceDocsOpenAPI31",
303+
"GET",
304+
"/banks/BANK_ID/resource-docs/API_VERSION/openapi",
305+
"Get Bank Level Dynamic OpenAPI 3.1 documentation",
306+
s"""Returns OpenAPI 3.1 documentation for the dynamic resources of one bank: its Dynamic Entities,
307+
|Dynamic Endpoints and Dynamic Resource Docs.
308+
|
309+
|BANK_ID is the bank whose dynamic resources you want. Use ${DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID} for the
310+
|system level dynamic resources, the ones that belong to no bank.
311+
|
312+
|API_VERSION is the version you want documentation about e.g. v7.0.0
313+
|
314+
|Static endpoints belong to no bank, so this document only ever contains dynamic resources. To
315+
|document every bank's dynamic resources at once, use /resource-docs/API_VERSION/openapi?content=dynamic
316+
|
317+
|## Query Parameters
318+
|
319+
|**tags** - Filter by endpoint tags (comma-separated list). Empty values will return error OBP-10053
320+
|
321+
|**functions** - Filter by function names (comma-separated list). Empty values will return error OBP-10054
322+
|
323+
|**locale** - Language for localized documentation, e.g. ?locale=en_GB. Invalid locales will return error OBP-10041
324+
|
325+
|For YAML format, use the corresponding endpoint: /banks/BANK_ID/resource-docs/API_VERSION/openapi.yaml
326+
|
327+
|Note: Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds
328+
|
329+
|## Examples
330+
|
331+
|${getObpApiRoot}/v7.0.0/banks/${DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID}/resource-docs/v7.0.0/openapi
332+
|${getObpApiRoot}/v7.0.0/banks/BANK_ID/resource-docs/v7.0.0/openapi.yaml
333+
|
334+
""",
335+
EmptyBody,
336+
EmptyBody,
337+
BankNotFound ::
338+
InvalidApiVersionString ::
339+
ApiVersionNotSupported ::
340+
InvalidLocale ::
341+
InvalidTagsParameter ::
342+
InvalidFunctionsParameter ::
343+
UnknownError :: Nil,
344+
List(apiTagDocumentation, apiTagApi),
345+
Some(List(canReadDynamicResourceDocsAtOneBank))
346+
)
347+
300348
implicit val formats = CustomJsonFormats.rolesMappedToClassesFormats
301349

302350
// avoid repeat execute method getSpecialInstructions, here save the calculate results.
@@ -507,7 +555,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable {
507555
// (don't keep a stale value left over from a different aggregated request that may have
508556
// overwritten this var on the shared ResourceDoc).
509557
val dynamicDocs = allDynamicResourceDocs
510-
.filter(rd => if (bankId.isDefined) rd.createdByBankId == bankId else true)
558+
.filter(rd => bankId.forall(space => APIUtil.dynamicResourceDocBelongsToSpace(rd, space)))
511559
.map { it =>
512560
it.specifiedUrl = if (it.partialFunctionName.startsWith("dynamicEntity")) Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-entity`}${it.requestUrl}") else Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-endpoint`}${it.requestUrl}")
513561
it
@@ -1147,36 +1195,36 @@ trait ResourceDocsAPIMethods extends MdcLoggable {
11471195

11481196

11491197

1150-
def convertResourceDocsToOpenAPI31YAMLAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : String = {
1198+
def convertResourceDocsToOpenAPI31YAMLAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson], setCache: (String, String) => Unit = Caching.setStaticSwaggerDocCache) : String = {
11511199
logger.debug(s"Generating OpenAPI 3.1 YAML-convertResourceDocsToOpenAPI31YAMLAndSetCache requestedApiVersion is $requestedApiVersionString")
11521200
val hostname = HostName
11531201
val openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJson, requestedApiVersionString, hostname)
11541202
val openApiJValue = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.OpenAPI31JsonFormats.toJValue(openApiDoc)
11551203

11561204
val yamlString = YAMLUtils.jValueToYAMLSafe(openApiJValue, "# Error converting to YAML")
1157-
Caching.setStaticSwaggerDocCache(cacheKey, yamlString)
1205+
setCache(cacheKey, yamlString)
11581206

11591207
yamlString
11601208
}
11611209

1162-
def convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = {
1210+
def convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson], setCache: (String, String) => Unit = Caching.setStaticSwaggerDocCache) : JValue = {
11631211
logger.debug(s"Generating OpenAPI 3.1-convertResourceDocsToOpenAPI31JvalueAndSetCache requestedApiVersion is $requestedApiVersionString")
11641212
val hostname = HostName
11651213
val openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJson, requestedApiVersionString, hostname)
11661214
val openApiJValue = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.OpenAPI31JsonFormats.toJValue(openApiDoc)
11671215

11681216
val jsonString = json.compactRender(openApiJValue)
1169-
Caching.setStaticSwaggerDocCache(cacheKey, jsonString)
1217+
setCache(cacheKey, jsonString)
11701218

11711219
openApiJValue
11721220
}
11731221

1174-
def convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = {
1222+
def convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson], setCache: (String, String) => Unit = Caching.setStaticSwaggerDocCache) : JValue = {
11751223
logger.debug(s"Generating Swagger-getResourceDocsSwaggerAndSetCache requestedApiVersion is $requestedApiVersionString")
11761224
val swaggerDocJsonJValue = getResourceDocsSwagger(requestedApiVersionString, resourceDocsJson).head
11771225

11781226
val jsonString = json.compactRender(swaggerDocJsonJValue)
1179-
Caching.setStaticSwaggerDocCache(cacheKey, jsonString)
1227+
setCache(cacheKey, jsonString)
11801228

11811229
swaggerDocJsonJValue
11821230
}

‎obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,17 @@ object Http4sDynamicEndpoint extends MdcLoggable {
127127
case Some(jv) => Full(jv)
128128
case None => Empty
129129
}
130+
// Every outcome records an API metric, like any other endpoint: the handler's response
131+
// under the authenticated call context (so the row names the User), and a failure to
132+
// authenticate or authorise under the context the request arrived with.
130133
val io: IO[Response[IO]] = for {
131134
authedCcOpt <- IO.fromFuture(IO(doc.authCheckIO(partPath, bodyJValue, cc)))
132135
authedCc = authedCcOpt.getOrElse(cc)
133136
resp <- doc.dynamicHttp4sFunction.get.apply(req)(authedCc)
134-
} yield resp
135-
io.handleErrorWith(err => ErrorResponseConverter.toHttp4sResponse(err, cc))
137+
.handleErrorWith(err => ErrorResponseConverter.toHttp4sResponse(err, authedCc))
138+
recorded <- EndpointHelpers.recordMetricFor(resp)(authedCc)
139+
} yield recorded
140+
io.handleErrorWith(err => ErrorResponseConverter.toHttp4sResponse(err, cc).flatMap(EndpointHelpers.recordMetricFor(_)(cc)))
136141
}
137142
}
138143
}

‎obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala‎

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import code.api.dynamic.endpoint.helper.practise.{DynamicEndpointCodeGenerator,
3333
import code.api.dynamic.endpoint.helper.practise.PractiseEndpointGroup
3434
import code.api.util.DynamicUtil.{DynamicCodeBody, Validation}
3535
import code.api.util.APIUtil.{BooleanBody, DoubleBody, EmptyBody, LongBody, Http4sEndpointIO, PrimaryDataBody, ResourceDoc, StringBody, getDisabledEndpointOperationIds}
36-
import code.api.util.{APIUtil, CallContext, DynamicUtil}
36+
import code.api.util.{APIUtil, CallContext, DynamicUtil, ErrorMessages}
37+
import code.api.JsonResponseException
3738
import net.liftweb.common.{Box, Failure, Full}
3839
import org.json4s.{JNothing, JValue}
3940
import org.json4s.JsonAST.{JBool, JDouble, JInt, JString}
@@ -111,10 +112,23 @@ object CompiledObjects {
111112
* validate and the dry-run compile all check against it, and CompiledObjects chooses its compiler
112113
* from the same normalised value, so nothing that passes the check can reach the wrong compiler.
113114
*/
114-
val supportedLanguages: List[String] = List("scala", "java")
115+
val supportedLanguages: List[String] = List("scala", "java", "query")
115116
def isSupportedLanguage(programmingLang: String): Boolean =
116117
supportedLanguages.contains(APIUtil.normaliseDynamicCodeLanguage(programmingLang))
117-
val supportedLanguagesText: String = "Scala, Java"
118+
val supportedLanguagesText: String = "Scala, Java, Query"
119+
120+
/**
121+
* True when the body is a Dynamic Query (`programming_lang` `Query`): a declaration, not code. It
122+
* runs no user code, so the switch for user-supplied code (`allow_user_generated_scala_code`) does
123+
* not apply to it, and it is read-only, so its doc's request_verb must be GET.
124+
*/
125+
def isQuery(programmingLang: String): Boolean = APIUtil.normaliseDynamicCodeLanguage(programmingLang) == "query"
126+
127+
/** A Dynamic Query only reads; every other language may use any verb. */
128+
def verbAllowed(programmingLang: String, requestVerb: String): Boolean =
129+
!isQuery(programmingLang) || requestVerb == "GET"
130+
131+
val queryVerbMessage: String = "A Dynamic Query only reads, so its request_verb must be GET."
118132

119133
/**
120134
* The native http4s template a method body is inlined into. Returns the full source and the
@@ -167,14 +181,26 @@ object CompiledObjects {
167181
* diagnostics with line numbers relative to the method body the author wrote, in the body's
168182
* programming language (Scala or Java). Empty = compiles.
169183
*/
170-
def compileProblems(exampleRequestBody: Option[JValue], successResponseBody: Option[JValue], methodBody: String, programmingLang: String = "Scala"): List[DynamicUtil.CompileProblem] = {
184+
def compileProblems(exampleRequestBody: Option[JValue], successResponseBody: Option[JValue], methodBody: String,
185+
programmingLang: String = "Scala", bankId: Option[String] = None): List[DynamicUtil.CompileProblem] = {
171186
val decodedMethodBody = URLDecoder.decode(methodBody, "UTF-8")
172187
// Java bodies are compiled as written (no template, no generated case classes), so the example
173-
// bodies play no part, as they play none in CompiledObjects' own Java branch.
174-
if (APIUtil.normaliseDynamicCodeLanguage(programmingLang) == "java") DynamicUtil.checkJavaCode(decodedMethodBody)
175-
else scalaCompileProblems(exampleRequestBody, successResponseBody, decodedMethodBody)
188+
// bodies play no part, as they play none in CompiledObjects' own Java branch. A Dynamic Query is
189+
// not compiled at all: its problems are the declaration's, which have no line numbers.
190+
APIUtil.normaliseDynamicCodeLanguage(programmingLang) match {
191+
case "java" => DynamicUtil.checkJavaCode(decodedMethodBody)
192+
case "query" => queryProblem(decodedMethodBody, bankId).map(message => DynamicUtil.CompileProblem(0, 0, "ERROR", message)).toList
193+
case _ => scalaCompileProblems(exampleRequestBody, successResponseBody, decodedMethodBody)
194+
}
176195
}
177196

197+
/** What is wrong with a Dynamic Query body, in full (error code included), or None when it is valid in `bankId`'s space. */
198+
def queryProblem(decodedMethodBody: String, bankId: Option[String]): Option[String] =
199+
code.api.dynamic.entity.query.DynamicQueryDeclaration.parse(decodedMethodBody) match {
200+
case Left(error) => Some(s"${ErrorMessages.DynamicQueryInvalid}${error.message}")
201+
case Right(declaration) => code.api.dynamic.entity.query.DynamicQuery.validate(bankId, declaration).left.toOption.map(_.message)
202+
}
203+
178204
private def scalaCompileProblems(exampleRequestBody: Option[JValue], successResponseBody: Option[JValue], decodedMethodBody: String): List[DynamicUtil.CompileProblem] = {
179205
val requestBody: Product = exampleRequestBody match {
180206
case Some(JString(s)) if StringUtils.isBlank(s) => toCaseObject(None)
@@ -205,17 +231,40 @@ object CompiledObjects {
205231
}
206232
}
207233

208-
case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBody: Option[JValue], methodBody: String, programmingLang: String = "Scala") {
234+
/**
235+
* `bankId` is the Dynamic Entity space of the doc (None for the system space). Only a Dynamic Query
236+
* uses it: its declaration names entities, which are looked up in that space.
237+
*/
238+
case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBody: Option[JValue], methodBody: String,
239+
programmingLang: String = "Scala", bankId: Option[String] = None) {
209240
val decodedMethodBody = URLDecoder.decode(methodBody, "UTF-8")
241+
private val isQuery = CompiledObjects.isQuery(programmingLang)
242+
// The stored doc of a system-level Dynamic Resource Doc carries Some(null) here, not None.
243+
private val space: Option[String] = bankId.flatMap(Option(_)).map(_.trim).filter(_.nonEmpty)
244+
245+
// A Dynamic Query compiles nothing, not even case classes for its examples: toCaseObject generates
246+
// and compiles Scala, which is refused where user-supplied code is switched off. Its examples are
247+
// carried as JSON, which the resource-docs serialisation renders as they are.
248+
private def exampleOf(json: Option[JValue]): Product =
249+
if (!isQuery) toCaseObject(json)
250+
else json.filter(j => j != JNothing && j != JNull).map(code.api.berlin.group.v1_3.JvalueCaseClass(_)).getOrElse(EmptyBody)
251+
210252
val requestBody: Product = exampleRequestBody match {
211253
//this case means, we accept the empty string "" from json post body, we need to map it to None.
212-
case Some(JString(s)) if StringUtils.isBlank(s) => toCaseObject(None)
254+
case Some(JString(s)) if StringUtils.isBlank(s) => exampleOf(None)
213255
// Here we will generate the object by the JValue (exampleRequestBody)
214-
case _ => toCaseObject(exampleRequestBody)
256+
case _ => exampleOf(exampleRequestBody)
215257
}
216-
val successResponse: Product = toCaseObject(successResponseBody)
258+
val successResponse: Product = exampleOf(successResponseBody)
217259

218260
private val partialFunction: Http4sEndpointIO = APIUtil.normaliseDynamicCodeLanguage(programmingLang) match {
261+
case "query" =>
262+
// A declaration, not code: parsed here (a malformed body cannot be served), checked against the
263+
// entity definitions by validateDependency, and run per request by DynamicQueryEndpoint.
264+
code.api.dynamic.entity.query.DynamicQueryDeclaration.parse(decodedMethodBody) match {
265+
case Right(declaration) => DynamicQueryEndpoint(declaration, space)
266+
case Left(error) => throw JsonResponseException(s"${ErrorMessages.DynamicQueryInvalid}${error.message}", 400, "none")
267+
}
219268
case "java" =>
220269
DynamicUtil.createJavaHttp4sEndpoint(decodedMethodBody) match {
221270
case Full(func) => func
@@ -286,6 +335,8 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo
286335
*/
287336
def validateDependency() = APIUtil.normaliseDynamicCodeLanguage(programmingLang) match {
288337
case "java" => ()
338+
// A Dynamic Query calls no methods; what it is checked against is the entity definitions of its space.
339+
case "query" => CompiledObjects.queryProblem(decodedMethodBody, space).foreach(message => throw JsonResponseException(message, 400, "none"))
289340
case _ => Validation.validateDependency(this.partialFunction)
290341
}
291342

@@ -294,7 +345,7 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo
294345
* author wrote (the wrapper's own lines are subtracted). Empty = compiles. Nothing is evaluated or cached.
295346
*/
296347
def compileProblems(): List[DynamicUtil.CompileProblem] =
297-
CompiledObjects.compileProblems(exampleRequestBody, successResponseBody, methodBody, programmingLang)
348+
CompiledObjects.compileProblems(exampleRequestBody, successResponseBody, methodBody, programmingLang, space)
298349

299350
/**
300351
* Wraps the compiled partial function as an endpoint. This used to bind a per-bank
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
Open Bank Project - API
3+
Copyright (C) 2011-2026, TESOBE GmbH.
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU Affero General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU Affero General Public License for more details.
14+
15+
You should have received a copy of the GNU Affero General Public License
16+
along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
Email: contact@tesobe.com
19+
TESOBE GmbH.
20+
Osloer Strasse 16/17
21+
Berlin 13359, Germany
22+
23+
This product includes software developed at
24+
TESOBE (http://www.tesobe.com/)
25+
26+
*/
27+
28+
package code.api.dynamic.endpoint.helper
29+
30+
import cats.effect.IO
31+
import code.api.dynamic.entity.query.{DynamicQuery, DynamicQueryDeclaration}
32+
import code.api.util.APIUtil.Http4sEndpointIO
33+
import code.api.util.{APIUtil, CallContext}
34+
import code.util.Helper.MdcLoggable
35+
import com.openbankproject.commons.util.JsonAliases.compactRender
36+
import org.http4s.headers.`Content-Type`
37+
import org.http4s.{MediaType, Request, Response, Status}
38+
import org.json4s.JsonAST.{JInt, JObject, JString}
39+
40+
/**
41+
* This object turns a Dynamic Query declaration into the handler of a Dynamic Resource Doc whose
42+
* `programming_lang` is `Query`. Nothing is compiled: each request is answered by
43+
* [[DynamicQuery.run]] for the caller, in the Dynamic Entity space of the doc (`bankId`, None for the
44+
* system space). The doc's own Roles are enforced by the middleware before this runs, as for any
45+
* Dynamic Resource Doc.
46+
*/
47+
object DynamicQueryEndpoint extends MdcLoggable {
48+
49+
private val jsonContentType = `Content-Type`(MediaType.application.json)
50+
51+
def apply(declaration: DynamicQueryDeclaration, bankId: Option[String]): Http4sEndpointIO = new Http4sEndpointIO {
52+
override def isDefinedAt(req: Request[IO]): Boolean = true
53+
54+
override def apply(req: Request[IO]): CallContext => IO[Response[IO]] = { cc =>
55+
val callerParams = req.uri.query.multiParams.map { case (name, values) => name -> values.toList }
56+
IO.blocking {
57+
DynamicQuery.run(bankId, declaration, callerParams, cc.user.map(_.userId).toOption, APIUtil.getConsumerPrimaryKey(Some(cc)))
58+
}.map {
59+
case Right(result) => json(Status.Ok, result)
60+
case Left(failure) => json(Status.fromInt(failure.status).getOrElse(Status.BadRequest),
61+
JObject(List("code" -> JInt(failure.status), "message" -> JString(failure.message))))
62+
}.handleError { e =>
63+
logger.warn(s"DynamicQueryEndpoint says: the Dynamic Query from '${declaration.from}' failed", e)
64+
json(Status.InternalServerError, JObject(List("code" -> JInt(500), "message" -> JString(s"OBP-50000: Unknown Error. ${e.getMessage}"))))
65+
}
66+
}
67+
}
68+
69+
private def json(status: Status, body: JObject): Response[IO] =
70+
Response[IO](status).withEntity(compactRender(body)).withContentType(jsonContentType)
71+
}

0 commit comments

Comments
 (0)