From e79754ff7a760ef64995f051a72bb9d191b59dc4 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Fri, 3 Apr 2026 23:31:51 +0200 Subject: [PATCH 01/14] add $schema declaration support via JsonSchemaVersion Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 ++- lib/src/main/scala/jsonschema/Schema.scala | 18 ++++++++ .../scala/jsonschema/JsonSchemaTest.scala | 42 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d66f45c..3de5d9f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ libraryDependencies += "io.github.ramytanios" %% "json-schema-lib" % "" - **Map support** - `Map[String, V]` maps to `{ "type": "object", "additionalProperties": ... }` - **Option support** - Optional fields automatically excluded from required list - **Circe integration** - Built-in JSON encoding for schemas +- **`$schema` declaration** - Annotate the root schema with a JSON Schema draft URI via `withSchemaVersion` ## Example @@ -56,13 +57,16 @@ case class Profile( // object Profile: // given JsonSchema[Profile] = DeriveJsonSchema.derived -val json = JsonSchema[Profile].schema.toJson +val json = JsonSchema[Profile].schema + .withSchemaVersion(JsonSchemaVersion.Draft202012) + .toJson ``` **Generated JSON Schema:** ```json { + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "title": "User profile", "properties": { diff --git a/lib/src/main/scala/jsonschema/Schema.scala b/lib/src/main/scala/jsonschema/Schema.scala index 59ebf59..58ec747 100644 --- a/lib/src/main/scala/jsonschema/Schema.scala +++ b/lib/src/main/scala/jsonschema/Schema.scala @@ -10,6 +10,14 @@ import io.circe.JsonObject sealed trait Schema: def toJson: Json +/** + * Represents a JSON Schema draft version, used to declare the `$schema` keyword on a root schema. + */ +enum JsonSchemaVersion(val uri: String): + case Draft07 extends JsonSchemaVersion("http://json-schema.org/draft-07/schema#") + case Draft201909 extends JsonSchemaVersion("https://json-schema.org/draft/2019-09/schema") + case Draft202012 extends JsonSchemaVersion("https://json-schema.org/draft/2020-12/schema") + object Schema: given Encoder[Schema] = Encoder.instance(_.toJson) @@ -35,6 +43,16 @@ object Schema: def withDescription(description: Option[String]): Schema = withOptionalStringField("description", description) + /** + * Add a `$schema` declaration to the schema. + * Should only be called on a root schema — nested schemas do not carry this field. + */ + def withSchemaVersion(version: JsonSchemaVersion): Schema = + new Schema: + def toJson: Json = + val base = sch.toJson.asObject.getOrElse(JsonObject.empty) + Json.fromJsonObject(("$schema" -> Json.fromString(version.uri)) +: base) + private def buildNumericJson[N](typeName: String, encode: N => Json)( minimum: Option[N], maximum: Option[N], diff --git a/lib/src/test/scala/jsonschema/JsonSchemaTest.scala b/lib/src/test/scala/jsonschema/JsonSchemaTest.scala index e8175d4..e5de8ba 100644 --- a/lib/src/test/scala/jsonschema/JsonSchemaTest.scala +++ b/lib/src/test/scala/jsonschema/JsonSchemaTest.scala @@ -225,3 +225,45 @@ class JsonSchemaTest extends FunSuite: } }""").getOrElse(io.circe.Json.Null) assertEquals(json, expected) + + test("withSchemaVersion adds $schema as the first field (Draft-07)"): + val schema = JsonSchema[String].schema.withSchemaVersion(JsonSchemaVersion.Draft07) + val json = schema.toJson + val expected = + parse("""{"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}""") + .getOrElse(io.circe.Json.Null) + assertEquals(json, expected) + assertEquals(json.hcursor.keys.flatMap(_.headOption), Some("$schema")) + + test("withSchemaVersion adds $schema as the first field (Draft 2019-09)"): + val schema = JsonSchema[Int].schema.withSchemaVersion(JsonSchemaVersion.Draft201909) + val json = schema.toJson + val expected = + parse("""{"$schema": "https://json-schema.org/draft/2019-09/schema", "type": "integer"}""") + .getOrElse(io.circe.Json.Null) + assertEquals(json, expected) + assertEquals(json.hcursor.keys.flatMap(_.headOption), Some("$schema")) + + test("withSchemaVersion adds $schema as the first field (Draft 2020-12)"): + val schema = JsonSchema[Boolean].schema.withSchemaVersion(JsonSchemaVersion.Draft202012) + val json = schema.toJson + val expected = + parse("""{"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "boolean"}""") + .getOrElse(io.circe.Json.Null) + assertEquals(json, expected) + assertEquals(json.hcursor.keys.flatMap(_.headOption), Some("$schema")) + + test("withSchemaVersion composes with withTitle and withDescription"): + val schema = JsonSchema[String].schema + .withTitle(Some("My Schema")) + .withDescription(Some("A test schema")) + .withSchemaVersion(JsonSchemaVersion.Draft202012) + val json = schema.toJson + val expected = parse("""{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "title": "My Schema", + "description": "A test schema" + }""").getOrElse(io.circe.Json.Null) + assertEquals(json, expected) + assertEquals(json.hcursor.keys.flatMap(_.headOption), Some("$schema")) From 0a8b442cf57c4af5400ca10858c5a18a2716c49b Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 10:06:36 +0200 Subject: [PATCH 02/14] add excel module for generating Excel Custom Functions artifacts Co-Authored-By: Claude Sonnet 4.6 --- build.sbt | 15 +- .../excel/ExcelFunctionBuilder.scala | 31 ++++ .../jsonschema/excel/ExcelFunctionDef.scala | 31 ++++ .../excel/ExcelFunctionsManifest.scala | 11 ++ .../jsonschema/excel/ExcelJsGenerator.scala | 29 ++++ .../scala/jsonschema/excel/ExcelOutput.scala | 10 ++ .../jsonschema/excel/ExcelParameter.scala | 24 ++++ .../jsonschema/excel/ExcelParameterType.scala | 12 ++ .../excel/ExcelFunctionBuilderTest.scala | 133 ++++++++++++++++++ 9 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelFunctionsManifest.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelOutput.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelParameter.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala create mode 100644 excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala diff --git a/build.sbt b/build.sbt index 906e256..a7efd55 100644 --- a/build.sbt +++ b/build.sbt @@ -29,7 +29,7 @@ lazy val V = new { lazy val root = (project in file(".")) - .aggregate(libJVM, libJS) + .aggregate(libJVM, libJS, excel) .settings(publish / skip := true) lazy val lib = crossProject(JSPlatform, JVMPlatform) @@ -52,3 +52,16 @@ lazy val lib = crossProject(JSPlatform, JVMPlatform) lazy val libJVM = lib.jvm lazy val libJS = lib.js + +lazy val excel = + (project in file("excel")) + .dependsOn(libJVM) + .settings( + name := "json-schema-lib-excel", + publish / skip := true, + libraryDependencies ++= Seq( + "io.circe" %% "circe-core" % V.circe, + "org.scalameta" %% "munit" % V.munit % Test + ), + scalacOptions -= "-Xfatal-warnings" + ) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala b/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala new file mode 100644 index 0000000..face87c --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala @@ -0,0 +1,31 @@ +package jsonschema.excel + +import io.circe.Json +import jsonschema.JsonSchema + +object ExcelFunctionBuilder: + + def from[A](id: String, description: String)(using js: JsonSchema[A]): ExcelFunctionDef = + val cursor = js.schema.toJson.hcursor + if cursor.get[String]("type").getOrElse("") != "object" then + throw IllegalArgumentException("ExcelFunctionBuilder.from requires a case class JsonSchema") + val required = cursor.downField("required").as[List[String]].getOrElse(Nil) + val properties = cursor.downField("properties").as[Map[String, Json]].getOrElse(Map.empty) + val reqPairs = required.flatMap(n => properties.get(n).map(n -> _)) + val optPairs = (properties -- required.toSet).toList.sortBy(_._1) + val params = (reqPairs ++ optPairs).map { (name, fieldJson) => + val fc = fieldJson.hcursor + val excelType = fc.get[String]("type").toOption match + case Some("string") => ExcelParameterType.StringType + case Some("integer") => ExcelParameterType.NumberType + case Some("number") => ExcelParameterType.NumberType + case Some("boolean") => ExcelParameterType.BooleanType + case _ => ExcelParameterType.AnyType + val desc = fc + .get[String]("description") + .toOption + .orElse(fc.get[String]("title").toOption) + .getOrElse("") + ExcelParameter(name, desc, excelType, !required.contains(name)) + } + ExcelFunctionDef(id, id, description, params) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala b/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala new file mode 100644 index 0000000..4d76ccc --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala @@ -0,0 +1,31 @@ +package jsonschema.excel + +import io.circe.Encoder +import io.circe.Json +import io.circe.JsonObject + +case class ExcelFunctionDef( + id: String, + name: String, + description: String, + parameters: List[ExcelParameter] +) + +object ExcelFunctionDef: + given Encoder[ExcelFunctionDef] = Encoder.instance { fn => + Json.fromJsonObject( + JsonObject( + "id" -> Json.fromString(fn.id), + "name" -> Json.fromString(fn.name), + "description" -> Json.fromString(fn.description), + "parameters" -> Encoder[List[ExcelParameter]].apply(fn.parameters), + "result" -> Json.fromJsonObject(JsonObject("type" -> Json.fromString("any"))), + "options" -> Json.fromJsonObject( + JsonObject( + "stream" -> Json.fromBoolean(false), + "cancelable" -> Json.fromBoolean(false) + ) + ) + ) + ) + } diff --git a/excel/src/main/scala/jsonschema/excel/ExcelFunctionsManifest.scala b/excel/src/main/scala/jsonschema/excel/ExcelFunctionsManifest.scala new file mode 100644 index 0000000..5298521 --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelFunctionsManifest.scala @@ -0,0 +1,11 @@ +package jsonschema.excel + +import io.circe.Encoder +import io.circe.Json +import io.circe.JsonObject + +case class ExcelFunctionsManifest(functions: List[ExcelFunctionDef]): + def toJson: Json = + Json.fromJsonObject( + JsonObject("functions" -> Encoder[List[ExcelFunctionDef]].apply(functions)) + ) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala new file mode 100644 index 0000000..6da083e --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -0,0 +1,29 @@ +package jsonschema.excel + +object ExcelJsGenerator: + + def generate(functions: List[ExcelFunctionDef], centralUrl: String): String = + val header = s"""const CENTRAL_URL = "$centralUrl";""" + val bodies = functions + .map { fn => + val paramNames = fn.parameters.map(_.name).mkString(", ") + val paramsEntries = fn.parameters.map(p => s" ${p.name}: ${p.name}").mkString(",\n") + s"""CustomFunctions.associate( + "${fn.id}", + async function($paramNames) { + const res = await fetch(CENTRAL_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + functionId: "${fn.id}", + params: { +$paramsEntries + } + }) + }); + return res.json(); + } +);""" + } + .mkString("\n\n") + s"$header\n\n$bodies\n" diff --git a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala new file mode 100644 index 0000000..2ab6155 --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala @@ -0,0 +1,10 @@ +package jsonschema.excel + +case class ExcelOutput(manifest: ExcelFunctionsManifest, js: String) + +object ExcelOutput: + def generate(functions: List[ExcelFunctionDef], centralUrl: String): ExcelOutput = + ExcelOutput( + ExcelFunctionsManifest(functions), + ExcelJsGenerator.generate(functions, centralUrl) + ) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala b/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala new file mode 100644 index 0000000..522e5a4 --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala @@ -0,0 +1,24 @@ +package jsonschema.excel + +import io.circe.Encoder +import io.circe.Json +import io.circe.JsonObject + +case class ExcelParameter( + name: String, + description: String, + `type`: ExcelParameterType, + optional: Boolean +) + +object ExcelParameter: + given Encoder[ExcelParameter] = Encoder.instance { p => + Json.fromJsonObject( + JsonObject( + "name" -> Json.fromString(p.name), + "description" -> Json.fromString(p.description), + "type" -> Encoder[ExcelParameterType].apply(p.`type`), + "optional" -> Json.fromBoolean(p.optional) + ) + ) + } diff --git a/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala b/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala new file mode 100644 index 0000000..157a238 --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala @@ -0,0 +1,12 @@ +package jsonschema.excel + +enum ExcelParameterType: + case StringType, NumberType, BooleanType, AnyType + +object ExcelParameterType: + given io.circe.Encoder[ExcelParameterType] = + io.circe.Encoder.encodeString.contramap: + case StringType => "string" + case NumberType => "number" + case BooleanType => "boolean" + case AnyType => "any" diff --git a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala new file mode 100644 index 0000000..1c74dc4 --- /dev/null +++ b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala @@ -0,0 +1,133 @@ +package jsonschema.excel + +import jsonschema.* +import munit.FunSuite + +class ExcelFunctionBuilderTest extends FunSuite: + + // ── Builder tests ────────────────────────────────────────────────────────── + + test("all required fields → correct types, optional = false"): + case class Req(name: String, count: Int, score: Double, flag: Boolean) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Req]("REQ", "desc") + assertEquals(fn.parameters.map(_.name), List("name", "count", "score", "flag")) + assertEquals( + fn.parameters.map(_.`type`), + List( + ExcelParameterType.StringType, + ExcelParameterType.NumberType, + ExcelParameterType.NumberType, + ExcelParameterType.BooleanType + ) + ) + assert(fn.parameters.forall(!_.optional)) + + test("Option[T] fields → optional = true, inner type resolved correctly"): + case class Opt(name: String, count: Option[Int]) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Opt]("OPT", "desc") + val countParam = fn.parameters.find(_.name == "count").get + assertEquals(countParam.optional, true) + assertEquals(countParam.`type`, ExcelParameterType.NumberType) + + test("Int/Long → number, Double/Float → number"): + case class Nums(i: Int, l: Long, d: Double, f: Float) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Nums]("NUMS", "desc") + assert(fn.parameters.forall(_.`type` == ExcelParameterType.NumberType)) + + test("Boolean → boolean"): + case class Bools(b: Boolean) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Bools]("BOOLS", "desc") + assertEquals(fn.parameters.head.`type`, ExcelParameterType.BooleanType) + + test("List, Map, nested case class → any; non-parameterized enum → string"): + case class Inner(x: Int) derives JsonSchema + enum Color derives JsonSchema: + case Red, Green, Blue + case class Complex(lst: List[String], mp: Map[String, Int], inner: Inner, color: Color) + derives JsonSchema + val fn = ExcelFunctionBuilder.from[Complex]("COMPLEX", "desc") + val anyParams = fn.parameters.filter(_.`type` == ExcelParameterType.AnyType) + assertEquals(anyParams.map(_.name).toSet, Set("lst", "mp", "inner")) + val colorParam = fn.parameters.find(_.name == "color").get + assertEquals(colorParam.`type`, ExcelParameterType.StringType) + + test("@Description on field → description populated"): + case class Described(@Description("The user name") name: String) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Described]("DESC", "desc") + assertEquals(fn.parameters.head.description, "The user name") + + test("@Title with no @Description → description falls back to title"): + case class Titled(@Title("User Name") name: String) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Titled]("TITLE", "desc") + assertEquals(fn.parameters.head.description, "User Name") + + test("no annotation → description = empty string"): + case class NoAnno(name: String) derives JsonSchema + val fn = ExcelFunctionBuilder.from[NoAnno]("NOANNO", "desc") + assertEquals(fn.parameters.head.description, "") + + test("required fields appear before optional in parameter list"): + case class Mixed(id: Int, name: String, note: Option[String], extra: Option[Int]) + derives JsonSchema + val fn = ExcelFunctionBuilder.from[Mixed]("MIXED", "desc") + val names = fn.parameters.map(_.name) + val reqIdx = names.indexOf("id").max(names.indexOf("name")) + val optIdx = names.indexOf("note").min(names.indexOf("extra")) + assert(reqIdx < optIdx) + + test("non-ObjectSchema input → IllegalArgumentException"): + @annotation.nowarn("msg=unused local definition") + case class NotObject(value: String) derives JsonSchema + // Use an enum schema (non-object) directly + enum Color derives JsonSchema: + case Red, Green, Blue + intercept[IllegalArgumentException]: + ExcelFunctionBuilder.from[Color]("COLOR", "desc") + + // ── Manifest test ────────────────────────────────────────────────────────── + + test("ExcelFunctionsManifest.toJson wraps functions in {\"functions\": [...]}"): + case class Simple(x: String) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Simple]("SIMPLE", "desc") + val manifest = ExcelFunctionsManifest(List(fn)) + val json = manifest.toJson + assert(json.hcursor.downField("functions").focus.exists(_.isArray)) + assertEquals(json.hcursor.downField("functions").as[List[io.circe.Json]].map(_.size), Right(1)) + + // ── JS generator tests ───────────────────────────────────────────────────── + + test("ExcelJsGenerator.generate output contains const CENTRAL_URL header"): + val js = ExcelJsGenerator.generate(Nil, "https://example.com/route") + assert(js.contains("""const CENTRAL_URL = "https://example.com/route";""")) + + test("generated JS contains CustomFunctions.associate call"): + case class Req2(ticker: String) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Req2]("MY.FUNC", "desc") + val js = ExcelJsGenerator.generate(List(fn), "https://example.com") + assert(js.contains("CustomFunctions.associate(")) + assert(js.contains(""""MY.FUNC"""")) + assert(js.contains("async function(ticker)")) + + test("request body shape: { functionId: ..., params: { field: field } }"): + case class Req3(ticker: String, amount: Double) derives JsonSchema + val fn = ExcelFunctionBuilder.from[Req3]("MY.FUNC2", "desc") + val js = ExcelJsGenerator.generate(List(fn), "https://example.com") + assert(js.contains("""functionId: "MY.FUNC2"""")) + assert(js.contains("ticker: ticker")) + assert(js.contains("amount: amount")) + + test("full round-trip: two functions, both CustomFunctions.associate calls present"): + case class PricingRequest(ticker: String, date: Option[String]) derives JsonSchema + case class RiskRequest(portfolio: String, confidence: Double) derives JsonSchema + val fns = List( + ExcelFunctionBuilder.from[PricingRequest]("PRICING.GET_PRICE", "Gets price"), + ExcelFunctionBuilder.from[RiskRequest]("RISK.COMPUTE_VAR", "Computes VaR") + ) + val output = ExcelOutput.generate(fns, "https://central-api.example.com/route") + assert(output.js.contains(""""PRICING.GET_PRICE"""")) + assert(output.js.contains(""""RISK.COMPUTE_VAR"""")) + val manifestJson = output.manifest.toJson + assertEquals( + manifestJson.hcursor.downField("functions").as[List[io.circe.Json]].map(_.size), + Right(2) + ) From f2bdbfa6177946c38863b2554e42ed997f4762c8 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 10:19:15 +0200 Subject: [PATCH 03/14] switch to axios for POST requests, add selfContained bundling option Co-Authored-By: Claude Sonnet 4.6 --- .../jsonschema/excel/ExcelJsGenerator.scala | 36 ++++++++++++------- .../scala/jsonschema/excel/ExcelOutput.scala | 8 +++-- .../excel/ExcelFunctionBuilderTest.scala | 8 ++++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala index 6da083e..0591525 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -2,28 +2,40 @@ package jsonschema.excel object ExcelJsGenerator: - def generate(functions: List[ExcelFunctionDef], centralUrl: String): String = + private val axiosVersion = "1.7.9" + private val axiosCdnUrl = + s"https://cdn.jsdelivr.net/npm/axios@$axiosVersion/dist/axios.min.js" + + private def fetchAxiosSource(): String = + val conn = java.net.URI.create(axiosCdnUrl).toURL.openConnection() + conn.setConnectTimeout(10_000) + conn.setReadTimeout(10_000) + scala.io.Source.fromInputStream(conn.getInputStream).mkString + + def generate( + functions: List[ExcelFunctionDef], + centralUrl: String, + selfContained: Boolean = false + ): String = val header = s"""const CENTRAL_URL = "$centralUrl";""" val bodies = functions .map { fn => val paramNames = fn.parameters.map(_.name).mkString(", ") - val paramsEntries = fn.parameters.map(p => s" ${p.name}: ${p.name}").mkString(",\n") + val paramsEntries = fn.parameters.map(p => s" ${p.name}: ${p.name}").mkString(",\n") s"""CustomFunctions.associate( "${fn.id}", async function($paramNames) { - const res = await fetch(CENTRAL_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - functionId: "${fn.id}", - params: { + const response = await axios.post(CENTRAL_URL, { + functionId: "${fn.id}", + params: { $paramsEntries - } - }) + } }); - return res.json(); + return response.data; } );""" } .mkString("\n\n") - s"$header\n\n$bodies\n" + val core = s"$header\n\n$bodies\n" + if selfContained then s"${fetchAxiosSource()}\n\n$core" + else core diff --git a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala index 2ab6155..05d1c90 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala @@ -3,8 +3,12 @@ package jsonschema.excel case class ExcelOutput(manifest: ExcelFunctionsManifest, js: String) object ExcelOutput: - def generate(functions: List[ExcelFunctionDef], centralUrl: String): ExcelOutput = + def generate( + functions: List[ExcelFunctionDef], + centralUrl: String, + selfContained: Boolean = false + ): ExcelOutput = ExcelOutput( ExcelFunctionsManifest(functions), - ExcelJsGenerator.generate(functions, centralUrl) + ExcelJsGenerator.generate(functions, centralUrl, selfContained) ) diff --git a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala index 1c74dc4..61d82d1 100644 --- a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala +++ b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala @@ -100,13 +100,15 @@ class ExcelFunctionBuilderTest extends FunSuite: val js = ExcelJsGenerator.generate(Nil, "https://example.com/route") assert(js.contains("""const CENTRAL_URL = "https://example.com/route";""")) - test("generated JS contains CustomFunctions.associate call"): + test("generated JS uses axios.post and returns response.data"): case class Req2(ticker: String) derives JsonSchema val fn = ExcelFunctionBuilder.from[Req2]("MY.FUNC", "desc") val js = ExcelJsGenerator.generate(List(fn), "https://example.com") assert(js.contains("CustomFunctions.associate(")) assert(js.contains(""""MY.FUNC"""")) assert(js.contains("async function(ticker)")) + assert(js.contains("axios.post(CENTRAL_URL")) + assert(js.contains("return response.data")) test("request body shape: { functionId: ..., params: { field: field } }"): case class Req3(ticker: String, amount: Double) derives JsonSchema @@ -116,6 +118,10 @@ class ExcelFunctionBuilderTest extends FunSuite: assert(js.contains("ticker: ticker")) assert(js.contains("amount: amount")) + test("selfContained = false does not include axios source inline"): + val js = ExcelJsGenerator.generate(Nil, "https://example.com", selfContained = false) + assert(!js.contains("axios/lib") && !js.contains("var axios")) + test("full round-trip: two functions, both CustomFunctions.associate calls present"): case class PricingRequest(ticker: String, date: Option[String]) derives JsonSchema case class RiskRequest(portfolio: String, confidence: Double) derives JsonSchema From 3f15ec249a2bb291727be749085e0a6d2bd8dd26 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 10:38:12 +0200 Subject: [PATCH 04/14] clean up generated JS formatting and align params to longest key Co-Authored-By: Claude Sonnet 4.6 --- .../jsonschema/excel/ExcelJsGenerator.scala | 47 +++++++++++-------- .../excel/ExcelFunctionBuilderTest.scala | 4 +- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala index 0591525..d2c3499 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -3,8 +3,7 @@ package jsonschema.excel object ExcelJsGenerator: private val axiosVersion = "1.7.9" - private val axiosCdnUrl = - s"https://cdn.jsdelivr.net/npm/axios@$axiosVersion/dist/axios.min.js" + private val axiosCdnUrl = s"https://cdn.jsdelivr.net/npm/axios@$axiosVersion/dist/axios.min.js" private def fetchAxiosSource(): String = val conn = java.net.URI.create(axiosCdnUrl).toURL.openConnection() @@ -12,30 +11,38 @@ object ExcelJsGenerator: conn.setReadTimeout(10_000) scala.io.Source.fromInputStream(conn.getInputStream).mkString + private def renderFunction(fn: ExcelFunctionDef): String = + val paramNames = fn.parameters.map(_.name).mkString(", ") + val maxKeyLen = fn.parameters.map(_.name.length).maxOption.getOrElse(0) + val paramLines = fn.parameters.map { p => + val pad = " " * (maxKeyLen - p.name.length) + s" ${p.name}${pad}: ${p.name}," + } + val lines = + List( + s"""CustomFunctions.associate(""", + s""" "${fn.id}",""", + s""" async function($paramNames) {""", + s""" const response = await axios.post(CENTRAL_URL, {""", + s""" functionId: "${fn.id}",""", + s""" params: {""" + ) ++ paramLines ++ + List( + s""" },""", + s""" });""", + s""" return response.data;""", + s""" }""", + s""");""" + ) + lines.mkString("\n") + def generate( functions: List[ExcelFunctionDef], centralUrl: String, selfContained: Boolean = false ): String = val header = s"""const CENTRAL_URL = "$centralUrl";""" - val bodies = functions - .map { fn => - val paramNames = fn.parameters.map(_.name).mkString(", ") - val paramsEntries = fn.parameters.map(p => s" ${p.name}: ${p.name}").mkString(",\n") - s"""CustomFunctions.associate( - "${fn.id}", - async function($paramNames) { - const response = await axios.post(CENTRAL_URL, { - functionId: "${fn.id}", - params: { -$paramsEntries - } - }); - return response.data; - } -);""" - } - .mkString("\n\n") + val bodies = functions.map(renderFunction).mkString("\n\n") val core = s"$header\n\n$bodies\n" if selfContained then s"${fetchAxiosSource()}\n\n$core" else core diff --git a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala index 61d82d1..4b7ea4f 100644 --- a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala +++ b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala @@ -115,8 +115,8 @@ class ExcelFunctionBuilderTest extends FunSuite: val fn = ExcelFunctionBuilder.from[Req3]("MY.FUNC2", "desc") val js = ExcelJsGenerator.generate(List(fn), "https://example.com") assert(js.contains("""functionId: "MY.FUNC2"""")) - assert(js.contains("ticker: ticker")) - assert(js.contains("amount: amount")) + assert(js.contains("ticker: ticker,")) + assert(js.contains("amount: amount,")) test("selfContained = false does not include axios source inline"): val js = ExcelJsGenerator.generate(Nil, "https://example.com", selfContained = false) From 76d51a6e2a4f41e07e0646d99d3f48868eef9f66 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 10:40:04 +0200 Subject: [PATCH 05/14] use stripMargin for JS template strings in ExcelJsGenerator Co-Authored-By: Claude Sonnet 4.6 --- .../jsonschema/excel/ExcelJsGenerator.scala | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala index d2c3499..6f256f0 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -14,35 +14,35 @@ object ExcelJsGenerator: private def renderFunction(fn: ExcelFunctionDef): String = val paramNames = fn.parameters.map(_.name).mkString(", ") val maxKeyLen = fn.parameters.map(_.name.length).maxOption.getOrElse(0) - val paramLines = fn.parameters.map { p => - val pad = " " * (maxKeyLen - p.name.length) - s" ${p.name}${pad}: ${p.name}," - } - val lines = - List( - s"""CustomFunctions.associate(""", - s""" "${fn.id}",""", - s""" async function($paramNames) {""", - s""" const response = await axios.post(CENTRAL_URL, {""", - s""" functionId: "${fn.id}",""", - s""" params: {""" - ) ++ paramLines ++ - List( - s""" },""", - s""" });""", - s""" return response.data;""", - s""" }""", - s""");""" - ) - lines.mkString("\n") + val paramLines = fn.parameters + .map { p => + val pad = " " * (maxKeyLen - p.name.length) + s" ${p.name}$pad: ${p.name}," + } + .mkString("\n") + s"""|CustomFunctions.associate( + | "${fn.id}", + | async function($paramNames) { + | const response = await axios.post(CENTRAL_URL, { + | functionId: "${fn.id}", + | params: { + |$paramLines + | }, + | }); + | return response.data; + | } + |);""".stripMargin def generate( functions: List[ExcelFunctionDef], centralUrl: String, selfContained: Boolean = false ): String = - val header = s"""const CENTRAL_URL = "$centralUrl";""" val bodies = functions.map(renderFunction).mkString("\n\n") - val core = s"$header\n\n$bodies\n" + val core = + s"""|const CENTRAL_URL = "$centralUrl"; + | + |$bodies + |""".stripMargin if selfContained then s"${fetchAxiosSource()}\n\n$core" else core From 1eb87421afbecdd76d73589a2f98801ef726da5e Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 11:18:42 +0200 Subject: [PATCH 06/14] more work --- .../main/scala/jsonschema/excel/Excel.scala | 17 +++++++++++++++++ .../excel/ExcelFunctionBuilder.scala | 4 ++-- .../jsonschema/excel/ExcelFunctionDef.scala | 3 +-- .../jsonschema/excel/ExcelJsGenerator.scala | 19 ++++++++++--------- .../scala/jsonschema/excel/ExcelOutput.scala | 1 + .../jsonschema/excel/ExcelParameter.scala | 4 ++-- .../jsonschema/excel/ExcelParameterType.scala | 14 ++++++++------ 7 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 excel/src/main/scala/jsonschema/excel/Excel.scala diff --git a/excel/src/main/scala/jsonschema/excel/Excel.scala b/excel/src/main/scala/jsonschema/excel/Excel.scala new file mode 100644 index 0000000..cdfe758 --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/Excel.scala @@ -0,0 +1,17 @@ +package jsonschema.excel + +import io.circe.Json + +/** + * Represents an Excel add-in with a list of functions and a central URL for the add-in. + * + * @param functions A list of Excel function definitions that the add-in provides. + * @param centralUrl The central URL for the add-in, which is used in the generated JavaScript and JSON outputs. + */ +class Excel(functions: List[ExcelFunctionDef], centralUrl: String): + + def `functions.js`(): String = + ExcelJsGenerator.generate(functions, centralUrl, selfContained = true) + + def `functions.json`(): Json = + ExcelOutput.generate(functions, centralUrl).manifest.toJson diff --git a/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala b/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala index face87c..18c1834 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala @@ -13,7 +13,7 @@ object ExcelFunctionBuilder: val properties = cursor.downField("properties").as[Map[String, Json]].getOrElse(Map.empty) val reqPairs = required.flatMap(n => properties.get(n).map(n -> _)) val optPairs = (properties -- required.toSet).toList.sortBy(_._1) - val params = (reqPairs ++ optPairs).map { (name, fieldJson) => + val params = (reqPairs ++ optPairs).map: (name, fieldJson) => val fc = fieldJson.hcursor val excelType = fc.get[String]("type").toOption match case Some("string") => ExcelParameterType.StringType @@ -27,5 +27,5 @@ object ExcelFunctionBuilder: .orElse(fc.get[String]("title").toOption) .getOrElse("") ExcelParameter(name, desc, excelType, !required.contains(name)) - } + ExcelFunctionDef(id, id, description, params) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala b/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala index 4d76ccc..be9b925 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelFunctionDef.scala @@ -12,7 +12,7 @@ case class ExcelFunctionDef( ) object ExcelFunctionDef: - given Encoder[ExcelFunctionDef] = Encoder.instance { fn => + given Encoder[ExcelFunctionDef] = Encoder.instance: fn => Json.fromJsonObject( JsonObject( "id" -> Json.fromString(fn.id), @@ -28,4 +28,3 @@ object ExcelFunctionDef: ) ) ) - } diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala index 6f256f0..c38e09b 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -2,14 +2,15 @@ package jsonschema.excel object ExcelJsGenerator: - private val axiosVersion = "1.7.9" - private val axiosCdnUrl = s"https://cdn.jsdelivr.net/npm/axios@$axiosVersion/dist/axios.min.js" + private object Axios: - private def fetchAxiosSource(): String = - val conn = java.net.URI.create(axiosCdnUrl).toURL.openConnection() - conn.setConnectTimeout(10_000) - conn.setReadTimeout(10_000) - scala.io.Source.fromInputStream(conn.getInputStream).mkString + private val version = "1.14.0" + private val cdnUrl = s"https://cdn.jsdelivr.net/npm/axios@$version/dist/axios.min.js" + def jsSource(): String = + val conn = java.net.URI.create(cdnUrl).toURL.openConnection() + conn.setConnectTimeout(10_000) + conn.setReadTimeout(10_000) + scala.io.Source.fromInputStream(conn.getInputStream).mkString private def renderFunction(fn: ExcelFunctionDef): String = val paramNames = fn.parameters.map(_.name).mkString(", ") @@ -26,7 +27,7 @@ object ExcelJsGenerator: | const response = await axios.post(CENTRAL_URL, { | functionId: "${fn.id}", | params: { - |$paramLines + | $paramLines | }, | }); | return response.data; @@ -44,5 +45,5 @@ object ExcelJsGenerator: | |$bodies |""".stripMargin - if selfContained then s"${fetchAxiosSource()}\n\n$core" + if selfContained then s"${Axios.jsSource()}\n\n$core" else core diff --git a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala index 05d1c90..989c869 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala @@ -3,6 +3,7 @@ package jsonschema.excel case class ExcelOutput(manifest: ExcelFunctionsManifest, js: String) object ExcelOutput: + def generate( functions: List[ExcelFunctionDef], centralUrl: String, diff --git a/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala b/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala index 522e5a4..970b604 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelParameter.scala @@ -12,7 +12,8 @@ case class ExcelParameter( ) object ExcelParameter: - given Encoder[ExcelParameter] = Encoder.instance { p => + + given Encoder[ExcelParameter] = Encoder.instance: p => Json.fromJsonObject( JsonObject( "name" -> Json.fromString(p.name), @@ -21,4 +22,3 @@ object ExcelParameter: "optional" -> Json.fromBoolean(p.optional) ) ) - } diff --git a/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala b/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala index 157a238..1d17b50 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelParameterType.scala @@ -1,12 +1,14 @@ package jsonschema.excel +import io.circe.Encoder + enum ExcelParameterType: case StringType, NumberType, BooleanType, AnyType object ExcelParameterType: - given io.circe.Encoder[ExcelParameterType] = - io.circe.Encoder.encodeString.contramap: - case StringType => "string" - case NumberType => "number" - case BooleanType => "boolean" - case AnyType => "any" + + given Encoder[ExcelParameterType] = Encoder.encodeString.contramap: + case StringType => "string" + case NumberType => "number" + case BooleanType => "boolean" + case AnyType => "any" From b31d4aaf98450c4bc6b7fd14c589cdfe89bf8b55 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 11:27:51 +0200 Subject: [PATCH 07/14] cleanup --- excel/src/main/scala/jsonschema/excel/Excel.scala | 3 +-- .../main/scala/jsonschema/excel/ExcelOutput.scala | 15 --------------- .../excel/ExcelFunctionBuilderTest.scala | 9 +++++---- 3 files changed, 6 insertions(+), 21 deletions(-) delete mode 100644 excel/src/main/scala/jsonschema/excel/ExcelOutput.scala diff --git a/excel/src/main/scala/jsonschema/excel/Excel.scala b/excel/src/main/scala/jsonschema/excel/Excel.scala index cdfe758..710ca4a 100644 --- a/excel/src/main/scala/jsonschema/excel/Excel.scala +++ b/excel/src/main/scala/jsonschema/excel/Excel.scala @@ -13,5 +13,4 @@ class Excel(functions: List[ExcelFunctionDef], centralUrl: String): def `functions.js`(): String = ExcelJsGenerator.generate(functions, centralUrl, selfContained = true) - def `functions.json`(): Json = - ExcelOutput.generate(functions, centralUrl).manifest.toJson + def `functions.json`(): Json = ExcelFunctionsManifest(functions).toJson diff --git a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala b/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala deleted file mode 100644 index 989c869..0000000 --- a/excel/src/main/scala/jsonschema/excel/ExcelOutput.scala +++ /dev/null @@ -1,15 +0,0 @@ -package jsonschema.excel - -case class ExcelOutput(manifest: ExcelFunctionsManifest, js: String) - -object ExcelOutput: - - def generate( - functions: List[ExcelFunctionDef], - centralUrl: String, - selfContained: Boolean = false - ): ExcelOutput = - ExcelOutput( - ExcelFunctionsManifest(functions), - ExcelJsGenerator.generate(functions, centralUrl, selfContained) - ) diff --git a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala index 4b7ea4f..ad5db65 100644 --- a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala +++ b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala @@ -129,10 +129,11 @@ class ExcelFunctionBuilderTest extends FunSuite: ExcelFunctionBuilder.from[PricingRequest]("PRICING.GET_PRICE", "Gets price"), ExcelFunctionBuilder.from[RiskRequest]("RISK.COMPUTE_VAR", "Computes VaR") ) - val output = ExcelOutput.generate(fns, "https://central-api.example.com/route") - assert(output.js.contains(""""PRICING.GET_PRICE"""")) - assert(output.js.contains(""""RISK.COMPUTE_VAR"""")) - val manifestJson = output.manifest.toJson + val excel = new Excel(fns, "https://central-api.example.com/route") + val js = excel.`functions.js`() + assert(js.contains(""""PRICING.GET_PRICE"""")) + assert(js.contains(""""RISK.COMPUTE_VAR"""")) + val manifestJson = excel.`functions.json`() assertEquals( manifestJson.hcursor.downField("functions").as[List[io.circe.Json]].map(_.size), Right(2) From 9054017c25b69afe386831a35c2e1825b0a0a03e Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 13:48:54 +0200 Subject: [PATCH 08/14] simplify ExcelFunctionBuilder and ExcelJsGenerator Co-Authored-By: Claude Sonnet 4.6 --- .../excel/ExcelFunctionBuilder.scala | 33 +++++++++---------- .../jsonschema/excel/ExcelJsGenerator.scala | 8 +---- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala b/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala index 18c1834..51f96f4 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelFunctionBuilder.scala @@ -9,23 +9,22 @@ object ExcelFunctionBuilder: val cursor = js.schema.toJson.hcursor if cursor.get[String]("type").getOrElse("") != "object" then throw IllegalArgumentException("ExcelFunctionBuilder.from requires a case class JsonSchema") - val required = cursor.downField("required").as[List[String]].getOrElse(Nil) + val required = cursor.downField("required").as[List[String]].getOrElse(Nil).toSet val properties = cursor.downField("properties").as[Map[String, Json]].getOrElse(Map.empty) - val reqPairs = required.flatMap(n => properties.get(n).map(n -> _)) - val optPairs = (properties -- required.toSet).toList.sortBy(_._1) - val params = (reqPairs ++ optPairs).map: (name, fieldJson) => - val fc = fieldJson.hcursor - val excelType = fc.get[String]("type").toOption match - case Some("string") => ExcelParameterType.StringType - case Some("integer") => ExcelParameterType.NumberType - case Some("number") => ExcelParameterType.NumberType - case Some("boolean") => ExcelParameterType.BooleanType - case _ => ExcelParameterType.AnyType - val desc = fc - .get[String]("description") - .toOption - .orElse(fc.get[String]("title").toOption) - .getOrElse("") - ExcelParameter(name, desc, excelType, !required.contains(name)) + val params = + properties.toList.sortBy((name, _) => (!required(name), name)).map: (name, fieldJson) => + val fc = fieldJson.hcursor + val excelType = fc.get[String]("type").toOption match + case Some("string") => ExcelParameterType.StringType + case Some("integer") => ExcelParameterType.NumberType + case Some("number") => ExcelParameterType.NumberType + case Some("boolean") => ExcelParameterType.BooleanType + case _ => ExcelParameterType.AnyType + val desc = fc + .get[String]("description") + .toOption + .orElse(fc.get[String]("title").toOption) + .getOrElse("") + ExcelParameter(name, desc, excelType, !required(name)) ExcelFunctionDef(id, id, description, params) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala index c38e09b..cbb3247 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -14,13 +14,7 @@ object ExcelJsGenerator: private def renderFunction(fn: ExcelFunctionDef): String = val paramNames = fn.parameters.map(_.name).mkString(", ") - val maxKeyLen = fn.parameters.map(_.name.length).maxOption.getOrElse(0) - val paramLines = fn.parameters - .map { p => - val pad = " " * (maxKeyLen - p.name.length) - s" ${p.name}$pad: ${p.name}," - } - .mkString("\n") + val paramLines = fn.parameters.map(p => s"${p.name}: ${p.name},").mkString("\n") s"""|CustomFunctions.associate( | "${fn.id}", | async function($paramNames) { From dc3e4cc7ea31889711e64102ebd000b4d5f107e8 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 14:16:50 +0200 Subject: [PATCH 09/14] add http4s server and functions.html for Excel add-in - add functions.html() to Excel: loads Office.js in , custom functions script in - add ExcelServer: serves functions.js, functions.json, functions.html from a single origin on port 7777 via http4s + cats-effect - content generated once at startup via Resource.eval - fix field-order sensitivity in ExcelFunctionBuilderTest Co-Authored-By: Claude Sonnet 4.6 --- build.sbt | 13 +++++-- .../main/scala/jsonschema/excel/Excel.scala | 13 +++++++ .../scala/jsonschema/excel/ExcelServer.scala | 37 +++++++++++++++++++ .../excel/ExcelFunctionBuilderTest.scala | 6 +-- 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelServer.scala diff --git a/build.sbt b/build.sbt index a7efd55..80270f2 100644 --- a/build.sbt +++ b/build.sbt @@ -23,8 +23,10 @@ ThisBuild / scmInfo := Some( ) lazy val V = new { - val circe = "0.14.15" - val munit = "1.2.1" + val circe = "0.14.15" + val munit = "1.2.1" + val http4s = "0.23.30" + val catsEffect = "3.5.4" } lazy val root = @@ -60,8 +62,11 @@ lazy val excel = name := "json-schema-lib-excel", publish / skip := true, libraryDependencies ++= Seq( - "io.circe" %% "circe-core" % V.circe, - "org.scalameta" %% "munit" % V.munit % Test + "io.circe" %% "circe-core" % V.circe, + "org.http4s" %% "http4s-ember-server" % V.http4s, + "org.http4s" %% "http4s-dsl" % V.http4s, + "org.typelevel" %% "cats-effect" % V.catsEffect, + "org.scalameta" %% "munit" % V.munit % Test ), scalacOptions -= "-Xfatal-warnings" ) diff --git a/excel/src/main/scala/jsonschema/excel/Excel.scala b/excel/src/main/scala/jsonschema/excel/Excel.scala index 710ca4a..25ae49a 100644 --- a/excel/src/main/scala/jsonschema/excel/Excel.scala +++ b/excel/src/main/scala/jsonschema/excel/Excel.scala @@ -14,3 +14,16 @@ class Excel(functions: List[ExcelFunctionDef], centralUrl: String): ExcelJsGenerator.generate(functions, centralUrl, selfContained = true) def `functions.json`(): Json = ExcelFunctionsManifest(functions).toJson + + def `functions.html`(): String = + s"""| + | + | + | + | + | + | + | + | + | + |""".stripMargin diff --git a/excel/src/main/scala/jsonschema/excel/ExcelServer.scala b/excel/src/main/scala/jsonschema/excel/ExcelServer.scala new file mode 100644 index 0000000..035a49f --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelServer.scala @@ -0,0 +1,37 @@ +package jsonschema.excel + +import cats.effect.IO +import cats.effect.Resource +import com.comcast.ip4s.* +import org.http4s.HttpRoutes +import org.http4s.MediaType +import org.http4s.dsl.io.* +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.headers.`Content-Type` +import org.http4s.server.Server + +object ExcelServer: + + val defaultPort: Port = port"7777" + + def server(excel: Excel, port: Port = defaultPort): Resource[IO, Server] = + for + js <- Resource.eval(IO.blocking(excel.`functions.js`())) + json <- Resource.eval(IO.pure(excel.`functions.json`().noSpaces)) + html <- Resource.eval(IO.pure(excel.`functions.html`())) + srv <- EmberServerBuilder + .default[IO] + .withHost(host"0.0.0.0") + .withPort(port) + .withHttpApp(routes(js, json, html).orNotFound) + .build + yield srv + + private def routes(js: String, json: String, html: String): HttpRoutes[IO] = + HttpRoutes.of[IO]: + case GET -> Root / "functions.js" => + Ok(js).map(_.withContentType(`Content-Type`(MediaType.unsafeParse("text/javascript")))) + case GET -> Root / "functions.json" => + Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json))) + case GET -> Root / "functions.html" => + Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html))) diff --git a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala index ad5db65..3ef9186 100644 --- a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala +++ b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala @@ -10,10 +10,10 @@ class ExcelFunctionBuilderTest extends FunSuite: test("all required fields → correct types, optional = false"): case class Req(name: String, count: Int, score: Double, flag: Boolean) derives JsonSchema val fn = ExcelFunctionBuilder.from[Req]("REQ", "desc") - assertEquals(fn.parameters.map(_.name), List("name", "count", "score", "flag")) + assertEquals(fn.parameters.map(_.name).toSet, Set("name", "count", "score", "flag")) assertEquals( - fn.parameters.map(_.`type`), - List( + fn.parameters.map(_.`type`).toSet, + Set( ExcelParameterType.StringType, ExcelParameterType.NumberType, ExcelParameterType.NumberType, From 1be692f3fc1e18cdeef4971a5789bf42ea650d28 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 14:22:07 +0200 Subject: [PATCH 10/14] add ExcelMain example server for HTTP smoke testing - add ExcelMain IOApp with Add and Repeat case classes, each with @Description annotations - POST /invoke route dispatches by functionId and executes real logic - ExcelServer.server now accepts extraRoutes for composability - add http4s-circe dep for JSON body decoding Co-Authored-By: Claude Sonnet 4.6 --- build.sbt | 1 + .../scala/jsonschema/excel/ExcelMain.scala | 96 +++++++++++++++++++ .../scala/jsonschema/excel/ExcelServer.scala | 11 ++- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelMain.scala diff --git a/build.sbt b/build.sbt index 80270f2..dc95491 100644 --- a/build.sbt +++ b/build.sbt @@ -65,6 +65,7 @@ lazy val excel = "io.circe" %% "circe-core" % V.circe, "org.http4s" %% "http4s-ember-server" % V.http4s, "org.http4s" %% "http4s-dsl" % V.http4s, + "org.http4s" %% "http4s-circe" % V.http4s, "org.typelevel" %% "cats-effect" % V.catsEffect, "org.scalameta" %% "munit" % V.munit % Test ), diff --git a/excel/src/main/scala/jsonschema/excel/ExcelMain.scala b/excel/src/main/scala/jsonschema/excel/ExcelMain.scala new file mode 100644 index 0000000..9aa7e23 --- /dev/null +++ b/excel/src/main/scala/jsonschema/excel/ExcelMain.scala @@ -0,0 +1,96 @@ +package jsonschema.excel + +import cats.effect.IO +import cats.effect.IOApp +import io.circe.Json +import io.circe.syntax.* +import jsonschema.Description +import jsonschema.JsonSchema +import org.http4s.HttpRoutes +import org.http4s.circe.* +import org.http4s.dsl.io.* + +/** + * Example server for manual HTTP testing. + * + * Start with: sbt "excel/runMain jsonschema.excel.ExcelMain" + * + * Endpoints: + * - GET http://localhost:7777/functions.html — add-in host page + * - GET http://localhost:7777/functions.js — generated JS bundle + * - GET http://localhost:7777/functions.json — functions manifest + * - POST http://localhost:7777/invoke — function dispatcher + * + * Example invoke calls: + * curl -s -X POST http://localhost:7777/invoke \ + * -H 'Content-Type: application/json' \ + * -d '{"functionId":"ADD","params":{"x":3,"y":4}}' + * + * curl -s -X POST http://localhost:7777/invoke \ + * -H 'Content-Type: application/json' \ + * -d '{"functionId":"REPEAT","params":{"text":"hello","times":3}}' + * + * curl -s -X POST http://localhost:7777/invoke \ + * -H 'Content-Type: application/json' \ + * -d '{"functionId":"REPEAT","params":{"text":"hi","times":2,"sep":" | "}}' + */ +object ExcelMain extends IOApp.Simple: + + // ── Function input schemas ───────────────────────────────────────────────── + + case class Add( + @Description("first operand") x: Double, + @Description("second operand") y: Double + ) derives JsonSchema + + case class Repeat( + @Description("text to repeat") text: String, + @Description("number of repetitions") times: Int, + @Description("separator between repetitions (default: space)") sep: Option[String] + ) derives JsonSchema + + // ── Excel instance ───────────────────────────────────────────────────────── + + private val centralUrl = "http://localhost:7777/invoke" + + private val excel = Excel( + functions = List( + ExcelFunctionBuilder.from[Add]("ADD", "Add two numbers"), + ExcelFunctionBuilder.from[Repeat]("REPEAT", "Repeat a string N times") + ), + centralUrl = centralUrl + ) + + // ── Invoke route ─────────────────────────────────────────────────────────── + + private val invokeRoutes: HttpRoutes[IO] = + HttpRoutes.of[IO]: + case req @ POST -> Root / "invoke" => + req.as[Json].flatMap: body => + val c = body.hcursor + val fnId = c.get[String]("functionId").getOrElse("") + val params = c.downField("params") + fnId match + case "ADD" => + (for + x <- params.get[Double]("x") + y <- params.get[Double]("y") + yield Ok((x + y).asJson)) + .fold(e => BadRequest(e.message), identity) + case "REPEAT" => + (for + text <- params.get[String]("text") + times <- params.get[Int]("times") + sep = params.get[String]("sep").toOption.getOrElse(" ") + yield Ok(List.fill(times)(text).mkString(sep).asJson)) + .fold(e => BadRequest(e.message), identity) + case other => + NotFound(s"unknown function: $other") + + // ── Entry point ──────────────────────────────────────────────────────────── + + def run: IO[Unit] = + ExcelServer + .server(excel, extraRoutes = invokeRoutes) + .use: srv => + IO.println(s"Server running at http://${srv.address}") *> IO.never diff --git a/excel/src/main/scala/jsonschema/excel/ExcelServer.scala b/excel/src/main/scala/jsonschema/excel/ExcelServer.scala index 035a49f..059d210 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelServer.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelServer.scala @@ -2,6 +2,7 @@ package jsonschema.excel import cats.effect.IO import cats.effect.Resource +import cats.syntax.semigroupk.* import com.comcast.ip4s.* import org.http4s.HttpRoutes import org.http4s.MediaType @@ -14,7 +15,11 @@ object ExcelServer: val defaultPort: Port = port"7777" - def server(excel: Excel, port: Port = defaultPort): Resource[IO, Server] = + def server( + excel: Excel, + extraRoutes: HttpRoutes[IO] = HttpRoutes.empty[IO], + port: Port = defaultPort + ): Resource[IO, Server] = for js <- Resource.eval(IO.blocking(excel.`functions.js`())) json <- Resource.eval(IO.pure(excel.`functions.json`().noSpaces)) @@ -23,11 +28,11 @@ object ExcelServer: .default[IO] .withHost(host"0.0.0.0") .withPort(port) - .withHttpApp(routes(js, json, html).orNotFound) + .withHttpApp((staticRoutes(js, json, html) <+> extraRoutes).orNotFound) .build yield srv - private def routes(js: String, json: String, html: String): HttpRoutes[IO] = + private def staticRoutes(js: String, json: String, html: String): HttpRoutes[IO] = HttpRoutes.of[IO]: case GET -> Root / "functions.js" => Ok(js).map(_.withContentType(`Content-Type`(MediaType.unsafeParse("text/javascript")))) From c8ce677bf3ccc6feaa7cd56798408f1d14e0cd14 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sat, 4 Apr 2026 14:27:21 +0200 Subject: [PATCH 11/14] extend ExcelMain with two API-backed functions using http4s client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FETCH_POST: calls jsonplaceholder.typicode.com/posts/{id}, returns title - CURRENT_TEMP: calls api.open-meteo.com with lat/lon, returns temperature °C - invokeRoutes now takes Client[IO]; EmberClientBuilder wires it at startup - add http4s-ember-client dependency Co-Authored-By: Claude Sonnet 4.6 --- build.sbt | 1 + .../scala/jsonschema/excel/ExcelMain.scala | 81 +++++++++++++++++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index dc95491..2004b60 100644 --- a/build.sbt +++ b/build.sbt @@ -64,6 +64,7 @@ lazy val excel = libraryDependencies ++= Seq( "io.circe" %% "circe-core" % V.circe, "org.http4s" %% "http4s-ember-server" % V.http4s, + "org.http4s" %% "http4s-ember-client" % V.http4s, "org.http4s" %% "http4s-dsl" % V.http4s, "org.http4s" %% "http4s-circe" % V.http4s, "org.typelevel" %% "cats-effect" % V.catsEffect, diff --git a/excel/src/main/scala/jsonschema/excel/ExcelMain.scala b/excel/src/main/scala/jsonschema/excel/ExcelMain.scala index 9aa7e23..1b2dae3 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelMain.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelMain.scala @@ -7,8 +7,11 @@ import io.circe.syntax.* import jsonschema.Description import jsonschema.JsonSchema import org.http4s.HttpRoutes +import org.http4s.Uri import org.http4s.circe.* +import org.http4s.client.Client import org.http4s.dsl.io.* +import org.http4s.ember.client.EmberClientBuilder /** * Example server for manual HTTP testing. @@ -22,6 +25,8 @@ import org.http4s.dsl.io.* * - POST http://localhost:7777/invoke — function dispatcher * * Example invoke calls: + * + * # pure computation * curl -s -X POST http://localhost:7777/invoke \ * -H 'Content-Type: application/json' \ * -d '{"functionId":"ADD","params":{"x":3,"y":4}}' @@ -30,9 +35,15 @@ import org.http4s.dsl.io.* * -H 'Content-Type: application/json' \ * -d '{"functionId":"REPEAT","params":{"text":"hello","times":3}}' * + * # calls jsonplaceholder.typicode.com + * curl -s -X POST http://localhost:7777/invoke \ + * -H 'Content-Type: application/json' \ + * -d '{"functionId":"FETCH_POST","params":{"id":1}}' + * + * # calls api.open-meteo.com (lat/lon for Paris) * curl -s -X POST http://localhost:7777/invoke \ * -H 'Content-Type: application/json' \ - * -d '{"functionId":"REPEAT","params":{"text":"hi","times":2,"sep":" | "}}' + * -d '{"functionId":"CURRENT_TEMP","params":{"lat":48.85,"lon":2.35}}' */ object ExcelMain extends IOApp.Simple: @@ -49,6 +60,17 @@ object ExcelMain extends IOApp.Simple: @Description("separator between repetitions (default: space)") sep: Option[String] ) derives JsonSchema + /** Fetches a post from https://jsonplaceholder.typicode.com/posts/{id} */ + case class FetchPost( + @Description("post ID between 1 and 100") id: Int + ) derives JsonSchema + + /** Fetches the current air temperature at a location from https://open-meteo.com (no API key). */ + case class CurrentTemp( + @Description("latitude (e.g. 48.85 for Paris, 40.71 for New York)") lat: Double, + @Description("longitude (e.g. 2.35 for Paris, -74.0 for New York)") lon: Double + ) derives JsonSchema + // ── Excel instance ───────────────────────────────────────────────────────── private val centralUrl = "http://localhost:7777/invoke" @@ -56,14 +78,19 @@ object ExcelMain extends IOApp.Simple: private val excel = Excel( functions = List( ExcelFunctionBuilder.from[Add]("ADD", "Add two numbers"), - ExcelFunctionBuilder.from[Repeat]("REPEAT", "Repeat a string N times") + ExcelFunctionBuilder.from[Repeat]("REPEAT", "Repeat a string N times"), + ExcelFunctionBuilder.from[FetchPost]("FETCH_POST", "Fetch the title of a JSONPlaceholder post"), + ExcelFunctionBuilder.from[CurrentTemp]( + "CURRENT_TEMP", + "Current temperature (°C) from Open-Meteo" + ) ), centralUrl = centralUrl ) - // ── Invoke route ─────────────────────────────────────────────────────────── + // ── Invoke routes ────────────────────────────────────────────────────────── - private val invokeRoutes: HttpRoutes[IO] = + private def invokeRoutes(client: Client[IO]): HttpRoutes[IO] = HttpRoutes.of[IO]: case req @ POST -> Root / "invoke" => req.as[Json].flatMap: body => @@ -77,6 +104,7 @@ object ExcelMain extends IOApp.Simple: y <- params.get[Double]("y") yield Ok((x + y).asJson)) .fold(e => BadRequest(e.message), identity) + case "REPEAT" => (for text <- params.get[String]("text") @@ -84,13 +112,50 @@ object ExcelMain extends IOApp.Simple: sep = params.get[String]("sep").toOption.getOrElse(" ") yield Ok(List.fill(times)(text).mkString(sep).asJson)) .fold(e => BadRequest(e.message), identity) + + case "FETCH_POST" => + params + .get[Int]("id") + .fold( + e => BadRequest(e.message), + id => + client + .expect[Json]( + Uri.unsafeFromString(s"https://jsonplaceholder.typicode.com/posts/$id") + ) + .flatMap: json => + val title = json.hcursor.get[String]("title").getOrElse("(no title)") + Ok(title.asJson) + ) + + case "CURRENT_TEMP" => + (for + lat <- params.get[Double]("lat") + lon <- params.get[Double]("lon") + yield + val uri = Uri + .unsafeFromString("https://api.open-meteo.com/v1/forecast") + .withQueryParam("latitude", lat) + .withQueryParam("longitude", lon) + .withQueryParam("current_weather", "true") + client + .expect[Json](uri) + .flatMap: json => + val temp = json.hcursor + .downField("current_weather") + .get[Double]("temperature") + .getOrElse(Double.NaN) + Ok(temp.asJson) + ).fold(e => BadRequest(e.message), identity) + case other => NotFound(s"unknown function: $other") // ── Entry point ──────────────────────────────────────────────────────────── def run: IO[Unit] = - ExcelServer - .server(excel, extraRoutes = invokeRoutes) - .use: srv => - IO.println(s"Server running at http://${srv.address}") *> IO.never + EmberClientBuilder.default[IO].build.use: client => + ExcelServer + .server(excel, extraRoutes = invokeRoutes(client)) + .use: srv => + IO.println(s"Server running at http://${srv.address}") *> IO.never From 2aefde50de9a3f74dd04b316cbfd8f630203ba70 Mon Sep 17 00:00:00 2001 From: Ramy Tanios Date: Sun, 5 Apr 2026 00:35:33 +0200 Subject: [PATCH 12/14] separate excel library from example module, fix manifest - extract HTML templates into ExcelHtml, icon generation into ExcelIcon - slim Excel class down to pure delegation - replace ExcelServer (full ember server) with ExcelRoutes (HttpRoutes only) - add excel-example module with ExcelMain (ember server + invoke routes) and manifest.xml - fix manifest: DesktopFormFactor (singular), AllFormFactors before DesktopFormFactor, bt:Images for icons Co-Authored-By: Claude Sonnet 4.6 --- build.sbt | 26 ++- excel-example/manifest.xml | 137 +++++++++++++++ .../scala/jsonschema/excel/ExcelMain.scala | 51 +++--- .../main/scala/jsonschema/excel/Excel.scala | 28 ++- .../scala/jsonschema/excel/ExcelHtml.scala | 163 ++++++++++++++++++ .../scala/jsonschema/excel/ExcelIcon.scala | 47 +++++ .../scala/jsonschema/excel/ExcelRoutes.scala | 35 ++++ .../scala/jsonschema/excel/ExcelServer.scala | 42 ----- 8 files changed, 434 insertions(+), 95 deletions(-) create mode 100644 excel-example/manifest.xml rename {excel => excel-example}/src/main/scala/jsonschema/excel/ExcelMain.scala (79%) create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelHtml.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelIcon.scala create mode 100644 excel/src/main/scala/jsonschema/excel/ExcelRoutes.scala delete mode 100644 excel/src/main/scala/jsonschema/excel/ExcelServer.scala diff --git a/build.sbt b/build.sbt index 2004b60..a53cf2c 100644 --- a/build.sbt +++ b/build.sbt @@ -31,7 +31,7 @@ lazy val V = new { lazy val root = (project in file(".")) - .aggregate(libJVM, libJS, excel) + .aggregate(libJVM, libJS, excel, excelExample) .settings(publish / skip := true) lazy val lib = crossProject(JSPlatform, JVMPlatform) @@ -60,15 +60,25 @@ lazy val excel = .dependsOn(libJVM) .settings( name := "json-schema-lib-excel", + libraryDependencies ++= Seq( + "io.circe" %% "circe-core" % V.circe, + "org.http4s" %% "http4s-dsl" % V.http4s, + "org.typelevel" %% "cats-effect" % V.catsEffect, + "org.scalameta" %% "munit" % V.munit % Test + ), + scalacOptions -= "-Xfatal-warnings" + ) + +lazy val excelExample = + (project in file("excel-example")) + .dependsOn(excel) + .settings( + name := "json-schema-lib-excel-example", publish / skip := true, libraryDependencies ++= Seq( - "io.circe" %% "circe-core" % V.circe, - "org.http4s" %% "http4s-ember-server" % V.http4s, - "org.http4s" %% "http4s-ember-client" % V.http4s, - "org.http4s" %% "http4s-dsl" % V.http4s, - "org.http4s" %% "http4s-circe" % V.http4s, - "org.typelevel" %% "cats-effect" % V.catsEffect, - "org.scalameta" %% "munit" % V.munit % Test + "org.http4s" %% "http4s-ember-server" % V.http4s, + "org.http4s" %% "http4s-ember-client" % V.http4s, + "org.http4s" %% "http4s-circe" % V.http4s, ), scalacOptions -= "-Xfatal-warnings" ) diff --git a/excel-example/manifest.xml b/excel-example/manifest.xml new file mode 100644 index 0000000..671235b --- /dev/null +++ b/excel-example/manifest.xml @@ -0,0 +1,137 @@ + + + + + + a1b2c3d4-e5f6-7890-abcd-ef1234567891 + 1.0.0.0 + ExcelMain (local dev) + en-US + + + + + + + + + + + + + + + + + + + + + + + ReadWriteDocument + + + + + + + + + + + + + + + + + + + + + + + + + + +