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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ libraryDependencies += "io.github.ramytanios" %% "json-schema-lib" % "<version>"
- **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

Expand Down Expand Up @@ -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": {
Expand Down
36 changes: 33 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ 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 =
(project in file("."))
.aggregate(libJVM, libJS)
.aggregate(libJVM, libJS, excel, excelExample)
.settings(publish / skip := true)

lazy val lib = crossProject(JSPlatform, JVMPlatform)
Expand All @@ -52,3 +54,31 @@ 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",
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(
"org.http4s" %% "http4s-ember-server" % V.http4s,
"org.http4s" %% "http4s-ember-client" % V.http4s,
"org.http4s" %% "http4s-circe" % V.http4s,
),
scalacOptions -= "-Xfatal-warnings"
)
137 changes: 137 additions & 0 deletions excel-example/manifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Minimal Office Add-in manifest for sideloading ExcelMain in Excel.

Prerequisites:
1. Start the server: sbt "excel/runMain jsonschema.excel.ExcelMain"
2. Verify it's up: curl http://localhost:7777/functions.json

Sideloading instructions:
https://learn.microsoft.com/en-us/office/dev/add-ins/testing/test-debug-office-add-ins

Functions exposed (all prefixed EXMAIN. in Excel):
=EXMAIN.ADD(x, y)
=EXMAIN.REPEAT(text, times, [sep])
=EXMAIN.FETCH_POST(id)
=EXMAIN.CURRENT_TEMP(lat, lon)
-->
<OfficeApp
xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides"
xsi:type="TaskPaneApp">

<!-- Unique add-in ID — regenerate if you deploy multiple instances -->
<Id>a1b2c3d4-e5f6-7890-abcd-ef1234567891</Id>
<Version>1.0.0.0</Version>
<ProviderName>ExcelMain (local dev)</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="ExcelMain Tst"/>
<Description DefaultValue="Custom functions served by local ExcelMain server"/>

<IconUrl DefaultValue="http://localhost:7777/icon-32.png"/>
<HighResolutionIconUrl DefaultValue="http://localhost:7777/icon-80.png"/>

<Hosts>
<Host Name="Workbook"/>
</Hosts>

<!-- Minimum requirement: CustomFunctionsRuntime 1.1 -->
<Requirements>
<Sets DefaultMinVersion="1.1">
<Set Name="CustomFunctionsRuntime"/>
</Sets>
</Requirements>

<DefaultSettings>
<!-- Task-pane host page -->
<SourceLocation DefaultValue="http://localhost:7777/taskpane.html"/>
</DefaultSettings>

<Permissions>ReadWriteDocument</Permissions>

<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides"
xsi:type="VersionOverridesV1_0">

<Hosts>
<Host xsi:type="Workbook">

<!-- Custom functions runtime (all form factors) -->
<AllFormFactors>
<ExtensionPoint xsi:type="CustomFunctions">
<Script>
<SourceLocation resid="FunctionsUrl"/>
</Script>
<Page>
<SourceLocation resid="HtmlUrl"/>
</Page>
<Metadata>
<SourceLocation resid="JsonUrl"/>
</Metadata>
<Namespace resid="Namespace"/>
</ExtensionPoint>
</AllFormFactors>

<!-- Ribbon button (desktop only) — note: singular DesktopFormFactor -->
<DesktopFormFactor>
<FunctionFile resid="FunctionsUrl"/>
<ExtensionPoint xsi:type="PrimaryCommandSurface">
<OfficeTab id="TabHome">
<Group id="ExMainGroup">
<Label resid="GroupLabel"/>
<Icon>
<bt:Image size="16" resid="Icon16"/>
<bt:Image size="32" resid="Icon32"/>
<bt:Image size="80" resid="Icon80"/>
</Icon>
<Control xsi:type="Button" id="OpenTaskpane">
<Label resid="TaskpaneButtonLabel"/>
<Supertip>
<Title resid="TaskpaneButtonLabel"/>
<Description resid="TaskpaneButtonDesc"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon16"/>
<bt:Image size="32" resid="Icon32"/>
<bt:Image size="80" resid="Icon80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<TaskpaneId>ExMainTaskpane</TaskpaneId>
<SourceLocation resid="TaskpaneUrl"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>

</Host>
</Hosts>

<Resources>
<bt:Images>
<bt:Image id="Icon16" DefaultValue="http://localhost:7777/icon-16.png"/>
<bt:Image id="Icon32" DefaultValue="http://localhost:7777/icon-32.png"/>
<bt:Image id="Icon80" DefaultValue="http://localhost:7777/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="HtmlUrl" DefaultValue="http://localhost:7777/functions.html"/>
<bt:Url id="FunctionsUrl" DefaultValue="http://localhost:7777/functions.js"/>
<bt:Url id="JsonUrl" DefaultValue="http://localhost:7777/functions.json"/>
<bt:Url id="TaskpaneUrl" DefaultValue="http://localhost:7777/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="Namespace" DefaultValue="EXMAIN"/>
<bt:String id="GroupLabel" DefaultValue="Functions"/>
<bt:String id="TaskpaneButtonLabel" DefaultValue="Function Reference"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButtonDesc"
DefaultValue="Browse and search all available custom functions"/>
</bt:LongStrings>
</Resources>

</VersionOverrides>

</OfficeApp>
157 changes: 157 additions & 0 deletions excel-example/src/main/scala/jsonschema/excel/ExcelMain.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package jsonschema.excel

import cats.data.OptionT
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.Resource
import cats.syntax.semigroupk.*
import com.comcast.ip4s.*
import io.circe.Json
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
import org.http4s.ember.server.EmberServerBuilder

/**
* Example server for manual HTTP testing.
*
* Start with: sbt "excel-example/runMain jsonschema.excel.ExcelMain"
*
* 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}}'
*
* # 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":"CURRENT_TEMP","params":{"lat":48.85,"lon":2.35}}'
*/
object ExcelMain extends IOApp.Simple:

// ── Function input schemas ─────────────────────────────────────────────────

@Description("Add two numbers")
case class Add(
@Description("first operand") x: Double,
@Description("second operand") y: Double
) derives JsonSchema

@Description("Repeat a string N times")
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

@Description("Fetch the title of a JSONPlaceholder post")
case class FetchPost(
@Description("post ID between 1 and 100") id: Int
) derives JsonSchema

@Description("Current temperature (°C) from Open-Meteo")
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 excel = Excel(
functions = List(
ExcelFunctionBuilder.from[Add]("ADD"),
ExcelFunctionBuilder.from[Repeat]("REPEAT"),
ExcelFunctionBuilder.from[FetchPost]("FETCH_POST"),
ExcelFunctionBuilder.from[CurrentTemp]("CURRENT_TEMP")
),
centralUrl = "http://localhost:7777/invoke",
namespace = "EXMAIN"
)

// ── Invoke routes ──────────────────────────────────────────────────────────

private def invokeRoutes(client: Client[IO]): 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 "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 =>
Ok(json.hcursor.get[String]("title").getOrElse("(no 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 =>
Ok(json.hcursor.downField(
"current_weather"
).get[Double]("temperature").getOrElse(Double.NaN).asJson)
).fold(e => BadRequest(e.message), identity)

case other =>
NotFound(s"unknown function: $other")

// ── Entry point ────────────────────────────────────────────────────────────

private def logged(routes: HttpRoutes[IO]): HttpRoutes[IO] =
HttpRoutes[IO](req => OptionT(IO.println(s"${req.method} ${req.uri}") *> routes(req).value))

def run: IO[Unit] =
EmberClientBuilder.default[IO].build.use: client =>
(Resource.eval(ExcelRoutes.routes(excel)).flatMap: addinRoutes =>
EmberServerBuilder
.default[IO]
.withHost(host"0.0.0.0")
.withPort(port"7777")
.withHttpApp((logged(addinRoutes) <+> logged(invokeRoutes(client))).orNotFound)
.build).use: srv =>
IO.println(s"Server running at http://${srv.address}") *> IO.never
26 changes: 26 additions & 0 deletions excel/src/main/scala/jsonschema/excel/Excel.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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 URL used in the generated JavaScript to dispatch function calls.
* @param namespace
* The Excel namespace prefix used in formula syntax (e.g. "EXMAIN" → =EXMAIN.ADD(...)). Should
* match the Namespace resource in the add-in manifest.
*/
class Excel(functions: List[ExcelFunctionDef], centralUrl: String, namespace: String = ""):

def functionsJs(): String = ExcelJsGenerator.generate(functions, centralUrl, true)

def functionsJson(): Json = ExcelFunctionsManifest(functions).toJson

def functionsHtml(): String = ExcelHtml.functionsHtml

def taskpaneHtml(): String = ExcelHtml.taskpaneHtml(namespace)

def iconPng(size: Int): Array[Byte] = ExcelIcon.png(size)
Loading
Loading