├── .mill-version ├── .beads ├── .local_version ├── metadata.json ├── .gitignore ├── config.yaml └── README.md ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── assets └── lechez.png ├── .gitattributes ├── AI ├── src │ ├── resources │ │ └── tweets.png │ └── main │ │ └── scala │ │ └── boogieloops │ │ ├── Examples │ │ └── Config.scala │ │ └── Providers │ │ ├── ProviderFormats.scala │ │ └── Http11Client.scala └── test │ └── src │ └── main │ └── scala │ └── boogieloops │ ├── ProviderSpec.scala │ └── ObjectResponseAPISpec.scala ├── typescript-sdk ├── src │ ├── client │ │ ├── index.ts │ │ ├── client │ │ │ └── index.ts │ │ ├── sdk.gen.ts │ │ ├── client.gen.ts │ │ ├── core │ │ │ ├── auth.ts │ │ │ ├── bodySerializer.ts │ │ │ ├── types.ts │ │ │ ├── params.ts │ │ │ └── pathSerializer.ts │ │ └── types.gen.ts │ ├── test.ts │ └── demo.ts ├── openapi-ts.config.ts └── package.json ├── .codeloops └── overview.md ├── makefiles ├── dev.mk ├── ci.mk ├── common.mk ├── watch.mk ├── util.mk ├── sdk.mk ├── build.mk ├── info.mk ├── test.mk └── examples.mk ├── package.json ├── .xrelease.yml ├── .gitignore ├── Web ├── src │ └── main │ │ └── scala │ │ └── boogieloops │ │ ├── examples │ │ ├── UploadStreamingServer.scala │ │ ├── zerotoapp │ │ │ ├── Models.scala │ │ │ └── Api.scala │ │ ├── OpenAPITest.scala │ │ └── UploadStreamingAPI.scala │ │ ├── openapi │ │ ├── generators │ │ │ ├── TagGenerator.scala │ │ │ ├── SchemaConverter.scala │ │ │ ├── OpenAPIGenerator.scala │ │ │ ├── SecurityGenerator.scala │ │ │ └── ComponentsGenerator.scala │ │ ├── models │ │ │ ├── OpenAPIDocument.scala │ │ │ ├── SecurityObjects.scala │ │ │ ├── ComponentsObject.scala │ │ │ └── PathsObject.scala │ │ └── config │ │ │ └── OpenAPIConfig.scala │ │ └── RouteSchema.scala └── test │ └── src │ └── boogieloops │ └── TestServer.scala ├── .github └── workflows │ ├── ci.yml │ ├── publish-maven.yml │ └── release.yml ├── docs ├── troubleshooting.md ├── concepts.md ├── typescript-sdk.md ├── schema.md ├── ai │ └── metadata-and-scoping.md ├── README.md ├── ai.md ├── web │ └── multipart-and-decorators.md └── zero-to-app.md ├── LICENSE ├── .scalafix.conf ├── .versionrc ├── Makefile ├── Schema ├── src │ └── main │ │ └── scala │ │ └── boogieloops │ │ ├── composition │ │ ├── AllOfSchema.scala │ │ ├── AnyOfSchema.scala │ │ ├── NotSchema.scala │ │ ├── OneOfSchema.scala │ │ └── IfThenElseSchema.scala │ │ ├── references │ │ ├── RefSchema.scala │ │ └── DynamicRefSchema.scala │ │ ├── validation │ │ ├── ValidationContext.scala │ │ └── ValidationResult.scala │ │ ├── primitives │ │ ├── NullSchema.scala │ │ ├── BooleanSchema.scala │ │ ├── NumberSchema.scala │ │ ├── StringSchema.scala │ │ └── EnumSchema.scala │ │ ├── SchemaType.scala │ │ ├── modifiers │ │ ├── IdSchema.scala │ │ ├── SchemaModifier.scala │ │ ├── TitleSchema.scala │ │ ├── DescriptionSchema.scala │ │ ├── ExamplesSchema.scala │ │ └── DefsSchema.scala │ │ ├── derivation │ │ ├── CollectionSchemas.scala │ │ └── SchemaAnnotations.scala │ │ └── examples │ │ ├── DefaultParameterTest.scala │ │ └── BasicUsage.scala └── test │ └── src │ └── boogieloops │ └── primitives │ ├── NullSchemaTests.scala │ └── BooleanSchemaTests.scala ├── .scalafmt.conf ├── AGENTS.md └── README.md /.mill-version: -------------------------------------------------------------------------------- 1 | 1.0.4 2 | -------------------------------------------------------------------------------- /.beads/.local_version: -------------------------------------------------------------------------------- 1 | 0.29.0 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | make format 2 | make build 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; -------------------------------------------------------------------------------- /assets/lechez.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/BoogieLoops/HEAD/assets/lechez.png -------------------------------------------------------------------------------- /.beads/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "beads.db", 3 | "jsonl_export": "issues.jsonl" 4 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | 2 | # Use bd merge for beads JSONL files 3 | .beads/issues.jsonl merge=beads 4 | -------------------------------------------------------------------------------- /AI/src/resources/tweets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/BoogieLoops/HEAD/AI/src/resources/tweets.png -------------------------------------------------------------------------------- /typescript-sdk/src/client/index.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | export * from './types.gen'; 3 | export * from './sdk.gen'; -------------------------------------------------------------------------------- /typescript-sdk/src/test.ts: -------------------------------------------------------------------------------- 1 | import { createClient, createConfig } from './client/client'; 2 | 3 | export const client = createClient(createConfig()); 4 | -------------------------------------------------------------------------------- /typescript-sdk/openapi-ts.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@hey-api/openapi-ts'; 2 | 3 | export default defineConfig({ 4 | input: 'http://0.0.0.0:8082/openapi', 5 | output: 'src/client', 6 | }); 7 | -------------------------------------------------------------------------------- /.codeloops/overview.md: -------------------------------------------------------------------------------- 1 | What is this garbly gook? Its how I do project management for my projects... I write out json files for the given projet at hand, (problem.json, technical.json and tasks.json) and then use those as the source of truth for wrking through a given project. 2 | -------------------------------------------------------------------------------- /makefiles/dev.mk: -------------------------------------------------------------------------------- 1 | ##@ Dev 2 | 3 | .PHONY: assembly 4 | assembly: ## Build assembly jars for all modules 5 | @$(MILL) __.assembly 6 | 7 | .PHONY: docs 8 | docs: ## Generate documentation jars 9 | @$(MILL) __.docJar 10 | 11 | .PHONY: deps 12 | deps: ## Show dependency updates 13 | @$(MILL) mill.scalalib.Dependency/showUpdates 14 | 15 | -------------------------------------------------------------------------------- /makefiles/ci.mk: -------------------------------------------------------------------------------- 1 | ##@ CI 2 | 3 | .PHONY: check 4 | check: build test lint ## Run build, tests, and linting 5 | @echo "✅ All checks passed!" 6 | 7 | .PHONY: ci 8 | ci: clean check ## Clean then run checks 9 | @echo "✅ CI pipeline complete!" 10 | 11 | .PHONY: release 12 | release: check assembly docs ## Prepare release artifacts 13 | @echo "📦 Release artifacts ready!" 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boogieloops", 3 | "version": "0.5.5", 4 | "type": "module", 5 | "scripts": { 6 | "test": "echo \"add your test command here\"", 7 | "prepare": "husky" 8 | }, 9 | "private": true, 10 | "devDependencies": { 11 | "@commitlint/cli": "^19.8.1", 12 | "@commitlint/config-conventional": "^19.8.1", 13 | "husky": "^9.1.7" 14 | } 15 | } -------------------------------------------------------------------------------- /typescript-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boogieloops/ts-sdk", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "openapi-ts": "openapi-ts", 7 | "demo": "tsx src/demo.ts" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "description": "", 12 | "dependencies": { 13 | "@hey-api/openapi-ts": "^0.78.3" 14 | }, 15 | "devDependencies": { 16 | "tsx": "^4.19.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /makefiles/common.mk: -------------------------------------------------------------------------------- 1 | ##@ Core 2 | 3 | # Auto help generator: parses lines with '##' descriptions 4 | .PHONY: help 5 | help: ## Show this help with available targets 6 | @echo "BoogieLoops Project Commands" 7 | @echo "============================" 8 | @awk 'BEGIN {FS = ":.*##"} \ 9 | /^##@/ { printf "\n%s\n", substr($$0, 5); next } \ 10 | /^[a-zA-Z0-9_.\-]+:.*##/ { printf " %-22s %s\n", $$1, $$2 } \ 11 | ' $(MAKEFILE_LIST) 12 | 13 | -------------------------------------------------------------------------------- /makefiles/watch.mk: -------------------------------------------------------------------------------- 1 | ##@ Watch 2 | 3 | .PHONY: watch w 4 | watch: ## Watch compile (override MODULE=...) 5 | @echo "👀 Watching for changes... (Ctrl+C to stop)" 6 | @$(MILL) -w $(MODULE).compile 7 | w: watch ## Alias for watch 8 | 9 | .PHONY: watch-test wt 10 | watch-test: ## Watch tests (override MODULE=...) 11 | @echo "👀 Running tests on change... (Ctrl+C to stop)" 12 | @$(MILL) -w $(MODULE).test 13 | wt: watch-test ## Alias for watch-test 14 | 15 | -------------------------------------------------------------------------------- /.xrelease.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | release: 3 | # Branch configuration 4 | branch: main 5 | 6 | # Version bump type 7 | defaultBump: patch 8 | 9 | # Changelog configuration 10 | changelog: 11 | enabled: true 12 | template: conventional 13 | 14 | # Release actions actions 15 | actions: 16 | - type: custom 17 | name: "assembly" 18 | command: "make assembly" 19 | - type: commit-push 20 | - type: git-tag 21 | - type: github-release 22 | -------------------------------------------------------------------------------- /makefiles/util.mk: -------------------------------------------------------------------------------- 1 | ##@ Utils 2 | 3 | .PHONY: tree 4 | tree: ## Show trimmed project tree 5 | @tree -I 'out|.git|.metals|.bloop|.bsp|target|node_modules' -L 2 6 | 7 | .PHONY: loc 8 | loc: ## Count Scala lines of code 9 | @find . -name "*.scala" -not -path "./out/*" | xargs wc -l | tail -1 10 | 11 | .PHONY: clean-all purge 12 | clean-all: ## Deep clean (mill + IDE/build caches) 13 | @$(MILL) clean 14 | @rm -rf out/ .bloop .bsp .metals target 15 | @echo "✅ Deep clean complete!" 16 | purge: clean-all ## Alias for clean-all 17 | 18 | -------------------------------------------------------------------------------- /.beads/.gitignore: -------------------------------------------------------------------------------- 1 | # SQLite databases 2 | *.db 3 | *.db?* 4 | *.db-journal 5 | *.db-wal 6 | *.db-shm 7 | 8 | # Daemon runtime files 9 | daemon.lock 10 | daemon.log 11 | daemon.pid 12 | bd.sock 13 | 14 | # Legacy database files 15 | db.sqlite 16 | bd.db 17 | 18 | # Merge artifacts (temporary files from 3-way merge) 19 | beads.base.jsonl 20 | beads.base.meta.json 21 | beads.left.jsonl 22 | beads.left.meta.json 23 | beads.right.jsonl 24 | beads.right.meta.json 25 | 26 | # Keep JSONL exports and config (source of truth for git) 27 | !issues.jsonl 28 | !metadata.json 29 | !config.json 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 5 | hs_err_pid* 6 | .DS_Store 7 | *.class 8 | *.log 9 | .bloop 10 | 11 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 12 | hs_err_pid* 13 | # Mill build tool 14 | out/ 15 | .bsp/ 16 | .idea/ 17 | .idea_modules/ 18 | 19 | bucket/ 20 | work/ 21 | 22 | .metals 23 | .vscode 24 | 25 | .env 26 | .env.test 27 | .env.production 28 | .env.* 29 | codeloops.config.json 30 | .voltagent 31 | .scala-build 32 | .privatenode_modules/ 33 | node_modules/ 34 | .private 35 | 36 | planning_docs/old 37 | private 38 | -------------------------------------------------------------------------------- /makefiles/sdk.mk: -------------------------------------------------------------------------------- 1 | ##@ SDK 2 | 3 | .PHONY: sdk-install 4 | sdk-install: ## Install TypeScript SDK dependencies 5 | @cd typescript-sdk && npm ci 6 | 7 | .PHONY: sdk-clean 8 | sdk-clean: ## Remove generated SDK client 9 | @rm -rf typescript-sdk/src/client 10 | 11 | .PHONY: sdk-generate 12 | sdk-generate: ## Generate TS client from running API (requires example-web-api) 13 | @cd typescript-sdk && npm run openapi-ts 14 | 15 | .PHONY: sdk-refresh 16 | sdk-refresh: sdk-clean sdk-generate ## Clean and regenerate the TS client 17 | 18 | .PHONY: sdk-demo 19 | sdk-demo: ## Run a small TS demo against the API using the generated client 20 | @cd typescript-sdk && npm run demo 21 | 22 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/client/index.ts: -------------------------------------------------------------------------------- 1 | export type { Auth } from '../core/auth'; 2 | export type { QuerySerializerOptions } from '../core/bodySerializer'; 3 | export { 4 | formDataBodySerializer, 5 | jsonBodySerializer, 6 | urlSearchParamsBodySerializer, 7 | } from '../core/bodySerializer'; 8 | export { buildClientParams } from '../core/params'; 9 | export { createClient } from './client'; 10 | export type { 11 | Client, 12 | ClientOptions, 13 | Config, 14 | CreateClientConfig, 15 | Options, 16 | OptionsLegacyParser, 17 | RequestOptions, 18 | RequestResult, 19 | ResponseStyle, 20 | TDataShape, 21 | } from './types'; 22 | export { createConfig, mergeHeaders } from './utils'; 23 | -------------------------------------------------------------------------------- /makefiles/build.mk: -------------------------------------------------------------------------------- 1 | ##@ Build 2 | 3 | .PHONY: build b 4 | build: ## Compile modules (override MODULE=Schema|Web|AI) 5 | @$(MILL) $(MODULE).compile 6 | b: build ## Alias for build 7 | 8 | .PHONY: clean 9 | clean: ## Clean build artifacts 10 | @$(MILL) clean 11 | @rm -rf out/ 12 | 13 | .PHONY: format fmt 14 | format: ## Format code with scalafmt 15 | @$(MILL) mill.scalalib.scalafmt.ScalafmtModule/reformatAll 16 | fmt: format ## Alias for format 17 | 18 | .PHONY: format-check 19 | format-check: ## Check scalafmt without rewriting 20 | @$(MILL) mill.scalalib.scalafmt.ScalafmtModule/checkFormatAll 21 | 22 | .PHONY: lint fix 23 | lint: ## Run scalafix (lint/fixes) 24 | @$(MILL) __.fix 25 | fix: lint ## Alias for lint 26 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/examples/UploadStreamingServer.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.examples 2 | 3 | import io.undertow.Undertow 4 | 5 | object UploadStreamingServer { 6 | def main(args: Array[String]): Unit = { 7 | val port = sys.env.get("PORT").flatMap(_.toIntOption).getOrElse(8080) 8 | val routes = new UploadStreamingAPI() 9 | 10 | val server = Undertow.builder 11 | .addHttpListener(port, "0.0.0.0") 12 | .setHandler(routes.defaultHandler) 13 | .build 14 | 15 | server.start() 16 | println(s"UploadStreamingAPI started on http://localhost:$port") 17 | println( 18 | "Endpoints:\n POST /demo/upload (multipart)\n GET /demo/stream/:size (stream)\n GET /demo/decorated (decorators)" 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/sdk.gen.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | 3 | import type { Options as ClientOptions, TDataShape, Client } from './client'; 4 | 5 | export type Options = ClientOptions & { 6 | /** 7 | * You can provide a client instance returned by `createClient()` instead of 8 | * individual options. This might be also useful if you want to implement a 9 | * custom client. 10 | */ 11 | client?: Client; 12 | /** 13 | * You can pass arbitrary values through the `meta` object. This can be 14 | * used to access values that aren't defined as part of the SDK function. 15 | */ 16 | meta?: Record; 17 | }; -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Java 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: "23" 19 | distribution: "temurin" 20 | 21 | - name: Install Coursier 22 | run: | 23 | curl -fLo cs https://git.io/coursier-cli-linux 24 | chmod +x cs 25 | sudo mv cs /usr/local/bin/cs 26 | 27 | - name: Build 28 | run: make build 29 | 30 | # skip for now, will revisit this 31 | # - name: Lint for smells 32 | # run: make lint 33 | 34 | - name: Run tests 35 | run: make test 36 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Build / Env 4 | 5 | - Mill not executable: `chmod +x mill` 6 | - Wrong Java: use JDK 17+ (`java -version`) 7 | - Slow import (IDE): run `./mill -w __.compile` to warm caches 8 | 9 | ## boogieloops.web 10 | 11 | - 400 errors: check `Content-Type: application/json` and request body JSON 12 | - Port conflict: default example uses `8082` (override `port` or stop other process) 13 | - Status codes: GET/PUT/PATCH/DELETE status chosen from first success response in `responses` 14 | 15 | ## BoogieLoops AI 16 | 17 | - Missing keys: set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` or use a local OpenAI‑compatible endpoint 18 | - Local LLM: verify endpoint (`http://localhost:1234/v1` etc.) and model ID; disable strict validation if needed 19 | - Structured output failures: tighten schema or lower temperature 20 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | ## Optional vs Nullable 4 | 5 | - Optional: `Option[T]` → field may be absent 6 | - Nullable: annotate a present field as nullable (e.g., `@Schema.nullable(true)`) 7 | 8 | ## Unions and OneOf 9 | 10 | - Scala union types (e.g., `A | B`) map to JSON Schema `oneOf` 11 | 12 | ## Validation Philosophy 13 | 14 | - Encode constraints alongside types using `@Schema.*` 15 | - Validate at boundaries (HTTP body/query/headers/params, or raw JSON) 16 | - Return validation results; avoid exceptions in normal flow 17 | 18 | ## Annotations Cheatsheet 19 | 20 | - Strings: `minLength`, `maxLength`, `pattern`, `format` 21 | - Numbers: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` 22 | - Arrays: `minItems`, `maxItems`, `uniqueItems` 23 | - Metadata: `title`, `description`, `examples`, `deprecated`, `readOnly`, `writeOnly` 24 | - Defaults: `default(value)` (type‑safe) 25 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/client.gen.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | 3 | import type { ClientOptions } from './types.gen'; 4 | import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client'; 5 | 6 | /** 7 | * The `createClientConfig()` function will be called on client initialization 8 | * and the returned object will become the client's initial configuration. 9 | * 10 | * You may want to initialize your client this way instead of calling 11 | * `setConfig()`. This is useful for example if you're using Next.js 12 | * to ensure your client always has the correct values. 13 | */ 14 | export type CreateClientConfig = (override?: Config) => Config & T>; 15 | 16 | export const client = createClient(createConfig({ 17 | baseUrl: 'http://0.0.0.0:8082' 18 | })); -------------------------------------------------------------------------------- /makefiles/info.mk: -------------------------------------------------------------------------------- 1 | ##@ Info 2 | 3 | .PHONY: info 4 | info: ## Show project info 5 | @echo "BoogieLoops Project" 6 | @echo "===================" 7 | @echo "Scala: 3.6.2+" 8 | @echo "Build: Mill" 9 | @echo "Test: utest" 10 | @echo "Modules: Schema (core), Web (HTTP), AI (LLM)" 11 | @echo "" 12 | @echo "Run 'make help' for available commands" 13 | 14 | .PHONY: version 15 | version: ## Show project version (from build) 16 | @printf "Project version: %s\n" "$$($(MILL) show Schema.publishVersion | sed -n 's/.*"\(.*\)".*/\1/p' | tail -n1)" 17 | 18 | .PHONY: mill-version 19 | mill-version: ## Show Mill version 20 | @$(MILL) version 21 | 22 | .PHONY: versions 23 | versions: ## Show project, Scala, and Mill versions 24 | @printf "Project: %s\n" "$$($(MILL) show Schema.publishVersion | sed -n 's/.*"\(.*\)".*/\1/p' | tail -n1)" 25 | @printf "Scala: %s\n" "$$($(MILL) show Schema.scalaVersion | sed -n 's/.*"\(.*\)".*/\1/p' | tail -n1)" 26 | @printf "Mill: %s\n" "$$($(MILL) version | tail -n1)" 27 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/core/auth.ts: -------------------------------------------------------------------------------- 1 | export type AuthToken = string | undefined; 2 | 3 | export interface Auth { 4 | /** 5 | * Which part of the request do we use to send the auth? 6 | * 7 | * @default 'header' 8 | */ 9 | in?: 'header' | 'query' | 'cookie'; 10 | /** 11 | * Header or query parameter name. 12 | * 13 | * @default 'Authorization' 14 | */ 15 | name?: string; 16 | scheme?: 'basic' | 'bearer'; 17 | type: 'apiKey' | 'http'; 18 | } 19 | 20 | export const getAuthToken = async ( 21 | auth: Auth, 22 | callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, 23 | ): Promise => { 24 | const token = 25 | typeof callback === 'function' ? await callback(auth) : callback; 26 | 27 | if (!token) { 28 | return; 29 | } 30 | 31 | if (auth.scheme === 'bearer') { 32 | return `Bearer ${token}`; 33 | } 34 | 35 | if (auth.scheme === 'basic') { 36 | return `Basic ${btoa(token)}`; 37 | } 38 | 39 | return token; 40 | }; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SILVABYTE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | scalaVersion = "3.7.2" 2 | 3 | rules = [ 4 | OrganizeImports, 5 | RemoveUnused, 6 | DisableSyntax 7 | ] 8 | 9 | // OrganizeImports: Scala 3–friendly import grooming 10 | OrganizeImports { 11 | // Let the rule rely on SemanticDB and honor Scala 3 specifics 12 | removeUnused = true 13 | groupedImports = Merge // Merge from the same package into one line if possible 14 | importSelectorsOrder = Ascii // Deterministic selector ordering 15 | groups = [ 16 | "re:javax?\\." // javax/java first (if you like this style) 17 | "scala." 18 | "re:scala\\." 19 | "re:java\\." 20 | "com.silvabyte" // your org/package next 21 | "*" // everything else 22 | ] 23 | } 24 | 25 | // Remove unused imports/vals/privates reliably in Scala 3 26 | RemoveUnused { 27 | imports = true 28 | privates = true 29 | locals = true 30 | explicitWildcards = true 31 | } 32 | 33 | // Tasteful guardrails (works as syntactic rules in Scala 3) 34 | DisableSyntax { 35 | noVars = true 36 | noNulls = true 37 | noThrows = true 38 | // add others you care about: noWhileLoops, noXml, etc. 39 | } 40 | -------------------------------------------------------------------------------- /AI/test/src/main/scala/boogieloops/ProviderSpec.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.ai.test 2 | 3 | import utest.* 4 | import boogieloops.ai.providers.* 5 | 6 | object ProviderSpec extends TestSuite: 7 | 8 | val tests = Tests { 9 | 10 | test("OpenAI provider configuration") { 11 | val provider = new OpenAIProvider("test-api-key") 12 | 13 | assert(provider.name == "OpenAI") 14 | assert(provider.supportedModels.contains("gpt-4o")) 15 | assert(provider.supportedModels.contains("gpt-4o-mini")) 16 | assert(provider.supportedModels.contains("gpt-3.5-turbo")) 17 | assert(provider.validateModel("gpt-4o").isRight) 18 | assert(provider.validateModel("unsupported-model").isLeft) 19 | } 20 | 21 | test("Anthropic provider configuration") { 22 | val provider = new AnthropicProvider("test-api-key") 23 | 24 | assert(provider.name == "Anthropic") 25 | assert(provider.supportedModels.contains("claude-3-5-sonnet-20241022")) 26 | assert(provider.supportedModels.contains("claude-3-5-haiku-20241022")) 27 | assert(provider.validateModel("claude-3-5-haiku-20241022").isRight) 28 | assert(provider.validateModel("unsupported-model").isLeft) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "Bug Fixes" 10 | }, 11 | { 12 | "type": "chore", 13 | "hidden": true 14 | }, 15 | { 16 | "type": "docs", 17 | "section": "Documentation" 18 | }, 19 | { 20 | "type": "style", 21 | "hidden": true 22 | }, 23 | { 24 | "type": "refactor", 25 | "section": "Code Refactoring" 26 | }, 27 | { 28 | "type": "perf", 29 | "section": "Performance Improvements" 30 | }, 31 | { 32 | "type": "test", 33 | "hidden": true 34 | } 35 | ], 36 | "commitUrlFormat": "{{host}}/{{owner}}/{{repository}}/commit/{{hash}}", 37 | "compareUrlFormat": "{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}", 38 | "issueUrlFormat": "{{host}}/{{owner}}/{{repository}}/issues/{{id}}", 39 | "userUrlFormat": "{{host}}/{{user}}", 40 | "releaseCommitMessageFormat": "chore(release): {{currentTag}}" 41 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # BoogieLoops Project Makefile (modular) 3 | # 4 | # This top-level Makefile delegates real logic to files in makefiles/. 5 | # It provides a clean help output and variables you can override. 6 | ################################################################################ 7 | 8 | .DEFAULT_GOAL := help 9 | SHELL := /bin/bash 10 | .SHELLFLAGS := -eu -o pipefail -c 11 | 12 | # Core variables (override via `make VAR=...`) 13 | # Path to mill launcher 14 | MILL ?= ./mill 15 | # Target module: one of `__`, `Schema`, `Web`, `AI` 16 | MODULE ?= __ 17 | # Optional test suite/class to run (e.g., boogieloops.schema.primitives.StringSchemaTests) 18 | SUITE ?= 19 | # Set to 1 to run in watch mode where supported 20 | WATCH ?= 0 21 | 22 | # Include modular makefiles 23 | MAKEFILES_DIR := makefiles 24 | -include $(MAKEFILES_DIR)/common.mk 25 | -include $(MAKEFILES_DIR)/build.mk 26 | -include $(MAKEFILES_DIR)/test.mk 27 | -include $(MAKEFILES_DIR)/watch.mk 28 | -include $(MAKEFILES_DIR)/examples.mk 29 | -include $(MAKEFILES_DIR)/dev.mk 30 | -include $(MAKEFILES_DIR)/ci.mk 31 | -include $(MAKEFILES_DIR)/info.mk 32 | -include $(MAKEFILES_DIR)/util.mk 33 | -include $(MAKEFILES_DIR)/sdk.mk 34 | -------------------------------------------------------------------------------- /.github/workflows/publish-maven.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Maven Central 2 | 3 | on: 4 | # Manual trigger for testing 5 | workflow_dispatch: 6 | # Automatically publish when a release is created or published 7 | release: 8 | types: [created, published] 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Java 19 | uses: actions/setup-java@v4 20 | with: 21 | java-version: "23" 22 | distribution: "temurin" 23 | 24 | - name: Install Coursier 25 | run: | 26 | curl -fLo cs https://git.io/coursier-cli-linux 27 | chmod +x cs 28 | sudo mv cs /usr/local/bin/cs 29 | 30 | - name: Import GPG key 31 | run: | 32 | echo "${{ secrets.MAVEN_GPG_SECRET_BASE64 }}" | base64 -d | gpg --batch --import 33 | # List keys to verify import 34 | gpg --list-secret-keys 35 | 36 | - name: Publish to Maven Central 37 | run: ./mill mill.javalib.SonatypeCentralPublishModule/ 38 | env: 39 | MILL_PGP_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} 40 | MILL_PGP_SECRET_BASE64: ${{ secrets.MAVEN_GPG_SECRET_BASE64 }} 41 | MILL_SONATYPE_PASSWORD: ${{ secrets.MAVEN_REPO_SECRET }} 42 | MILL_SONATYPE_USERNAME: ${{ vars.MAVEN_REPO_USERNAME }} -------------------------------------------------------------------------------- /makefiles/test.mk: -------------------------------------------------------------------------------- 1 | ##@ Test 2 | 3 | .PHONY: test t 4 | test: ## Run tests (override MODULE=Schema|Web|AI, SUITE=) 5 | @if [ -n "$(strip $(SUITE))" ]; then \ 6 | echo "Running $(MODULE).test $(SUITE)"; \ 7 | $(MILL) $(MODULE).test $(SUITE); \ 8 | else \ 9 | $(MILL) $(MODULE).test; \ 10 | fi 11 | t: test ## Alias for test 12 | 13 | # Pattern: make test-Schema or test-Web or test-AI 14 | .PHONY: test-% 15 | test-%: ## Run tests for a specific module (e.g., test-Schema) 16 | @$(MILL) $*.test 17 | 18 | # Convenience aliases 19 | .PHONY: ts tw ta 20 | ts: ## Run Schema tests 21 | @$(MILL) Schema.test 22 | tw: ## Run Web tests 23 | @$(MILL) Web.test 24 | ta: ## Run AI tests 25 | @$(MILL) AI.test 26 | 27 | # README-friendly aliases 28 | .PHONY: test-schema test-web test-ai 29 | test-schema: ## Alias: Run Schema tests 30 | @$(MILL) Schema.test 31 | test-web: ## Alias: Run Web tests 32 | @$(MILL) Web.test 33 | test-ai: ## Alias: Run AI tests 34 | @$(MILL) AI.test 35 | 36 | # Specific test suite groups 37 | .PHONY: tp td tv ti 38 | tp: ## Run Schema primitives tests 39 | @$(MILL) Schema.test boogieloops.schema.primitives 40 | td: ## Run Schema derivation tests 41 | @$(MILL) Schema.test boogieloops.schema.derivation 42 | tv: ## Run Schema validation tests 43 | @$(MILL) Schema.test boogieloops.schema.validation 44 | ti: ## Run Web integration tests 45 | @$(MILL) Web.test boogieloops.web.UserCrudAPITest 46 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/composition/AllOfSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.composition 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * AllOf composition schema - validates if the instance is valid against all of the schemas 8 | */ 9 | case class AllOfSchema( 10 | schemas: List[Schema] 11 | ) extends Schema { 12 | 13 | override def toJsonSchema: ujson.Value = { 14 | val schema = ujson.Obj() 15 | 16 | schema("allOf") = ujson.Arr(schemas.map(_.toJsonSchema)*) 17 | 18 | title.foreach(t => schema("title") = ujson.Str(t)) 19 | description.foreach(d => schema("description") = ujson.Str(d)) 20 | default.foreach(d => schema("default") = d) 21 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 22 | 23 | schema 24 | } 25 | 26 | /** 27 | * Validate a ujson.Value against this allOf schema 28 | */ 29 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 30 | // For allOf, all schemas must validate successfully 31 | val allErrors = schemas.flatMap { schema => 32 | val result = schema.validate(value, context) 33 | if (!result.isValid) result.errors else Nil 34 | } 35 | 36 | if (allErrors.isEmpty) { 37 | ValidationResult.valid() 38 | } else { 39 | ValidationResult.invalid(allErrors) 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/composition/AnyOfSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.composition 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * AnyOf composition schema - validates if the instance is valid against any of the schemas 8 | */ 9 | case class AnyOfSchema( 10 | schemas: List[Schema] 11 | ) extends Schema { 12 | 13 | override def toJsonSchema: ujson.Value = { 14 | val schema = ujson.Obj() 15 | 16 | schema("anyOf") = ujson.Arr(schemas.map(_.toJsonSchema)*) 17 | 18 | title.foreach(t => schema("title") = ujson.Str(t)) 19 | description.foreach(d => schema("description") = ujson.Str(d)) 20 | default.foreach(d => schema("default") = d) 21 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 22 | 23 | schema 24 | } 25 | 26 | /** 27 | * Validate a ujson.Value against this anyOf schema 28 | */ 29 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 30 | // For anyOf, at least one schema must validate successfully 31 | val results = schemas.map(_.validate(value, context)) 32 | val hasSuccess = results.exists(_.isValid) 33 | 34 | if (hasSuccess) { 35 | ValidationResult.valid() 36 | } else { 37 | // If all schemas fail, return all collected errors 38 | val allErrors = results.flatMap(_.errors) 39 | ValidationResult.invalid(allErrors) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docs/typescript-sdk.md: -------------------------------------------------------------------------------- 1 | # TypeScript SDK Generation 2 | 3 | Generate a TypeScript client from the running BoogieLoops Web example API using @hey-api/openapi-ts, then run a quick demo. 4 | 5 | ## Prerequisites 6 | 7 | - Node.js and npm installed 8 | - The web example API running (see below) 9 | 10 | ## 1) Start the API (terminal A) 11 | 12 | ```bash 13 | make example-web-api # starts UserCrudAPI on http://localhost:8082 14 | ``` 15 | 16 | The OpenAPI spec is served at `http://localhost:8082/openapi`. 17 | 18 | ## 2) Generate the SDK (terminal B) 19 | 20 | ```bash 21 | # Install toolchain for the TS SDK folder 22 | make sdk-install 23 | 24 | # Generate the client into typescript-sdk/src/client 25 | make sdk-generate 26 | ``` 27 | 28 | Config: `typescript-sdk/openapi-ts.config.ts` points to `http://0.0.0.0:8082/openapi` and outputs to `src/client/`. 29 | 30 | ## 3) Try the demo (optional) 31 | 32 | ```bash 33 | # By default uses BASE_URL=http://0.0.0.0:8082 34 | make sdk-demo 35 | 36 | # Or override the API base URL 37 | BASE_URL=http://localhost:8082 make sdk-demo 38 | ``` 39 | 40 | The demo creates a user, lists users, fetches one by id, updates it, then deletes it. 41 | 42 | ## Troubleshooting 43 | 44 | - 404 on generation: ensure the server is running and OpenAPI is available at `/openapi`. 45 | - Port mismatch: if you changed ports, update `typescript-sdk/openapi-ts.config.ts` or set `BASE_URL` for `sdk-demo`. 46 | - Node/npm issues: run `make sdk-install` to ensure dependencies are present. 47 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.8.5" # keep the formatter itself current 2 | 3 | ## Language 4 | runner.dialect = scala3 # 3-syntax everywhere unless overridden 5 | 6 | ## Alignment – minimal on purpose 7 | align { 8 | preset = none # no vertical “tower” alignment → cleaner diffs 9 | openParenCallSite = false 10 | stripMargin = true 11 | } 12 | assumeStandardLibraryStripMargin = true 13 | 14 | rewrite.insertBraces.minLines = 2 15 | rewrite.insertBraces.allBlocks = true 16 | 17 | ## Line length 18 | maxColumn = 120 # give it more room so `) derives ...` fits 19 | danglingParentheses { 20 | callSite = true # avoid aggressive line breaks around parens 21 | defnSite = true 22 | } 23 | 24 | ## Indentation 25 | continuationIndent { 26 | callSite = 2 # method-call chains 27 | defnSite = 4 # defs/param lists 28 | } 29 | 30 | ## Newlines & spacing 31 | newlines.source = keep 32 | 33 | ## Scaladoc / KDoc 34 | docstrings { 35 | style = Asterisk # /** ... */ 36 | oneline = keep 37 | wrap = no # manual wrapping > auto 38 | } 39 | 40 | ## Repo hygiene 41 | project { 42 | git = true # obey .gitignore 43 | excludePaths = [ 44 | "glob:**/target/**", 45 | "glob:**/out/**", 46 | "glob:**/node_modules/**", 47 | "glob:**/generated/**", 48 | "glob:**/*.generated.scala" 49 | ] 50 | } 51 | 52 | ## File-specific tweaks (example) 53 | fileOverride { 54 | "glob:**/build.sc" { docstrings.style = keep } # leave Mill scripts alone 55 | } 56 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/composition/NotSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.composition 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Not composition schema - validates if the instance is NOT valid against the schema 8 | */ 9 | case class NotSchema( 10 | schema: Schema 11 | ) extends Schema { 12 | 13 | override def toJsonSchema: ujson.Value = { 14 | val schemaObj = ujson.Obj() 15 | 16 | schemaObj("not") = schema.toJsonSchema 17 | 18 | title.foreach(t => schemaObj("title") = ujson.Str(t)) 19 | description.foreach(d => schemaObj("description") = ujson.Str(d)) 20 | default.foreach(d => schemaObj("default") = d) 21 | examples.foreach(e => schemaObj("examples") = ujson.Arr(e*)) 22 | 23 | schemaObj 24 | } 25 | 26 | /** 27 | * Validate a ujson.Value against this not schema 28 | */ 29 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 30 | // For not, the schema must NOT validate successfully 31 | val result = schema.validate(value, context) 32 | 33 | if (result.isValid) { 34 | // Schema validated successfully, so NOT validation fails 35 | val error = 36 | boogieloops.schema.ValidationError.CompositionError("Value must NOT match the schema", context.path) 37 | ValidationResult.invalid(error) 38 | } else { 39 | // Schema validation failed, so NOT validation succeeds 40 | ValidationResult.valid() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/references/RefSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.references 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * JSON Schema $ref keyword implementation 8 | * 9 | * Represents a reference to another schema definition. According to JSON Schema 2020-12, $ref is a URI reference that identifies 10 | * a schema to be applied to the instance. When $ref is present, other keywords are typically ignored in that schema location. 11 | */ 12 | //TODO: consolidate with modifiers.DefsSchema 13 | case class RefSchema( 14 | ref: String 15 | ) extends Schema { 16 | 17 | override def $ref: Option[String] = Some(ref) 18 | 19 | override def toJsonSchema: ujson.Value = { 20 | val schema = ujson.Obj("$ref" -> ujson.Str(ref)) 21 | 22 | // Add meta-data keywords if present 23 | title.foreach(t => schema("title") = ujson.Str(t)) 24 | description.foreach(d => schema("description") = ujson.Str(d)) 25 | 26 | schema 27 | } 28 | 29 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 30 | // In a complete implementation, this would: 31 | // 1. Resolve the reference URI against the base URI 32 | // 2. Retrieve the target schema 33 | // 3. Validate the value against the target schema 34 | // 35 | // For now, we return an error indicating unresolved reference 36 | ValidationResult.invalid(List(boogieloops.schema.ValidationError.ReferenceError(ref, context.path))) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/validation/ValidationContext.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.validation 2 | 3 | import boogieloops.schema.Schema 4 | 5 | /** 6 | * Context for validation operations that tracks the current JSON path and schema context 7 | * 8 | * This enables proper error reporting with accurate JSON paths during recursive validation. 9 | * The path follows JSONPath notation (e.g., "/user/name", "/items/0", "/properties/address/street"). 10 | */ 11 | case class ValidationContext( 12 | path: String = "/", 13 | rootSchema: Option[Schema] = None 14 | ) { 15 | 16 | /** 17 | * Create a new context with an extended path for property access 18 | */ 19 | def withProperty(propertyName: String): ValidationContext = { 20 | val newPath = if (path == "/") s"/$propertyName" else s"$path/$propertyName" 21 | copy(path = newPath) 22 | } 23 | 24 | /** 25 | * Create a new context with an extended path for array index access 26 | */ 27 | def withIndex(index: Int): ValidationContext = { 28 | val newPath = if (path == "/") s"/$index" else s"$path/$index" 29 | copy(path = newPath) 30 | } 31 | 32 | /** 33 | * Create a new context with a custom path segment 34 | */ 35 | def withPath(segment: String): ValidationContext = { 36 | val newPath = if (path == "/") s"/$segment" else s"$path/$segment" 37 | copy(path = newPath) 38 | } 39 | 40 | /** 41 | * Create a new context with the root schema set 42 | */ 43 | def withRootSchema(schema: Schema): ValidationContext = { 44 | copy(rootSchema = Some(schema)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/examples/zerotoapp/Models.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.examples.zerotoapp 2 | 3 | import boogieloops.schema.derivation.Schematic 4 | import upickle.default.* 5 | 6 | /** 7 | * Zero-to-App: Models stub 8 | * 9 | * Follow docs/zero-to-app.md Step 1 to refine these models. 10 | * You can start with these defaults and iterate progressively. 11 | */ 12 | @Schematic.title("CreateUser") 13 | case class CreateUser( 14 | // TODO: Add/adjust annotations per docs (minLength, format, minimum) 15 | name: String, 16 | email: String, 17 | age: Int 18 | ) derives Schematic, ReadWriter 19 | 20 | @Schematic.title("User") 21 | case class User( 22 | id: String, 23 | name: String, 24 | email: String, 25 | age: Int 26 | ) derives Schematic, ReadWriter 27 | 28 | @Schematic.title("ProfileSummary") 29 | case class ProfileSummary( 30 | // Free-form profile text to infer interests from 31 | @Schematic.minLength(5) text: String 32 | ) derives Schematic, ReadWriter 33 | 34 | @Schematic.title("UserInterests") 35 | case class UserInterests( 36 | @Schematic.description("Primary, strong interests") 37 | @Schematic.minItems(0) 38 | primary: List[String] = Nil, 39 | @Schematic.description("Secondary interests") 40 | @Schematic.minItems(0) 41 | secondary: List[String] = Nil, 42 | @Schematic.description("Normalized tags for indexing/search") 43 | @Schematic.minItems(0) 44 | tags: List[String] = Nil, 45 | @Schematic.description("Optional notes or rationale") 46 | notes: Option[String] = None 47 | ) derives Schematic, ReadWriter 48 | -------------------------------------------------------------------------------- /Schema/test/src/boogieloops/primitives/NullSchemaTests.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.primitives 2 | 3 | import utest.* 4 | import boogieloops.schema.primitives.* 5 | import boogieloops.schema.validation.ValidationContext 6 | import boogieloops.schema.* 7 | 8 | object NullSchemaTests extends TestSuite { 9 | 10 | val tests = Tests { 11 | test("basic null schema") { 12 | val schema = NullSchema() 13 | val result = schema.validate(ujson.Null, ValidationContext()) 14 | assert(result.isValid) 15 | } 16 | 17 | test("json schema generation") { 18 | val schema = NullSchema() 19 | val json = schema.toJsonSchema 20 | assert(json("type").str == "null") 21 | } 22 | 23 | test("schema with metadata") { 24 | val schema = NullSchema() 25 | .withTitle("Null Value") 26 | .withDescription("A null value schema") 27 | 28 | val json = schema.toJsonSchema 29 | assert(json("type").str == "null") 30 | assert(json("title").str == "Null Value") 31 | assert(json("description").str == "A null value schema") 32 | } 33 | 34 | test("default value support") { 35 | val schema = NullSchema().withDefault(ujson.Null) 36 | val json = schema.toJsonSchema 37 | assert(json("default") == ujson.Null) 38 | 39 | val schemaWithMetadata = NullSchema() 40 | .withTitle("Optional Field") 41 | .withDefault(ujson.Null) 42 | 43 | val jsonWithMetadata = schemaWithMetadata.toJsonSchema 44 | assert(jsonWithMetadata("default") == ujson.Null) 45 | assert(jsonWithMetadata("title").str == "Optional Field") 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/generators/TagGenerator.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.generators 2 | 3 | import boogieloops.web.* 4 | import boogieloops.web.openapi.models.* 5 | 6 | /** 7 | * Generates OpenAPI Tag objects from route schemas 8 | */ 9 | object TagGenerator { 10 | 11 | /** 12 | * Extract unique tags from all routes and create TagObjects 13 | */ 14 | def extractUniqueTags(allRoutes: Map[String, RouteSchema]): List[TagObject] = { 15 | val allTags = allRoutes.values.flatMap(_.tags).toSet 16 | 17 | allTags 18 | .map { tagName => 19 | TagObject( 20 | name = tagName, 21 | description = generateTagDescription(tagName, allRoutes), 22 | externalDocs = None 23 | ) 24 | } 25 | .toList 26 | .sortBy(_.name) 27 | } 28 | 29 | /** 30 | * Generate a description for a tag based on routes that use it 31 | */ 32 | private def generateTagDescription( 33 | tagName: String, 34 | allRoutes: Map[String, RouteSchema] 35 | ): Option[String] = { 36 | val routesWithTag = allRoutes.filter(_._2.tags.contains(tagName)) 37 | 38 | if (routesWithTag.nonEmpty) { 39 | val routeCount = routesWithTag.size 40 | val operations = routesWithTag.keys.map(extractMethod).toSet 41 | 42 | Some(s"Operations related to $tagName ($routeCount endpoints: ${operations.mkString(", ")})") 43 | } else { 44 | Some(s"Operations related to $tagName") 45 | } 46 | } 47 | 48 | private def extractMethod(methodPath: String): String = { 49 | methodPath.split(":").headOption.getOrElse("GET").toUpperCase 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /typescript-sdk/src/demo.ts: -------------------------------------------------------------------------------- 1 | import { createClient, createConfig } from './client/client'; 2 | 3 | const BASE_URL = process.env.BASE_URL || 'http://0.0.0.0:8082'; 4 | 5 | const client = createClient( 6 | createConfig({ baseUrl: BASE_URL, responseStyle: 'data', throwOnError: true }) 7 | ); 8 | 9 | const json = (obj: any) => JSON.stringify(obj, null, 2); 10 | 11 | async function run() { 12 | console.log(`SDK demo using API at ${BASE_URL}`); 13 | 14 | const health = await client.get({ url: '/health' }); 15 | console.log('Health:', json(health)); 16 | 17 | const user = await client.post({ 18 | url: '/users', 19 | headers: new Headers({ 'Content-Type': 'application/json' }), 20 | body: { name: 'John Doe', email: `john.${Date.now()}@example.com`, age: 30, isActive: true }, 21 | bodySerializer: JSON.stringify, 22 | }); 23 | console.log('Created user:', json(user)); 24 | 25 | const users = await client.get({ url: '/users' }); 26 | console.log('Users:', json(users)); 27 | 28 | const userId = user?.id || '1'; 29 | const got = await client.get({ url: `/users/${userId}` }); 30 | console.log('Get user:', json(got)); 31 | 32 | const updated = await client.put({ 33 | url: `/users/${userId}`, 34 | headers: new Headers({ 'Content-Type': 'application/json' }), 35 | body: { age: 31 }, 36 | bodySerializer: JSON.stringify, 37 | }); 38 | console.log('Updated user:', json(updated)); 39 | 40 | const deleted = await client.delete({ url: `/users/${userId}` }); 41 | console.log('Deleted:', json(deleted)); 42 | } 43 | 44 | run().catch((err) => { 45 | console.error('Demo failed:', err); 46 | process.exit(1); 47 | }); 48 | 49 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # BoogieLoops Schema (Core): Type‑Safe JSON Schema for Scala 3 2 | 3 | Define schemas next to your types with annotations, derive JSON Schema automatically, and validate JSON at runtime. 4 | 5 | ## Install 6 | 7 | Mill: 8 | 9 | ```scala 10 | mvn"dev.boogieloop::schema:0.5.5" 11 | ``` 12 | 13 | SBT: 14 | 15 | ```scala 16 | libraryDependencies += "dev.boogieloop" %% "schema" % "0.5.5" 17 | ``` 18 | 19 | ## Quickstart 20 | 21 | ```scala 22 | import boogieloops.schema.derivation.Schema 23 | import ujson.* 24 | 25 | @Schema.title("User") 26 | case class User( 27 | @Schema.minLength(1) name: String, 28 | @Schema.format("email") email: String, 29 | @Schema.minimum(0) age: Int 30 | ) derives Schema 31 | 32 | val schema = Schema[User] 33 | val json = Obj("name"->"Ada","email"->"ada@lovelace.org","age"->28) 34 | schema.validate(json) // Right(()) or Left(List(...)) 35 | ``` 36 | 37 | ## Annotations 38 | 39 | - Strings: `minLength`, `maxLength`, `pattern`, `format`, `const` 40 | - Numbers: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `const` 41 | - Arrays: `minItems`, `maxItems`, `uniqueItems` 42 | - Enums: `enumValues("a", 1, true, 3.14, null)` 43 | - Metadata: `title`, `description`, `examples`, `deprecated`, `readOnly`, `writeOnly` 44 | - Defaults: `default(value)` (type‑safe) 45 | 46 | → [Annotations: Full Docs](./schema/annotations.md) 47 | 48 | ## Unions, Option, Nulls 49 | 50 | - `A | B` → JSON Schema `oneOf` 51 | - `Option[T]` → optional field (may be absent) 52 | - Nullable: use `.nullable` (e.g., `Schema.String().nullable`) 53 | 54 | ## Tips 55 | 56 | - Validate at boundaries and use errors to drive responses. 57 | - Pair with upickle for read/write when you need typed data. 58 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/generators/SchemaConverter.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.generators 2 | 3 | import _root_.boogieloops.schema.Schema 4 | 5 | /** 6 | * Schema utilities for OpenAPI 3.1.1 generation 7 | * 8 | * OpenAPI 3.1.1 uses JSON Schema 2020-12 directly, which means 9 | * Schema schemas can be used without conversion! 10 | */ 11 | object SchemaConverter { 12 | 13 | /** 14 | * Generate a meaningful name for a schema component 15 | */ 16 | def generateSchemaName(schema: Schema, fallback: String = "Schema"): String = { 17 | val jsonSchema = schema.toJsonSchema 18 | 19 | // Try to extract title from schema 20 | jsonSchema.objOpt 21 | .flatMap(_.get("title")) 22 | .flatMap(_.strOpt) 23 | .map(sanitizeSchemaName) 24 | .getOrElse { 25 | // Fallback to type-based naming 26 | jsonSchema.objOpt 27 | .flatMap(_.get("type")) 28 | .flatMap(_.strOpt) 29 | .map(t => s"${t.capitalize}$fallback") 30 | .getOrElse(fallback) 31 | } 32 | } 33 | 34 | /** 35 | * Sanitize schema name for use as component key 36 | */ 37 | private def sanitizeSchemaName(name: String): String = { 38 | name.replaceAll("[^a-zA-Z0-9_]", "") 39 | .split("\\s+") 40 | .map(_.capitalize) 41 | .mkString("") 42 | } 43 | 44 | /** 45 | * Check if two Schema schemas are equivalent for deduplication 46 | */ 47 | def schemasEqual(schema1: Schema, schema2: Schema): Boolean = { 48 | schema1.toJsonSchema == schema2.toJsonSchema 49 | } 50 | 51 | /** 52 | * Calculate hash for schema deduplication 53 | */ 54 | def schemaHash(schema: Schema): Int = { 55 | schema.toJsonSchema.hashCode() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /AI/src/main/scala/boogieloops/Examples/Config.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.ai.examples 2 | 3 | import dotenv.{DotEnv} 4 | 5 | object Config { 6 | // scalafix:off DisableSyntax.var 7 | // Disabling because we need mutable state to lazy-load the .env configuration file 8 | // only when initialize() is called, supporting different env directories at runtime 9 | @volatile private var _dotenv: Option[DotEnv] = None 10 | // scalafix:on DisableSyntax.var 11 | 12 | // Initialize the configuration with a specified directory 13 | def initialize(envDirectory: String): Unit = { 14 | _dotenv = Some(DotEnv.load(s"$envDirectory/.env")) 15 | } 16 | 17 | private def dotenv: Option[DotEnv] = _dotenv 18 | 19 | // Accessors for configuration values - return Option instead of throwing 20 | def getOpenAIKey: Option[String] = 21 | dotenv.flatMap(_.get("OPENAI_API_KEY")) 22 | 23 | def getAnthropicKey: Option[String] = 24 | dotenv.flatMap(_.get("ANTHROPIC_API_KEY")) 25 | 26 | // For backward compatibility, provide methods that return default empty strings 27 | lazy val OPENAI_API_KEY: String = getOpenAIKey.getOrElse("") 28 | lazy val ANTHROPIC_API_KEY: String = getAnthropicKey.getOrElse("") 29 | 30 | // Generic get method for any config value 31 | def get(key: String, default: String = ""): String = { 32 | dotenv 33 | .flatMap(_.get(key)) 34 | .getOrElse(default) 35 | } 36 | 37 | // Get integer config value 38 | def getInt(key: String, default: Int = 0): Int = { 39 | dotenv 40 | .flatMap(_.get(key)) 41 | .map(_.toIntOption.getOrElse(default)) 42 | .getOrElse(default) 43 | } 44 | def getLong(key: String, default: Long = 0) = 45 | dotenv.flatMap(_.get(key)).map(_.toLongOption.getOrElse(default)).getOrElse(default) 46 | } 47 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/models/OpenAPIDocument.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.models 2 | 3 | import upickle.default.* 4 | import boogieloops.web.openapi.config.* 5 | 6 | /** 7 | * OpenAPI 3.1.1 Root Document Structure 8 | */ 9 | case class OpenAPIDocument( 10 | openapi: String, 11 | info: InfoObject, 12 | jsonSchemaDialect: String, 13 | servers: Option[List[ServerObject]] = None, 14 | paths: Option[PathsObject] = None, 15 | webhooks: Option[Map[String, PathItemObject]] = None, 16 | components: Option[ComponentsObject] = None, 17 | security: Option[List[SecurityRequirementObject]] = None, 18 | tags: Option[List[TagObject]] = None, 19 | externalDocs: Option[ExternalDocumentationObject] = None 20 | ) derives ReadWriter 21 | 22 | /** 23 | * Info Object (OpenAPI 3.1.1) 24 | */ 25 | case class InfoObject( 26 | title: String, 27 | summary: Option[String] = None, // OpenAPI 3.1.1 feature 28 | description: String, 29 | version: String, 30 | termsOfService: Option[String] = None, 31 | contact: Option[ContactObject] = None, 32 | license: Option[LicenseObject] = None 33 | ) derives ReadWriter 34 | 35 | /** 36 | * Paths Object - container for all path items 37 | * OpenAPI spec requires paths to serialize as a flat map, not wrapped in a "paths" field 38 | */ 39 | case class PathsObject( 40 | paths: Map[String, PathItemObject] 41 | ) 42 | 43 | object PathsObject { 44 | given ReadWriter[PathsObject] = readwriter[Map[String, PathItemObject]].bimap( 45 | obj => obj.paths, 46 | map => PathsObject(map) 47 | ) 48 | } 49 | 50 | /** 51 | * Tag Object for categorizing operations 52 | */ 53 | case class TagObject( 54 | name: String, 55 | description: Option[String] = None, 56 | externalDocs: Option[ExternalDocumentationObject] = None 57 | ) derives ReadWriter 58 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/composition/OneOfSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.composition 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * OneOf composition schema - validates if the instance is valid against exactly one of the schemas 8 | */ 9 | case class OneOfSchema( 10 | schemas: List[Schema] 11 | ) extends Schema { 12 | 13 | override def toJsonSchema: ujson.Value = { 14 | val schema = ujson.Obj() 15 | 16 | schema("oneOf") = ujson.Arr(schemas.map(_.toJsonSchema)*) 17 | 18 | title.foreach(t => schema("title") = ujson.Str(t)) 19 | description.foreach(d => schema("description") = ujson.Str(d)) 20 | default.foreach(d => schema("default") = d) 21 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 22 | 23 | schema 24 | } 25 | 26 | /** 27 | * Validate a ujson.Value against this oneOf schema 28 | */ 29 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 30 | // For oneOf, exactly one schema must validate successfully 31 | val results = schemas.map(_.validate(value, context)) 32 | val successCount = results.count(_.isValid) 33 | 34 | if (successCount == 1) { 35 | ValidationResult.valid() 36 | } else if (successCount == 0) { 37 | val allErrors = results.flatMap(_.errors) 38 | val error = boogieloops.schema.ValidationError.CompositionError( 39 | "Value does not match any of the schemas in oneOf", 40 | context.path 41 | ) 42 | ValidationResult.invalid(error :: allErrors) 43 | } else { 44 | val error = boogieloops.schema.ValidationError.CompositionError( 45 | "Value matches more than one schema in oneOf", 46 | context.path 47 | ) 48 | ValidationResult.invalid(error) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/references/DynamicRefSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.references 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * JSON Schema $dynamicRef keyword implementation 8 | * 9 | * Represents a dynamic reference that can be resolved differently based on the dynamic scope. 10 | * This is a JSON Schema 2020-12 feature that allows for more flexible reference resolution 11 | * in recursive schemas and schema composition scenarios. 12 | * 13 | * $dynamicRef works in conjunction with $dynamicAnchor to enable dynamic reference resolution 14 | * that can vary based on the evaluation context. 15 | */ 16 | case class DynamicRefSchema( 17 | dynamicRef: String 18 | ) extends Schema { 19 | 20 | override def $dynamicRef: Option[String] = Some(dynamicRef) 21 | 22 | override def toJsonSchema: ujson.Value = { 23 | val schema = ujson.Obj("$dynamicRef" -> ujson.Str(dynamicRef)) 24 | 25 | // Add meta-data keywords if present 26 | title.foreach(t => schema("title") = ujson.Str(t)) 27 | description.foreach(d => schema("description") = ujson.Str(d)) 28 | 29 | schema 30 | } 31 | 32 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 33 | // TODO: implement this validation 34 | // In a complete implementation, this would: 35 | // 1. Search the dynamic scope for a matching $dynamicAnchor 36 | // 2. If found, resolve to that schema and validate 37 | // 3. Otherwise, treat as a normal $ref and resolve statically 38 | // 4. Validate the value against the resolved schema 39 | // 40 | // For now, we return an error indicating unresolved dynamic reference 41 | ValidationResult.invalid(List(boogieloops.schema.ValidationError.ReferenceError( 42 | s"$dynamicRef (dynamic)", 43 | context.path 44 | ))) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Schema/test/src/boogieloops/primitives/BooleanSchemaTests.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.primitives 2 | 3 | import utest.* 4 | import boogieloops.schema.primitives.* 5 | import boogieloops.schema.validation.ValidationContext 6 | import boogieloops.schema.* 7 | 8 | object BooleanSchemaTests extends TestSuite { 9 | 10 | val tests = Tests { 11 | test("basic boolean schema") { 12 | val schema = BooleanSchema() 13 | val result1 = schema.validate(ujson.Bool(true), ValidationContext()) 14 | assert(result1.isValid) 15 | val result2 = schema.validate(ujson.Bool(false), ValidationContext()) 16 | assert(result2.isValid) 17 | } 18 | 19 | test("const true validation") { 20 | val schema = BooleanSchema(const = Some(true)) 21 | val result1 = schema.validate(ujson.Bool(true), ValidationContext()) 22 | assert(result1.isValid) 23 | val result2 = schema.validate(ujson.Bool(false), ValidationContext()) 24 | assert(!result2.isValid) 25 | } 26 | 27 | test("const false validation") { 28 | val schema = BooleanSchema(const = Some(false)) 29 | val result1 = schema.validate(ujson.Bool(false), ValidationContext()) 30 | assert(result1.isValid) 31 | val result2 = schema.validate(ujson.Bool(true), ValidationContext()) 32 | assert(!result2.isValid) 33 | } 34 | 35 | test("json schema generation") { 36 | val schema = BooleanSchema() 37 | val json = schema.toJsonSchema 38 | assert(json("type").str == "boolean") 39 | } 40 | 41 | test("default value support") { 42 | val schema = BooleanSchema().withDefault(ujson.Bool(true)) 43 | val json = schema.toJsonSchema 44 | assert(json("default").bool == true) 45 | 46 | val schemaFalse = BooleanSchema().withDefault(ujson.Bool(false)) 47 | val jsonFalse = schemaFalse.toJsonSchema 48 | assert(jsonFalse("default").bool == false) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/config/OpenAPIConfig.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.config 2 | 3 | import upickle.default.* 4 | 5 | /** 6 | * Configuration for OpenAPI 3.1.1 document generation 7 | */ 8 | case class OpenAPIConfig( 9 | // Info Object (OpenAPI 3.1.1) 10 | title: String = "API Documentation", 11 | description: String = "Generated from boogieloops.web route schemas", 12 | summary: Option[String] = Some("boogieloops.web Auto-Generated API"), 13 | version: String = "1.0.0", 14 | termsOfService: Option[String] = None, 15 | contact: Option[ContactObject] = None, 16 | license: Option[LicenseObject] = None, 17 | 18 | // Root level fields 19 | jsonSchemaDialect: String = "https://spec.openapis.org/oas/3.1/dialect/base", 20 | servers: List[ServerObject] = List.empty, 21 | externalDocs: Option[ExternalDocumentationObject] = None, 22 | 23 | // Generation options 24 | includeWebhooks: Boolean = false, 25 | extractComponents: Boolean = true, 26 | generateOperationIds: Boolean = true 27 | ) derives ReadWriter 28 | 29 | case class ServerObject( 30 | url: String, 31 | description: Option[String] = None, 32 | variables: Map[String, ServerVariableObject] = Map.empty 33 | ) derives ReadWriter 34 | 35 | case class ServerVariableObject( 36 | `enum`: Option[List[String]] = None, 37 | default: String, 38 | description: Option[String] = None 39 | ) derives ReadWriter 40 | 41 | case class ContactObject( 42 | name: Option[String] = None, 43 | url: Option[String] = None, 44 | email: Option[String] = None 45 | ) derives ReadWriter 46 | 47 | case class LicenseObject( 48 | name: String, 49 | identifier: Option[String] = None, // OpenAPI 3.1.1 addition 50 | url: Option[String] = None 51 | ) derives ReadWriter 52 | 53 | case class ExternalDocumentationObject( 54 | description: Option[String] = None, 55 | url: String 56 | ) derives ReadWriter 57 | -------------------------------------------------------------------------------- /docs/ai/metadata-and-scoping.md: -------------------------------------------------------------------------------- 1 | # Metadata & Scoping 2 | 3 | BoogieLoops AI uses `RequestMetadata` to scope conversations and enable stateful interactions across requests. 4 | 5 | ## RequestMetadata 6 | 7 | ```scala 8 | case class RequestMetadata( 9 | tenantId: Option[String] = None, 10 | userId: Option[String] = None, 11 | conversationId: Option[String] = None 12 | ) 13 | ``` 14 | 15 | - **tenantId**: Multi‑tenant isolation 16 | - **userId**: Per‑user conversation history 17 | - **conversationId**: Specific conversation thread 18 | 19 | ## Scoped History 20 | 21 | Agents automatically maintain conversation history based on metadata: 22 | 23 | ```scala 24 | val metadata = RequestMetadata( 25 | userId = Some("alice"), 26 | conversationId = Some("support-session-123") 27 | ) 28 | 29 | // First message - starts new conversation 30 | agent.generateText("Hello, I need help with my account.", metadata) 31 | 32 | // Follow-up - continues same conversation 33 | agent.generateText("What's my current balance?", metadata) 34 | ``` 35 | 36 | ## Stateless Requests 37 | 38 | Use `generateTextWithoutHistory` for one‑off requests: 39 | 40 | ```scala 41 | // No history maintained 42 | val response = agent.generateTextWithoutHistory("Translate: Hello world") 43 | ``` 44 | 45 | ## Conversation Isolation 46 | 47 | Different metadata creates separate conversation contexts: 48 | 49 | ```scala 50 | val alice = RequestMetadata(userId = Some("alice")) 51 | val bob = RequestMetadata(userId = Some("bob")) 52 | 53 | // These maintain separate histories 54 | agent.generateText("My favorite color is blue", alice) 55 | agent.generateText("My favorite color is red", bob) 56 | ``` 57 | 58 | ## Multi‑Tenant Usage 59 | 60 | Combine `tenantId` and `userId` for SaaS applications: 61 | 62 | ```scala 63 | val metadata = RequestMetadata( 64 | tenantId = Some("company-a"), 65 | userId = Some("alice"), 66 | conversationId = Some("onboarding") 67 | ) 68 | ``` 69 | 70 | This ensures conversation history is isolated both by tenant and user. 71 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/primitives/NullSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.primitives 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Null schema type with JSON Schema 2020-12 validation keywords 8 | */ 9 | case class NullSchema() extends Schema { 10 | 11 | override def toJsonSchema: ujson.Value = { 12 | val schema = ujson.Obj("type" -> ujson.Str("null")) 13 | 14 | title.foreach(t => schema("title") = ujson.Str(t)) 15 | description.foreach(d => schema("description") = ujson.Str(d)) 16 | default.foreach(d => schema("default") = d) 17 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 18 | 19 | schema 20 | } 21 | 22 | /** 23 | * Validate a ujson.Value against this null schema 24 | */ 25 | def validate(value: ujson.Value): ValidationResult = { 26 | validate(value, ValidationContext()) 27 | } 28 | 29 | /** 30 | * Validate a ujson.Value against this null schema with context 31 | */ 32 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 33 | // Type check for ujson.Null 34 | value match { 35 | case ujson.Null => 36 | // Null values are always valid for null schema 37 | ValidationResult.valid() 38 | case _ => 39 | // Non-null ujson.Value type - return TypeMismatch error 40 | val error = boogieloops.schema.ValidationError.TypeMismatch("null", getValueType(value), context.path) 41 | ValidationResult.invalid(error) 42 | } 43 | } 44 | 45 | /** 46 | * Get string representation of ujson.Value type for error messages 47 | */ 48 | private def getValueType(value: ujson.Value): String = { 49 | value match { 50 | case _: ujson.Str => "string" 51 | case _: ujson.Num => "number" 52 | case _: ujson.Bool => "boolean" 53 | case ujson.Null => "null" 54 | case _: ujson.Arr => "array" 55 | case _: ujson.Obj => "object" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/composition/IfThenElseSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.composition 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * If-Then-Else conditional schema - JSON Schema 2020-12 conditional validation 8 | */ 9 | case class IfThenElseSchema( 10 | condition: Schema, 11 | thenSchema: Option[Schema] = None, 12 | elseSchema: Option[Schema] = None 13 | ) extends Schema { 14 | 15 | override def toJsonSchema: ujson.Value = { 16 | val schema = ujson.Obj() 17 | 18 | schema("if") = condition.toJsonSchema 19 | thenSchema.foreach(t => schema("then") = t.toJsonSchema) 20 | elseSchema.foreach(e => schema("else") = e.toJsonSchema) 21 | 22 | title.foreach(t => schema("title") = ujson.Str(t)) 23 | description.foreach(d => schema("description") = ujson.Str(d)) 24 | default.foreach(d => schema("default") = d) 25 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 26 | 27 | schema 28 | } 29 | 30 | /** 31 | * Validate a ujson.Value against this if-then-else schema 32 | */ 33 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 34 | // For if-then-else, we need to: 35 | // 1. Check if the condition matches 36 | // 2. If it does, apply the "then" schema 37 | // 3. If it doesn't, apply the "else" schema 38 | // 39 | 40 | // TODO: we can do a little better here.... 41 | 42 | val conditionResult = condition.validate(value, context) 43 | 44 | if (conditionResult.isValid) { 45 | // Condition matched, apply "then" schema if present 46 | thenSchema match { 47 | case Some(schema) => schema.validate(value, context) 48 | case None => ValidationResult.valid() 49 | } 50 | } else { 51 | // Condition didn't match, apply "else" schema if present 52 | elseSchema match { 53 | case Some(schema) => schema.validate(value, context) 54 | case None => ValidationResult.valid() 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /AI/src/main/scala/boogieloops/Providers/ProviderFormats.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.ai.providers 2 | 3 | import upickle.default.ReadWriter 4 | import boogieloops.ai.{ChatResponse, Usage, ObjectResponse} 5 | 6 | // Provider-specific response formats for automatic parsing 7 | 8 | // OpenAI API response format 9 | case class OpenAIMessage( 10 | role: String, 11 | content: String 12 | ) derives ReadWriter 13 | 14 | case class OpenAIChoice( 15 | message: OpenAIMessage, 16 | finish_reason: Option[String], 17 | index: Int 18 | ) derives ReadWriter 19 | 20 | case class OpenAIUsage( 21 | prompt_tokens: Int, 22 | completion_tokens: Int, 23 | total_tokens: Int 24 | ) derives ReadWriter 25 | 26 | case class OpenAIResponse( 27 | id: String, 28 | `object`: String, 29 | created: Long, 30 | model: String, 31 | choices: List[OpenAIChoice], 32 | usage: Option[OpenAIUsage] 33 | ) derives ReadWriter { 34 | // Convert to unified ChatResponse 35 | def toChatResponse: ChatResponse = { 36 | val firstChoice = choices.head 37 | ChatResponse( 38 | content = firstChoice.message.content, 39 | usage = usage.map(u => Usage(u.prompt_tokens, u.completion_tokens, u.total_tokens)), 40 | model = model, 41 | finishReason = firstChoice.finish_reason 42 | ) 43 | } 44 | 45 | // Convert to unified ObjectResponse with ujson.Value (for structured responses) 46 | def toObjectResponse: ObjectResponse[ujson.Value] = { 47 | val firstChoice = choices.head 48 | val contentJson = ujson.read(firstChoice.message.content) 49 | ObjectResponse[ujson.Value]( 50 | data = contentJson, 51 | usage = usage.map(u => Usage(u.prompt_tokens, u.completion_tokens, u.total_tokens)), 52 | model = model, 53 | finishReason = firstChoice.finish_reason 54 | ) 55 | } 56 | } 57 | 58 | // Error response format (common to both providers) 59 | case class ErrorDetails( 60 | message: String, 61 | `type`: Option[String] = None, 62 | code: Option[String] = None 63 | ) derives ReadWriter 64 | 65 | case class ErrorResponse( 66 | error: ErrorDetails 67 | ) derives ReadWriter 68 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/validation/ValidationResult.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.validation 2 | 3 | import boogieloops.schema.ValidationError 4 | 5 | /** 6 | * Result of a validation operation 7 | * 8 | * Represents either a successful validation (valid) or a failed validation with errors (invalid). 9 | * This provides a unified result type for all validation operations in the BoogieLoops library. 10 | */ 11 | sealed trait ValidationResult { 12 | def isValid: Boolean 13 | def errors: List[ValidationError] 14 | 15 | /** 16 | * Combine this result with another validation result 17 | */ 18 | def combine(other: ValidationResult): ValidationResult = (this, other) match { 19 | case (ValidationResult.Valid, ValidationResult.Valid) => ValidationResult.Valid 20 | case (ValidationResult.Valid, invalid @ ValidationResult.Invalid(_)) => invalid 21 | case (invalid @ ValidationResult.Invalid(_), ValidationResult.Valid) => invalid 22 | case (ValidationResult.Invalid(errors1), ValidationResult.Invalid(errors2)) => 23 | ValidationResult.Invalid(errors1 ++ errors2) 24 | } 25 | } 26 | 27 | object ValidationResult { 28 | 29 | /** 30 | * Successful validation result 31 | */ 32 | case object Valid extends ValidationResult { 33 | def isValid: Boolean = true 34 | def errors: List[ValidationError] = List.empty 35 | } 36 | 37 | /** 38 | * Failed validation result with errors 39 | */ 40 | case class Invalid(errors: List[ValidationError]) extends ValidationResult { 41 | def isValid: Boolean = false 42 | } 43 | 44 | /** 45 | * Create a valid result 46 | */ 47 | def valid(): ValidationResult = Valid 48 | 49 | /** 50 | * Create an invalid result with a single error 51 | */ 52 | def invalid(error: ValidationError): ValidationResult = Invalid(List(error)) 53 | 54 | /** 55 | * Create an invalid result with multiple errors 56 | */ 57 | def invalid(errors: List[ValidationError]): ValidationResult = Invalid(errors) 58 | 59 | /** 60 | * Combine multiple validation results 61 | */ 62 | def combine(results: List[ValidationResult]): ValidationResult = { 63 | results.foldLeft[ValidationResult](Valid)(_.combine(_)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/models/SecurityObjects.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.models 2 | 3 | import upickle.default.* 4 | 5 | //TODO: add support for OAuth 2.1 dynamic client registration 6 | 7 | /** 8 | * Security Scheme Object - defines security schemes that can be used by operations 9 | */ 10 | case class SecuritySchemeObject( 11 | `type`: String, // "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" 12 | description: Option[String] = None, 13 | // For apiKey 14 | name: Option[String] = None, 15 | in: Option[String] = None, // "query", "header", "cookie" 16 | // For http 17 | scheme: Option[String] = None, // "basic", "bearer", etc. 18 | bearerFormat: Option[String] = None, 19 | // For oauth2 20 | flows: Option[OAuthFlowsObject] = None, 21 | // For openIdConnect 22 | openIdConnectUrl: Option[String] = None 23 | ) derives ReadWriter 24 | 25 | /** 26 | * OAuth Flows Object - configuration details for supported OAuth Flow types 27 | */ 28 | case class OAuthFlowsObject( 29 | `implicit`: Option[OAuthFlowObject] = None, 30 | password: Option[OAuthFlowObject] = None, 31 | clientCredentials: Option[OAuthFlowObject] = None, 32 | authorizationCode: Option[OAuthFlowObject] = None 33 | ) derives ReadWriter 34 | 35 | /** 36 | * OAuth Flow Object - configuration for a supported OAuth Flow 37 | */ 38 | case class OAuthFlowObject( 39 | authorizationUrl: Option[String] = None, // Required for implicit, authorizationCode 40 | tokenUrl: Option[String] = None, // Required for password, clientCredentials, authorizationCode 41 | refreshUrl: Option[String] = None, 42 | scopes: Map[String, String] = Map.empty 43 | ) derives ReadWriter 44 | 45 | /** 46 | * Security Requirement Object - lists required security schemes for operation 47 | * OpenAPI spec requires this to serialize as a flat map, not wrapped in a "requirements" field 48 | */ 49 | case class SecurityRequirementObject( 50 | requirements: Map[String, List[String]] = Map.empty 51 | ) 52 | 53 | object SecurityRequirementObject { 54 | given ReadWriter[SecurityRequirementObject] = 55 | readwriter[Map[String, List[String]]].bimap( 56 | _.requirements, 57 | SecurityRequirementObject(_) 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # BoogieLoops Project - Agent Instructions 2 | 3 | ## Build & Test Commands 4 | 5 | - **Compile all:** `./mill __.compile` 6 | - **Test all:** `./mill __.test` 7 | - **Test single module:** `./mill schema.test` or `./mill web.test` or `./mill ai.test` 8 | - **Run single test:** `./mill schema.test boogieloops.schema.primitives.StringSchemaTests` 9 | - **Lint/Format:** `./mill __.fix` (scalafix) or `make format` (scalafmt) 10 | - **Clean:** `./mill clean` or `make clean` 11 | 12 | ## Code Style 13 | 14 | - **Language:** Scala 3.6.2+ with Mill build tool 15 | - **Packages:** lowercase (e.g., `boogieloops.schema`, `boogieloops.web`, `boogieloops.ai`) 16 | - **Classes:** PascalCase ending in `Schema` for schema types (e.g., `StringSchema`, `ObjectSchema`) 17 | - **Imports:** Group by java/scala/third-party/local, merge same package, use `_root_` prefix when needed 18 | - **Formatting:** Max 120 chars/line, 2-space indent, Asterisk-style docs, no vertical alignment 19 | - **Testing:** utest framework, tests in parallel `test/src` structure mirroring main 20 | - **Error handling:** Return `ValidationResult` with errors, avoid exceptions 21 | - **No comments:** Unless explicitly requested, avoid adding code comments 22 | - **Dependencies:** Check existing before adding - upickle for JSON, os-lib for file ops, cask for web 23 | 24 | ## Beads Workflow (Issue Tracking) 25 | 26 | This project uses [beads](https://github.com/steveyegge/beads) for issue tracking. Run `bd prime` after context compaction or new sessions. 27 | 28 | ### Session Close Protocol 29 | 30 | Before ending a session, ALWAYS run: 31 | 32 | ```bash 33 | git status # check what changed 34 | git add # stage code changes 35 | bd sync # commit beads changes 36 | git commit -m "..." # commit code 37 | git push # push to remote 38 | ``` 39 | 40 | ### Essential Commands 41 | 42 | - `bd ready` - Show issues ready to work (no blockers) 43 | - `bd list --status=open` - All open issues 44 | - `bd create --title="..." --type=task|bug|feature` - New issue 45 | - `bd update --status=in_progress` - Claim work 46 | - `bd close --reason="..."` - Mark complete 47 | - `bd dep add ` - Add dependency 48 | - `bd sync` - Sync with git remote 49 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Welcome to the BoogieLoops ecosystem — three Scala 3 libraries that work great together or stand alone: 4 | 5 | - **schema**: Type-safe JSON Schema (derive, build, validate) 6 | - **web**: Schema-first HTTP validation + OpenAPI for Cask 7 | - **ai**: Type-safe LLM agents with structured output 8 | 9 | ## Prerequisites 10 | 11 | - Scala 3.6.2+ 12 | - JDK 17+ 13 | - Mill (launcher included as `./mill`) 14 | 15 | ## Install 16 | 17 | Mill: 18 | 19 | ```scala 20 | mvn"dev.boogieloop::schema:0.5.5" // Core schemas 21 | mvn"dev.boogieloop::web:0.5.5" // HTTP validation + OpenAPI 22 | mvn"dev.boogieloop::ai:0.5.5" // LLM agents 23 | ``` 24 | 25 | SBT: 26 | 27 | ```scala 28 | libraryDependencies ++= Seq( 29 | "dev.boogieloop" %% "schema" % "0.5.5", 30 | "dev.boogieloop" %% "web" % "0.5.5", 31 | "dev.boogieloop" %% "ai" % "0.5.5" 32 | ) 33 | ``` 34 | 35 | ## Common Commands 36 | 37 | - Build: `make build` (or `./mill __.compile`) 38 | - Test: `make test` (override `MODULE=schema|web|ai`) 39 | - Lint: `make lint` (scalafix) 40 | - Format: `make format` (scalafmt) 41 | - Watch: `make watch MODULE=schema` or `make watch-test MODULE=web` 42 | - Examples: `make schema`, `make web`, `make ai` 43 | 44 | Run `make help` for the full list. 45 | 46 | ## Examples Overview 47 | 48 | - `make schema`: Run core schema examples 49 | - `make web`: Start the web API example on port 8082 50 | - `make web-upload`: Start the Upload/Streaming demo (defaults to port 8080) 51 | - `make ai`: Run ai examples (requires API keys or a local LLM) 52 | 53 | AI examples environment variables: 54 | 55 | ```bash 56 | export OPENAI_API_KEY="your-openai-key" # OpenAI provider 57 | export ANTHROPIC_API_KEY="your-anthropic-key" # Anthropic provider (optional) 58 | ``` 59 | 60 | Local LLMs via OpenAI-compatible endpoints (no key by default) are supported (LM Studio, Ollama, llama.cpp). 61 | 62 | ## Next Steps 63 | 64 | - schema (core): [schema.md](./schema.md) 65 | - web: [web.md](./web.md) 66 | - ai: [ai.md](./ai.md) 67 | 68 | ## More Docs 69 | 70 | - Concepts: [concepts.md](./concepts.md) 71 | - Zero to App: [zero-to-app.md](./zero-to-app.md) 72 | - Troubleshooting: [troubleshooting.md](./troubleshooting.md) 73 | - TypeScript SDK: [typescript-sdk.md](./typescript-sdk.md) 74 | -------------------------------------------------------------------------------- /AI/test/src/main/scala/boogieloops/ObjectResponseAPISpec.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.ai.test 2 | 3 | import utest.* 4 | import boogieloops.ai.{ObjectResponse, Usage} 5 | import ujson.Value 6 | import upickle.default.* 7 | 8 | object ObjectResponseAPISpec extends TestSuite: 9 | 10 | case class TestData(name: String, age: Int) derives ReadWriter 11 | 12 | val tests = Tests { 13 | 14 | test("ObjectResponse provides clean API") { 15 | val testObj = TestData("Alice", 30) 16 | val response = ObjectResponse( 17 | data = testObj, 18 | usage = Some(Usage(10, 20, 30)), 19 | model = "test-model", 20 | finishReason = Some("stop") 21 | ) 22 | 23 | // Simple, clean access to data 24 | assert(response.data == testObj) 25 | assert(response.data.name == "Alice") 26 | assert(response.data.age == 30) 27 | } 28 | 29 | test("ObjectResponse with ujson.Value works correctly") { 30 | val jsonData = ujson.Obj("name" -> "Bob", "age" -> 25) 31 | val jsonResponse = ObjectResponse[ujson.Value]( 32 | data = jsonData, 33 | usage = Some(Usage(5, 15, 20)), 34 | model = "test-model", 35 | finishReason = Some("stop") 36 | ) 37 | 38 | // Simple access to raw JSON data 39 | assert(jsonResponse.data == jsonData) 40 | 41 | // Test conversion to typed response 42 | val typedResponse = ObjectResponse[TestData]( 43 | data = read[TestData](jsonResponse.data), 44 | usage = jsonResponse.usage, 45 | model = jsonResponse.model, 46 | finishReason = jsonResponse.finishReason 47 | ) 48 | assert(typedResponse.data.name == "Bob") 49 | assert(typedResponse.data.age == 25) 50 | } 51 | 52 | test("API demonstrates clean usage patterns") { 53 | val jsonData = ujson.Obj("name" -> "Charlie", "age" -> 35) 54 | val jsonResponse = ObjectResponse[ujson.Value]( 55 | data = jsonData, 56 | usage = None, 57 | model = "test-model", 58 | finishReason = Some("stop") 59 | ) 60 | 61 | // Simple conversion pattern 62 | val person = read[TestData](jsonResponse.data) 63 | 64 | assert(person.name == "Charlie") 65 | assert(person.age == 35) 66 | 67 | // Direct access to raw data 68 | assert(jsonResponse.data("name").str == "Charlie") 69 | assert(jsonResponse.data("age").num == 35) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/SchemaType.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema 2 | 3 | import boogieloops.schema.primitives.* 4 | import boogieloops.schema.complex.* 5 | 6 | /** 7 | * Type-level computation for mapping BoogieLoops schemas to Scala types 8 | * 9 | * This uses Scala 3's match types to provide compile-time type safety. For complex types, use Mirror-based Schema derivation with 10 | * case classes, see ./derivation/SchemaDerivation.scala 11 | */ 12 | type SchemaType[C <: Schema] = C match { 13 | // Primitive types - fully supported 14 | case StringSchema => String 15 | case NumberSchema => Double 16 | case IntegerSchema => Int 17 | case BooleanSchema => Boolean 18 | case NullSchema => Null 19 | 20 | // Complex types - use case classes with Mirror derivation instead 21 | case ArraySchema[t] => List[SchemaType[t]] 22 | 23 | // Modifiers - fully supported 24 | case OptionalSchema[t] => Option[SchemaType[t]] 25 | case NullableSchema[t] => Option[SchemaType[t]] 26 | case OptionalNullableSchema[t] => Option[SchemaType[t]] 27 | case DefaultSchema[t] => SchemaType[t] 28 | } 29 | 30 | /** 31 | * Validation error types 32 | */ 33 | enum ValidationError { 34 | case TypeMismatch(expected: String, actual: String, path: String) 35 | case MissingField(field: String, path: String) 36 | case InvalidFormat(format: String, value: String, path: String) 37 | case OutOfRange(min: Option[Double], max: Option[Double], value: Double, path: String) 38 | case ParseError(message: String, path: String) 39 | case PatternMismatch(pattern: String, value: String, path: String) 40 | case AdditionalProperty(property: String, path: String) 41 | case UniqueViolation(path: String) 42 | case MinItemsViolation(min: Int, actual: Int, path: String) 43 | case MaxItemsViolation(max: Int, actual: Int, path: String) 44 | case ContainsViolation( 45 | minContains: Option[Int], 46 | maxContains: Option[Int], 47 | actualContains: Int, 48 | path: String 49 | ) 50 | case MinLengthViolation(min: Int, actual: Int, path: String) 51 | case MaxLengthViolation(max: Int, actual: Int, path: String) 52 | case MinPropertiesViolation(min: Int, actual: Int, path: String) 53 | case MaxPropertiesViolation(max: Int, actual: Int, path: String) 54 | case MultipleOfViolation(multipleOf: Double, value: Double, path: String) 55 | case CompositionError(message: String, path: String) 56 | case ReferenceError(ref: String, path: String) 57 | } 58 | -------------------------------------------------------------------------------- /docs/ai.md: -------------------------------------------------------------------------------- 1 | # BoogieLoops AI: Type‑Safe LLM Agents 2 | 3 | LLM agents with multi‑provider support (OpenAI, Anthropic, OpenAI‑compatible local endpoints), typed structured output using BoogieLoops Schema schemas, scoped history, and hooks/metrics. 4 | 5 | ## Install 6 | 7 | Mill: 8 | 9 | ```scala 10 | mvn"dev.boogieloop::schema:0.5.5" 11 | mvn"dev.boogieloop::ai:0.5.5" 12 | ``` 13 | 14 | SBT: 15 | 16 | ```scala 17 | libraryDependencies ++= Seq( 18 | "dev.boogieloop" %% "schema" % "0.5.5", 19 | "dev.boogieloop" %% "ai" % "0.5.5" 20 | ) 21 | ``` 22 | 23 | ## Quickstart 24 | 25 | ```scala 26 | import boogieloops.ai.agent.* 27 | import boogieloops.ai.agent.providers.OpenAIProvider 28 | import boogieloops.schema.derivation.Schema 29 | 30 | @Schema.title("Summary") 31 | case class Summary(@Schema.minLength(1) text: String) derives Schema 32 | 33 | val agent = Agent( 34 | name = "Summarizer", 35 | instructions = "Summarize briefly.", 36 | provider = new OpenAIProvider(sys.env("OPENAI_API_KEY")), 37 | model = "gpt-4o-mini" 38 | ) 39 | 40 | val md = RequestMetadata(userId = Some("dev")) 41 | val res = agent.generateObject[Summary]("Summarize: Scala 3 match types.", md) 42 | ``` 43 | 44 | ## Local LLMs (OpenAI‑Compatible) 45 | 46 | ```scala 47 | import boogieloops.ai.agent.providers.OpenAICompatibleProvider 48 | 49 | val local = OpenAICompatibleProvider( 50 | baseUrl = "http://localhost:1234/v1", 51 | modelId = "local-model", 52 | strictModelValidation = false 53 | ) 54 | 55 | val agent = Agent("Local", "You are helpful.", local, model = "local-model") 56 | ``` 57 | 58 | ## Structured Output 59 | 60 | - Define your data with BoogieLoops Schema annotations; use `agent.generateObject[T]`. 61 | - On success you get `Right(ObjectResponse[T])`; failures return `Left(SchemaError)`. 62 | 63 | ## Metadata & Scoping 64 | 65 | - Provide `RequestMetadata(tenantId, userId, conversationId)` to scope history. 66 | - Use `generateTextWithoutHistory` for stateless calls. 67 | 68 | → [Detailed Guide: Metadata & Scoping](./ai/metadata-and-scoping.md) 69 | 70 | ## Hooks & Metrics 71 | 72 | - Register hooks for logging/metrics/tracing; combine multiple hooks. 73 | - Built‑in metrics helpers available; export to Prometheus or JSON if desired. 74 | 75 | → [Detailed Guide: Hooks & Metrics](./ai/hooks-and-metrics.md) 76 | 77 | ## Run Examples 78 | 79 | - `make ai` (requires `OPENAI_API_KEY` or a local LLM endpoint) 80 | - Local LLM: ensure your server exposes `/v1` and a valid model ID. 81 | -------------------------------------------------------------------------------- /.beads/config.yaml: -------------------------------------------------------------------------------- 1 | # Beads Configuration File 2 | # This file configures default behavior for all bd commands in this repository 3 | # All settings can also be set via environment variables (BD_* prefix) 4 | # or overridden with command-line flags 5 | 6 | # Issue prefix for this repository (used by bd init) 7 | # If not set, bd init will auto-detect from directory name 8 | # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. 9 | # issue-prefix: "" 10 | 11 | # Use no-db mode: load from JSONL, no SQLite, write back after each command 12 | # When true, bd will use .beads/issues.jsonl as the source of truth 13 | # instead of SQLite database 14 | # no-db: false 15 | 16 | # Disable daemon for RPC communication (forces direct database access) 17 | # no-daemon: false 18 | 19 | # Disable auto-flush of database to JSONL after mutations 20 | # no-auto-flush: false 21 | 22 | # Disable auto-import from JSONL when it's newer than database 23 | # no-auto-import: false 24 | 25 | # Enable JSON output by default 26 | # json: false 27 | 28 | # Default actor for audit trails (overridden by BD_ACTOR or --actor) 29 | # actor: "" 30 | 31 | # Path to database (overridden by BEADS_DB or --db) 32 | # db: "" 33 | 34 | # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) 35 | # auto-start-daemon: true 36 | 37 | # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) 38 | # flush-debounce: "5s" 39 | 40 | # Git branch for beads commits (bd sync will commit to this branch) 41 | # IMPORTANT: Set this for team projects so all clones use the same sync branch. 42 | # This setting persists across clones (unlike database config which is gitignored). 43 | # Can also use BEADS_SYNC_BRANCH env var for local override. 44 | # If not set, bd sync will require you to run 'bd config set sync.branch '. 45 | # sync-branch: "beads-sync" 46 | 47 | # Multi-repo configuration (experimental - bd-307) 48 | # Allows hydrating from multiple repositories and routing writes to the correct JSONL 49 | # repos: 50 | # primary: "." # Primary repo (where this database lives) 51 | # additional: # Additional repos to hydrate from (read-only) 52 | # - ~/beads-planning # Personal planning repo 53 | # - ~/work-planning # Work planning repo 54 | 55 | # Integration settings (access with 'bd config get/set') 56 | # These are stored in the database, not in this file: 57 | # - jira.url 58 | # - jira.project 59 | # - linear.url 60 | # - linear.api-key 61 | # - github.org 62 | # - github.repo 63 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/core/bodySerializer.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArrayStyle, 3 | ObjectStyle, 4 | SerializerOptions, 5 | } from './pathSerializer'; 6 | 7 | export type QuerySerializer = (query: Record) => string; 8 | 9 | export type BodySerializer = (body: any) => any; 10 | 11 | export interface QuerySerializerOptions { 12 | allowReserved?: boolean; 13 | array?: SerializerOptions; 14 | object?: SerializerOptions; 15 | } 16 | 17 | const serializeFormDataPair = ( 18 | data: FormData, 19 | key: string, 20 | value: unknown, 21 | ): void => { 22 | if (typeof value === 'string' || value instanceof Blob) { 23 | data.append(key, value); 24 | } else { 25 | data.append(key, JSON.stringify(value)); 26 | } 27 | }; 28 | 29 | const serializeUrlSearchParamsPair = ( 30 | data: URLSearchParams, 31 | key: string, 32 | value: unknown, 33 | ): void => { 34 | if (typeof value === 'string') { 35 | data.append(key, value); 36 | } else { 37 | data.append(key, JSON.stringify(value)); 38 | } 39 | }; 40 | 41 | export const formDataBodySerializer = { 42 | bodySerializer: | Array>>( 43 | body: T, 44 | ): FormData => { 45 | const data = new FormData(); 46 | 47 | Object.entries(body).forEach(([key, value]) => { 48 | if (value === undefined || value === null) { 49 | return; 50 | } 51 | if (Array.isArray(value)) { 52 | value.forEach((v) => serializeFormDataPair(data, key, v)); 53 | } else { 54 | serializeFormDataPair(data, key, value); 55 | } 56 | }); 57 | 58 | return data; 59 | }, 60 | }; 61 | 62 | export const jsonBodySerializer = { 63 | bodySerializer: (body: T): string => 64 | JSON.stringify(body, (_key, value) => 65 | typeof value === 'bigint' ? value.toString() : value, 66 | ), 67 | }; 68 | 69 | export const urlSearchParamsBodySerializer = { 70 | bodySerializer: | Array>>( 71 | body: T, 72 | ): string => { 73 | const data = new URLSearchParams(); 74 | 75 | Object.entries(body).forEach(([key, value]) => { 76 | if (value === undefined || value === null) { 77 | return; 78 | } 79 | if (Array.isArray(value)) { 80 | value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); 81 | } else { 82 | serializeUrlSearchParamsPair(data, key, value); 83 | } 84 | }); 85 | 86 | return data.toString(); 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /Web/test/src/boogieloops/TestServer.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web 2 | 3 | import io.undertow.Undertow 4 | import scala.util.Random 5 | 6 | /** 7 | * Test server for running boogieloops.web API tests 8 | * 9 | * Provides a simple server setup for integration testing with automatic port selection 10 | * and proper lifecycle management. 11 | */ 12 | object TestServer { 13 | 14 | @volatile private var server: Option[Undertow] = None 15 | @volatile private var currentPort: Option[Int] = None 16 | @volatile private var currentRoutes: Option[TestUserCrudAPI] = None 17 | 18 | // Find an available port 19 | private def findAvailablePort(): Int = { 20 | val basePort = 18080 21 | val maxAttempts = 100 22 | 23 | val availablePort = (0 until maxAttempts).iterator.map { _ => 24 | val port = basePort + Random.nextInt(1000) 25 | try { 26 | val testServer = java.net.ServerSocket(port) 27 | testServer.close() 28 | Some(port) 29 | } catch { 30 | case _: java.io.IOException => None // Port is in use, try next 31 | } 32 | }.collectFirst { case Some(port) => port } 33 | 34 | availablePort.getOrElse( 35 | throw new RuntimeException("Could not find an available port for testing") 36 | ) 37 | } 38 | 39 | def startServer(): (String, TestUserCrudAPI) = this.synchronized { 40 | if (server.isEmpty) { 41 | val port = findAvailablePort() 42 | val routes = new TestUserCrudAPI() 43 | 44 | val newServer = Undertow.builder 45 | .addHttpListener(port, "localhost") 46 | .setHandler(routes.defaultHandler) 47 | .build 48 | 49 | newServer.start() 50 | server = Some(newServer) 51 | currentPort = Some(port) 52 | currentRoutes = Some(routes) 53 | 54 | // Give server time to start 55 | Thread.sleep(200) 56 | } 57 | 58 | val port = currentPort.getOrElse(throw new RuntimeException("Server port not available")) 59 | val routes = currentRoutes.getOrElse(throw new RuntimeException("Server routes not available")) 60 | 61 | (s"http://localhost:$port", routes) 62 | } 63 | 64 | def stopServer(): Unit = this.synchronized { 65 | server.foreach(_.stop()) 66 | server = None 67 | currentPort = None 68 | currentRoutes = None 69 | } 70 | 71 | def withServer[T](f: (String, TestUserCrudAPI) => T): T = { 72 | val (host, routes) = startServer() 73 | try { 74 | routes.resetUsers() // Reset state for clean tests 75 | f(host, routes) 76 | } finally { 77 | stopServer() 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/primitives/BooleanSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.primitives 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Boolean schema type with JSON Schema 2020-12 validation keywords 8 | */ 9 | case class BooleanSchema( 10 | const: Option[Boolean] = None 11 | ) extends Schema { 12 | 13 | override def toJsonSchema: ujson.Value = { 14 | val schema = ujson.Obj("type" -> ujson.Str("boolean")) 15 | 16 | const.foreach(c => schema("const") = ujson.Bool(c)) 17 | 18 | title.foreach(t => schema("title") = ujson.Str(t)) 19 | description.foreach(d => schema("description") = ujson.Str(d)) 20 | default.foreach(d => schema("default") = d) 21 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 22 | 23 | schema 24 | } 25 | 26 | /** 27 | * Validate a ujson.Value against this boolean schema 28 | */ 29 | def validate(value: ujson.Value): ValidationResult = { 30 | validate(value, ValidationContext()) 31 | } 32 | 33 | /** 34 | * Validate a ujson.Value against this boolean schema with context 35 | */ 36 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 37 | // Type check for ujson.Bool 38 | value match { 39 | case ujson.Bool(booleanValue) => 40 | // Inline validation logic 41 | val constErrors = const.fold(List.empty[boogieloops.schema.ValidationError]) { c => 42 | if (booleanValue != c) 43 | List(boogieloops.schema.ValidationError.TypeMismatch(c.toString, booleanValue.toString, context.path)) 44 | else Nil 45 | } 46 | 47 | if (constErrors.isEmpty) { 48 | ValidationResult.valid() 49 | } else { 50 | ValidationResult.invalid(constErrors) 51 | } 52 | case _ => 53 | // Non-boolean ujson.Value type - return TypeMismatch error 54 | val error = boogieloops.schema.ValidationError.TypeMismatch("boolean", getValueType(value), context.path) 55 | ValidationResult.invalid(error) 56 | } 57 | } 58 | 59 | /** 60 | * Get string representation of ujson.Value type for error messages 61 | */ 62 | private def getValueType(value: ujson.Value): String = { 63 | value match { 64 | case _: ujson.Str => "string" 65 | case _: ujson.Num => "number" 66 | case _: ujson.Bool => "boolean" 67 | case ujson.Null => "null" 68 | case _: ujson.Arr => "array" 69 | case _: ujson.Obj => "object" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.beads/README.md: -------------------------------------------------------------------------------- 1 | # Beads - AI-Native Issue Tracking 2 | 3 | Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. 4 | 5 | ## What is Beads? 6 | 7 | Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. 8 | 9 | **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) 10 | 11 | ## Quick Start 12 | 13 | ### Essential Commands 14 | 15 | ```bash 16 | # Create new issues 17 | bd create "Add user authentication" 18 | 19 | # View all issues 20 | bd list 21 | 22 | # View issue details 23 | bd show 24 | 25 | # Update issue status 26 | bd update --status in_progress 27 | bd update --status done 28 | 29 | # Sync with git remote 30 | bd sync 31 | ``` 32 | 33 | ### Working with Issues 34 | 35 | Issues in Beads are: 36 | - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code 37 | - **AI-friendly**: CLI-first design works perfectly with AI coding agents 38 | - **Branch-aware**: Issues can follow your branch workflow 39 | - **Always in sync**: Auto-syncs with your commits 40 | 41 | ## Why Beads? 42 | 43 | ✨ **AI-Native Design** 44 | - Built specifically for AI-assisted development workflows 45 | - CLI-first interface works seamlessly with AI coding agents 46 | - No context switching to web UIs 47 | 48 | 🚀 **Developer Focused** 49 | - Issues live in your repo, right next to your code 50 | - Works offline, syncs when you push 51 | - Fast, lightweight, and stays out of your way 52 | 53 | 🔧 **Git Integration** 54 | - Automatic sync with git commits 55 | - Branch-aware issue tracking 56 | - Intelligent JSONL merge resolution 57 | 58 | ## Get Started with Beads 59 | 60 | Try Beads in your own projects: 61 | 62 | ```bash 63 | # Install Beads 64 | curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash 65 | 66 | # Initialize in your repo 67 | bd init 68 | 69 | # Create your first issue 70 | bd create "Try out Beads" 71 | ``` 72 | 73 | ## Learn More 74 | 75 | - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) 76 | - **Quick Start Guide**: Run `bd quickstart` 77 | - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) 78 | 79 | --- 80 | 81 | *Beads: Issue tracking that moves at the speed of thought* ⚡ 82 | -------------------------------------------------------------------------------- /AI/src/main/scala/boogieloops/Providers/Http11Client.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.ai.providers 2 | 3 | import java.net.URI 4 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 5 | import java.time.Duration 6 | import boogieloops.ai.SchemaError 7 | import scala.util.{Try, Success, Failure} 8 | 9 | /** 10 | * HTTP/1.1 client implementation for servers that don't support HTTP/2. 11 | * This is a workaround for requests-scala 0.9.0 proxy issues. 12 | */ 13 | object Http11Client { 14 | 15 | private val client = HttpClient.newBuilder() 16 | .version(HttpClient.Version.HTTP_1_1) 17 | .connectTimeout(Duration.ofSeconds(15)) 18 | .build() 19 | 20 | def post( 21 | url: String, 22 | headers: Map[String, String], 23 | body: String, 24 | readTimeout: Int = 60000 25 | ): Either[SchemaError, String] = { 26 | Try { 27 | val requestBuilder = HttpRequest.newBuilder() 28 | .uri(URI.create(url)) 29 | .timeout(Duration.ofMillis(readTimeout)) 30 | .POST(HttpRequest.BodyPublishers.ofString(body)) 31 | 32 | headers.foreach { case (k, v) => requestBuilder.header(k, v) } 33 | 34 | val request = requestBuilder.build() 35 | val response = client.send(request, HttpResponse.BodyHandlers.ofString()) 36 | 37 | if (response.statusCode() >= 400) { 38 | Left(SchemaError.NetworkError( 39 | message = s"HTTP ${response.statusCode()}: ${response.body()}", 40 | statusCode = Some(response.statusCode()) 41 | )) 42 | } else { 43 | Right(response.body()) 44 | } 45 | } match { 46 | case Success(result) => result 47 | case Failure(ex: java.net.http.HttpTimeoutException) => 48 | Left(SchemaError.NetworkError(s"Request to $url timed out: ${ex.getMessage}")) 49 | case Failure(ex: java.net.ConnectException) => 50 | Left(SchemaError.NetworkError(s"Failed to connect to $url: ${ex.getMessage}")) 51 | case Failure(ex) => 52 | // Enhanced error logging for debugging 53 | ex.printStackTrace() 54 | val errorMsg = ex match { 55 | case e: java.net.UnknownHostException => 56 | s"Unknown host: ${e.getMessage} in url $url" 57 | case e: java.net.ConnectException => 58 | s"Connection failed: ${e.getMessage} to $url" 59 | case e: java.net.http.HttpTimeoutException => 60 | s"Request timeout: ${e.getMessage} for $url" 61 | case e => 62 | s"Network request failed: ${e.getClass.getSimpleName}: ${e.getMessage}" 63 | } 64 | Left(SchemaError.NetworkError(errorMsg)) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/generators/OpenAPIGenerator.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.generators 2 | 3 | import boogieloops.web.* 4 | import boogieloops.web.openapi.models.* 5 | import boogieloops.web.openapi.config.* 6 | 7 | /** 8 | * Main OpenAPI 3.1.1 document generator 9 | */ 10 | object OpenAPIGenerator { 11 | 12 | // scalafix:off DisableSyntax.var 13 | // Disabling because mutable cache is needed for performance optimization - avoids regenerating 14 | // the OpenAPI document on every request when the registry hasn't changed 15 | @volatile private var _documentCache: Option[(OpenAPIDocument, Int)] = None 16 | // scalafix:on DisableSyntax.var 17 | 18 | /** 19 | * Generate complete OpenAPI 3.1.1 document from registered routes 20 | */ 21 | def generateDocument(config: OpenAPIConfig = OpenAPIConfig()): OpenAPIDocument = { 22 | val registryHash = RouteSchemaRegistry.getAll.hashCode() 23 | 24 | // Use cache if registry hasn't changed 25 | _documentCache match { 26 | case Some((doc, hash)) if hash == registryHash => doc 27 | case _ => 28 | val newDoc = generateFreshDocument(config) 29 | _documentCache = Some((newDoc, registryHash)) 30 | newDoc 31 | } 32 | } 33 | 34 | /** 35 | * Generate fresh OpenAPI document from current registry state 36 | */ 37 | private def generateFreshDocument(config: OpenAPIConfig): OpenAPIDocument = { 38 | val allRoutes = RouteSchemaRegistry.getAll 39 | 40 | OpenAPIDocument( 41 | openapi = "3.1.1", 42 | info = InfoObject( 43 | title = config.title, 44 | summary = config.summary, 45 | description = config.description, 46 | version = config.version, 47 | termsOfService = config.termsOfService, 48 | contact = config.contact, 49 | license = config.license 50 | ), 51 | jsonSchemaDialect = config.jsonSchemaDialect, 52 | servers = if (config.servers.nonEmpty) Some(config.servers) else None, 53 | paths = if (allRoutes.nonEmpty) 54 | Some(PathsGenerator.convertPathsFromRegistry(allRoutes, config)) 55 | else None, 56 | components = if (config.extractComponents && allRoutes.nonEmpty) 57 | Some(ComponentsGenerator.extractComponents(allRoutes, config)) 58 | else None, 59 | tags = if (allRoutes.nonEmpty) Some(TagGenerator.extractUniqueTags(allRoutes)) else None, 60 | externalDocs = config.externalDocs 61 | ) 62 | } 63 | 64 | /** 65 | * Clear the document cache (useful for testing or manual refresh) 66 | */ 67 | def clearCache(): Unit = { 68 | _documentCache = None 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/modifiers/IdSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.modifiers 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Wrapper for adding $id metadata to a schema 8 | * 9 | * According to JSON Schema 2020-12, $id is used to declare a unique identifier 10 | * for the schema. It serves as the base URI for resolving relative references. 11 | */ 12 | case class IdSchema[T <: Schema]( 13 | underlying: T, 14 | idValue: String 15 | ) extends Schema { 16 | 17 | override def $id: Option[String] = Some(idValue) 18 | 19 | // Delegate all other core vocabulary to underlying schema 20 | override def $schema: Option[String] = underlying.$schema 21 | override def $ref: Option[String] = underlying.$ref 22 | override def $defs: Option[Map[String, Schema]] = underlying.$defs 23 | override def $dynamicRef: Option[String] = underlying.$dynamicRef 24 | override def $dynamicAnchor: Option[String] = underlying.$dynamicAnchor 25 | override def $vocabulary: Option[Map[String, Boolean]] = underlying.$vocabulary 26 | override def $comment: Option[String] = underlying.$comment 27 | 28 | // Delegate all metadata to underlying schema 29 | override def title: Option[String] = underlying.title 30 | override def description: Option[String] = underlying.description 31 | override def default: Option[ujson.Value] = underlying.default 32 | override def examples: Option[List[ujson.Value]] = underlying.examples 33 | override def readOnly: Option[Boolean] = underlying.readOnly 34 | override def writeOnly: Option[Boolean] = underlying.writeOnly 35 | override def deprecated: Option[Boolean] = underlying.deprecated 36 | 37 | override def toJsonSchema: ujson.Value = { 38 | val base = underlying.toJsonSchema 39 | base("$id") = ujson.Str(idValue) 40 | base 41 | } 42 | 43 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 44 | underlying.validate(value, context) 45 | } 46 | 47 | // Override modifier methods to maintain chaining 48 | override def optional: Schema = boogieloops.schema.OptionalSchema(this) 49 | override def nullable: Schema = boogieloops.schema.NullableSchema(this) 50 | override def withDefault(value: ujson.Value): Schema = boogieloops.schema.DefaultSchema(this, value) 51 | 52 | // Override with* methods to preserve $id while adding other metadata 53 | override def withTitle(title: String): Schema = TitleSchema(this, title) 54 | override def withDescription(desc: String): Schema = DescriptionSchema(this, desc) 55 | override def withSchema(schema: String): Schema = SchemaModifier(this, schema) 56 | override def withDefs(defs: (String, Schema)*): Schema = DefsSchema(Some(this), defs.toMap) 57 | } 58 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/modifiers/SchemaModifier.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.modifiers 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Wrapper for adding $schema metadata to a schema 8 | * 9 | * According to JSON Schema 2020-12, $schema is used to declare which version 10 | * of the JSON Schema specification the schema is written against. 11 | */ 12 | case class SchemaModifier[T <: Schema]( 13 | underlying: T, 14 | schemaValue: String 15 | ) extends Schema { 16 | 17 | override def $schema: Option[String] = Some(schemaValue) 18 | 19 | // Delegate all other core vocabulary to underlying schema 20 | override def $id: Option[String] = underlying.$id 21 | override def $ref: Option[String] = underlying.$ref 22 | override def $defs: Option[Map[String, Schema]] = underlying.$defs 23 | override def $dynamicRef: Option[String] = underlying.$dynamicRef 24 | override def $dynamicAnchor: Option[String] = underlying.$dynamicAnchor 25 | override def $vocabulary: Option[Map[String, Boolean]] = underlying.$vocabulary 26 | override def $comment: Option[String] = underlying.$comment 27 | 28 | // Delegate all metadata to underlying schema 29 | override def title: Option[String] = underlying.title 30 | override def description: Option[String] = underlying.description 31 | override def default: Option[ujson.Value] = underlying.default 32 | override def examples: Option[List[ujson.Value]] = underlying.examples 33 | override def readOnly: Option[Boolean] = underlying.readOnly 34 | override def writeOnly: Option[Boolean] = underlying.writeOnly 35 | override def deprecated: Option[Boolean] = underlying.deprecated 36 | 37 | override def toJsonSchema: ujson.Value = { 38 | val base = underlying.toJsonSchema 39 | base("$schema") = ujson.Str(schemaValue) 40 | base 41 | } 42 | 43 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 44 | underlying.validate(value, context) 45 | } 46 | 47 | // Override modifier methods to maintain chaining 48 | override def optional: Schema = boogieloops.schema.OptionalSchema(this) 49 | override def nullable: Schema = boogieloops.schema.NullableSchema(this) 50 | override def withDefault(value: ujson.Value): Schema = boogieloops.schema.DefaultSchema(this, value) 51 | 52 | // Override with* methods to preserve $schema while adding other metadata 53 | override def withTitle(title: String): Schema = TitleSchema(this, title) 54 | override def withDescription(desc: String): Schema = DescriptionSchema(this, desc) 55 | override def withId(id: String): Schema = IdSchema(this, id) 56 | override def withDefs(defs: (String, Schema)*): Schema = DefsSchema(Some(this), defs.toMap) 57 | } 58 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/modifiers/TitleSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.modifiers 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Wrapper for adding title metadata to a schema 8 | * 9 | * According to JSON Schema 2020-12, title provides a short description of the instance 10 | * and is primarily used for documentation and user interface purposes. 11 | */ 12 | case class TitleSchema[T <: Schema]( 13 | underlying: T, 14 | titleValue: String 15 | ) extends Schema { 16 | 17 | override def title: Option[String] = Some(titleValue) 18 | 19 | // Delegate all other metadata to underlying schema 20 | override def description: Option[String] = underlying.description 21 | override def default: Option[ujson.Value] = underlying.default 22 | override def examples: Option[List[ujson.Value]] = underlying.examples 23 | override def readOnly: Option[Boolean] = underlying.readOnly 24 | override def writeOnly: Option[Boolean] = underlying.writeOnly 25 | override def deprecated: Option[Boolean] = underlying.deprecated 26 | 27 | // Delegate core vocabulary to underlying schema 28 | override def $schema: Option[String] = underlying.$schema 29 | override def $id: Option[String] = underlying.$id 30 | override def $ref: Option[String] = underlying.$ref 31 | override def $defs: Option[Map[String, Schema]] = underlying.$defs 32 | override def $dynamicRef: Option[String] = underlying.$dynamicRef 33 | override def $dynamicAnchor: Option[String] = underlying.$dynamicAnchor 34 | override def $vocabulary: Option[Map[String, Boolean]] = underlying.$vocabulary 35 | override def $comment: Option[String] = underlying.$comment 36 | 37 | override def toJsonSchema: ujson.Value = { 38 | val base = underlying.toJsonSchema 39 | base("title") = ujson.Str(titleValue) 40 | base 41 | } 42 | 43 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 44 | underlying.validate(value, context) 45 | } 46 | 47 | // Override modifier methods to maintain chaining 48 | override def optional: Schema = boogieloops.schema.OptionalSchema(this) 49 | override def nullable: Schema = boogieloops.schema.NullableSchema(this) 50 | override def withDefault(value: ujson.Value): Schema = boogieloops.schema.DefaultSchema(this, value) 51 | 52 | // Override with* methods to preserve title while adding other metadata 53 | override def withDescription(desc: String): Schema = DescriptionSchema(this, desc) 54 | override def withSchema(schema: String): Schema = SchemaModifier(this, schema) 55 | override def withId(id: String): Schema = IdSchema(this, id) 56 | override def withDefs(defs: (String, Schema)*): Schema = DefsSchema(Some(this), defs.toMap) 57 | } 58 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/modifiers/DescriptionSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.modifiers 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Wrapper for adding description metadata to a schema 8 | * 9 | * According to JSON Schema 2020-12, description provides a more detailed explanation 10 | * of the purpose of the instance described by the schema. 11 | */ 12 | case class DescriptionSchema[T <: Schema]( 13 | underlying: T, 14 | descriptionValue: String 15 | ) extends Schema { 16 | 17 | override def description: Option[String] = Some(descriptionValue) 18 | 19 | // Delegate all other metadata to underlying schema 20 | override def title: Option[String] = underlying.title 21 | override def default: Option[ujson.Value] = underlying.default 22 | override def examples: Option[List[ujson.Value]] = underlying.examples 23 | override def readOnly: Option[Boolean] = underlying.readOnly 24 | override def writeOnly: Option[Boolean] = underlying.writeOnly 25 | override def deprecated: Option[Boolean] = underlying.deprecated 26 | 27 | // Delegate core vocabulary to underlying schema 28 | override def $schema: Option[String] = underlying.$schema 29 | override def $id: Option[String] = underlying.$id 30 | override def $ref: Option[String] = underlying.$ref 31 | override def $defs: Option[Map[String, Schema]] = underlying.$defs 32 | override def $dynamicRef: Option[String] = underlying.$dynamicRef 33 | override def $dynamicAnchor: Option[String] = underlying.$dynamicAnchor 34 | override def $vocabulary: Option[Map[String, Boolean]] = underlying.$vocabulary 35 | override def $comment: Option[String] = underlying.$comment 36 | 37 | override def toJsonSchema: ujson.Value = { 38 | val base = underlying.toJsonSchema 39 | base("description") = ujson.Str(descriptionValue) 40 | base 41 | } 42 | 43 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 44 | underlying.validate(value, context) 45 | } 46 | 47 | // Override modifier methods to maintain chaining 48 | override def optional: Schema = boogieloops.schema.OptionalSchema(this) 49 | override def nullable: Schema = boogieloops.schema.NullableSchema(this) 50 | override def withDefault(value: ujson.Value): Schema = boogieloops.schema.DefaultSchema(this, value) 51 | 52 | // Override with* methods to preserve description while adding other metadata 53 | override def withTitle(title: String): Schema = TitleSchema(this, title) 54 | override def withSchema(schema: String): Schema = SchemaModifier(this, schema) 55 | override def withId(id: String): Schema = IdSchema(this, id) 56 | override def withDefs(defs: (String, Schema)*): Schema = DefsSchema(Some(this), defs.toMap) 57 | } 58 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/modifiers/ExamplesSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.modifiers 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Wrapper for adding examples metadata to a schema 8 | * 9 | * According to JSON Schema 2020-12, examples provides sample JSON values 10 | * that are intended to validate against the schema. 11 | */ 12 | case class ExamplesSchema[T <: Schema]( 13 | underlying: T, 14 | examplesValue: List[ujson.Value] 15 | ) extends Schema { 16 | 17 | override def examples: Option[List[ujson.Value]] = Some(examplesValue) 18 | 19 | // Delegate all other metadata to underlying schema 20 | override def title: Option[String] = underlying.title 21 | override def description: Option[String] = underlying.description 22 | override def default: Option[ujson.Value] = underlying.default 23 | override def readOnly: Option[Boolean] = underlying.readOnly 24 | override def writeOnly: Option[Boolean] = underlying.writeOnly 25 | override def deprecated: Option[Boolean] = underlying.deprecated 26 | 27 | // Delegate all core vocabulary to underlying schema 28 | override def $schema: Option[String] = underlying.$schema 29 | override def $id: Option[String] = underlying.$id 30 | override def $ref: Option[String] = underlying.$ref 31 | override def $defs: Option[Map[String, Schema]] = underlying.$defs 32 | override def $dynamicRef: Option[String] = underlying.$dynamicRef 33 | override def $dynamicAnchor: Option[String] = underlying.$dynamicAnchor 34 | override def $vocabulary: Option[Map[String, Boolean]] = underlying.$vocabulary 35 | override def $comment: Option[String] = underlying.$comment 36 | 37 | override def toJsonSchema: ujson.Value = { 38 | val base = underlying.toJsonSchema 39 | if (examplesValue.nonEmpty) { 40 | base("examples") = ujson.Arr(examplesValue*) 41 | } 42 | base 43 | } 44 | 45 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 46 | underlying.validate(value, context) 47 | } 48 | 49 | // Override modifier methods to maintain chaining 50 | override def optional: Schema = boogieloops.schema.OptionalSchema(this) 51 | override def nullable: Schema = boogieloops.schema.NullableSchema(this) 52 | override def withDefault(value: ujson.Value): Schema = boogieloops.schema.DefaultSchema(this, value) 53 | 54 | // Override with* methods to preserve examples while adding other metadata 55 | override def withTitle(title: String): Schema = TitleSchema(this, title) 56 | override def withDescription(desc: String): Schema = DescriptionSchema(this, desc) 57 | override def withSchema(schema: String): Schema = SchemaModifier(this, schema) 58 | override def withId(id: String): Schema = IdSchema(this, id) 59 | override def withDefs(defs: (String, Schema)*): Schema = DefsSchema(Some(this), defs.toMap) 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Rename this to whatever you want to call the workflow, or leave as is 2 | name: Release 3 | 4 | # Manual trigger 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | version_bump: 9 | description: "Version bump type" 10 | required: true 11 | default: "patch" 12 | type: choice 13 | options: 14 | - patch 15 | - minor 16 | - major 17 | 18 | jobs: 19 | release: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: write # Required for creating releases and tags 23 | pull-requests: write # Required if you want to create PRs 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 # Important for correct version calculation 28 | 29 | - name: Setup Java 30 | uses: actions/setup-java@v4 31 | with: 32 | java-version: "23" 33 | distribution: "temurin" 34 | 35 | # Install Mill build tool 36 | - name: Make Assembly 37 | run: make assembly 38 | 39 | # Configure Git user - if you want to use a different user, or leave as is to use the default git bot user 40 | - name: Configure Git 41 | run: | 42 | git config --global user.name 'github-actions[bot]' 43 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 44 | 45 | # see .xrelease/yml 46 | # xrelease will: 47 | # 1. Update version in package.json 48 | # 2. Generate/update CHANGELOG.md 49 | # 3. Commit these changes with message "chore(release): x.y.z" 50 | # 4. Create and push git tag vx.y.z 51 | # 5. Create GitHub release from the changelog 52 | - name: Create Release 53 | run: npx xrelease create --bump ${{ inputs.version_bump }} 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | # Sync version in documentation files 58 | - name: Sync Doc Versions 59 | run: | 60 | VERSION=$(node -p "require('./package.json').version") 61 | echo "Syncing docs to version $VERSION" 62 | 63 | # Update Mill format: mvn"dev.boogieloop::module:X.Y.Z" 64 | find . -name "*.md" -type f -exec sed -i "s|dev\.boogieloop::\([^:]*\):[0-9]\+\.[0-9]\+\.[0-9]\+|dev.boogieloop::\1:$VERSION|g" {} + 65 | 66 | # Update SBT format: "dev.boogieloop" %% "module" % "X.Y.Z" 67 | find . -name "*.md" -type f -exec sed -i "s|\"dev\.boogieloop\" %% \"\([^\"]*\)\" % \"[0-9]\+\.[0-9]\+\.[0-9]\+\"|\"dev.boogieloop\" %% \"\1\" % \"$VERSION\"|g" {} + 68 | 69 | - name: Commit Doc Version Updates 70 | run: | 71 | if git diff --quiet; then 72 | echo "No doc version changes to commit" 73 | else 74 | git add -A 75 | git commit -m "docs: sync installation versions to $(node -p "require('./package.json').version")" 76 | git push 77 | fi 78 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/RouteSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web 2 | 3 | import _root_.boogieloops.schema.Schema 4 | 5 | /** 6 | * Core schema definition classes for boogieloops.web module 7 | * 8 | * This file contains the fundamental data structures for defining API route schemas for use with custom Cask endpoints that 9 | * provide automatic validation. 10 | */ 11 | 12 | /** 13 | * Represents a single API response schema with description and optional headers 14 | */ 15 | case class ApiResponse( 16 | description: String, 17 | schema: Schema, 18 | headers: Option[Schema] = None 19 | ) 20 | 21 | /** 22 | * Security requirement definitions for OpenAPI spec 23 | */ 24 | sealed trait SecurityRequirement 25 | 26 | object SecurityRequirement { 27 | case class ApiKey(name: String = "apiKey", in: String = "header") extends SecurityRequirement 28 | case class Bearer(format: String = "JWT") extends SecurityRequirement 29 | case class Basic() extends SecurityRequirement 30 | // TODO: add support for OAuth 2.1 dynamic client registration 31 | case class OAuth2(flows: Map[String, String] = Map.empty) extends SecurityRequirement 32 | 33 | def apiKey(name: String = "apiKey", in: String = "header"): ApiKey = ApiKey(name, in) 34 | def bearer(format: String = "JWT"): Bearer = Bearer(format) 35 | def basic(): Basic = Basic() 36 | def oauth2(flows: Map[String, String] = Map.empty): OAuth2 = OAuth2(flows) 37 | } 38 | 39 | /** 40 | * Status code type for response definitions 41 | */ 42 | type StatusCode = Int | String 43 | 44 | /** 45 | * Complete route schema definition containing validation and documentation information 46 | */ 47 | case class RouteSchema( 48 | description: Option[String] = None, 49 | tags: List[String] = List.empty, 50 | summary: Option[String] = None, 51 | security: List[SecurityRequirement] = List.empty, 52 | params: Option[Schema] = None, 53 | body: Option[Schema] = None, 54 | query: Option[Schema] = None, 55 | headers: Option[Schema] = None, 56 | responses: Map[StatusCode, ApiResponse] = Map.empty 57 | ) 58 | 59 | /** 60 | * Simple registry for storing route schemas for OpenAPI generation 61 | */ 62 | object RouteSchemaRegistry { 63 | 64 | // scalafix:off DisableSyntax.var 65 | // Disabling because a mutable registry is needed to dynamically register route schemas at runtime 66 | // as Cask endpoints are discovered and initialized 67 | @volatile private var _schemas = Map[String, RouteSchema]() 68 | // scalafix:on DisableSyntax.var 69 | 70 | /** 71 | * Register a route schema with a given path and method 72 | */ 73 | def register(path: String, method: String, schema: RouteSchema): Unit = { 74 | val key = s"$method:$path" 75 | _schemas = _schemas.updated(key, schema) 76 | } 77 | 78 | /** 79 | * Get all registered schemas 80 | */ 81 | def getAll: Map[String, RouteSchema] = _schemas.toMap 82 | 83 | /** 84 | * Clear all registered schemas (useful for testing) 85 | */ 86 | def clear(): Unit = { 87 | _schemas = Map.empty 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/derivation/CollectionSchemas.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.derivation 2 | 3 | import scala.compiletime.* 4 | import scala.annotation.unused 5 | import boogieloops.schema.* 6 | import boogieloops.schema.complex.* 7 | 8 | /** 9 | * Schema derivation for Scala collection types 10 | * 11 | * Provides given instances for Map, Set, Vector and other collection types 12 | * to integrate with the Schema derivation system. 13 | */ 14 | object CollectionSchemas { 15 | 16 | /** 17 | * Schematic derivation for Map[K, V] types 18 | * 19 | * Maps are represented as JSON objects in two ways: 20 | * - Map[String, V]: Uses additionalPropertiesSchema to allow any string key with V schema 21 | * - Map[K, V] where K != String: Uses patternProperties with ".*" pattern for any key 22 | */ 23 | inline given [K, V](using @unused kSchema: Schematic[K], vSchema: Schematic[V]): Schematic[Map[K, V]] = { 24 | // Use compile-time type checking to determine key type 25 | inline erasedValue[K] match { 26 | case _: String => 27 | // For Map[String, V], use additionalPropertiesSchema 28 | Schematic.instance(ObjectSchema( 29 | additionalPropertiesSchema = Some(vSchema.schema) 30 | )) 31 | case _ => 32 | // For Map[K, V] where K != String, use patternProperties 33 | val keyDescription = inline erasedValue[K] match { 34 | case _: Int => "integer" 35 | case _: Long => "long" 36 | case _: Double => "double" 37 | case _: Float => "float" 38 | case _: Boolean => "boolean" 39 | case _ => "unknown" 40 | } 41 | 42 | Schematic.instance(ObjectSchema( 43 | patternProperties = Map(".*" -> vSchema.schema) 44 | ).withDescription(s"Map with ${keyDescription} keys")) 45 | } 46 | } 47 | 48 | /** 49 | * Schematic derivation for Set[T] types 50 | * 51 | * Sets are represented as JSON arrays with uniqueItems=true to ensure 52 | * no duplicate values are allowed in the schema validation. 53 | */ 54 | inline given [T](using tSchema: Schematic[T]): Schematic[Set[T]] = { 55 | Schematic.instance(ArraySchema( 56 | items = tSchema.schema, 57 | uniqueItems = Some(true) 58 | )) 59 | } 60 | 61 | /** 62 | * Schematic derivation for Vector[T] types 63 | * 64 | * Vectors are represented as JSON arrays without uniqueItems constraint, 65 | * allowing duplicate values and preserving order. 66 | */ 67 | inline given [T](using tSchema: Schematic[T]): Schematic[Vector[T]] = { 68 | Schematic.instance(ArraySchema( 69 | items = tSchema.schema 70 | // uniqueItems is None (default) to allow duplicates 71 | )) 72 | } 73 | 74 | /** 75 | * Helper to get type name for descriptions 76 | */ 77 | private inline def typeNameOf[T]: String = { 78 | inline erasedValue[T] match { 79 | case _: Int => "Int" 80 | case _: Long => "Long" 81 | case _: Double => "Double" 82 | case _: Float => "Float" 83 | case _: Boolean => "Boolean" 84 | case _ => "Unknown" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/examples/OpenAPITest.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.examples 2 | 3 | import boogieloops.web.* 4 | import boogieloops.web.openapi.config.OpenAPIConfig 5 | import boogieloops.web.openapi.generators.OpenAPIGenerator 6 | import _root_.boogieloops.schema.* 7 | import upickle.default.* 8 | 9 | /** 10 | * Simple test for OpenAPI 3.1.1 generation functionality 11 | */ 12 | object OpenAPITest { 13 | 14 | def main(args: Array[String]): Unit = { 15 | // Clear any existing routes 16 | RouteSchemaRegistry.clear() 17 | 18 | // Create some test schemas 19 | val userSchema = bl.Object( 20 | "name" -> bl.String(minLength = Some(1)), 21 | "email" -> bl.String(format = Some("email")), 22 | "age" -> bl.Integer(minimum = Some(0)) 23 | ) 24 | 25 | val responseSchema = bl.Object( 26 | "id" -> bl.String(), 27 | "name" -> bl.String(), 28 | "email" -> bl.String() 29 | ) 30 | 31 | // Register some test routes 32 | RouteSchemaRegistry.register( 33 | "/users", 34 | "POST", 35 | RouteSchema( 36 | summary = Some("Create user"), 37 | description = Some("Create a new user with validation"), 38 | tags = List("users", "creation"), 39 | body = Some(userSchema), 40 | responses = Map( 41 | 201 -> ApiResponse("User created", responseSchema), 42 | 400 -> ApiResponse("Validation error", bl.Object("error" -> bl.String())) 43 | ) 44 | ) 45 | ) 46 | 47 | RouteSchemaRegistry.register( 48 | "/users/{id}", 49 | "GET", 50 | RouteSchema( 51 | summary = Some("Get user"), 52 | description = Some("Retrieve user by ID"), 53 | tags = List("users"), 54 | responses = Map( 55 | 200 -> ApiResponse("User found", responseSchema), 56 | 404 -> ApiResponse("User not found", bl.Object("error" -> bl.String())) 57 | ) 58 | ) 59 | ) 60 | 61 | // Generate OpenAPI document 62 | val config = OpenAPIConfig( 63 | title = "Test API", 64 | summary = Some("OpenAPI 3.1.1 Generation Test"), 65 | description = "Testing boogieloops.web OpenAPI generation with JSON Schema 2020-12", 66 | version = "1.0.0" 67 | ) 68 | 69 | val openAPIDoc = OpenAPIGenerator.generateDocument(config) 70 | 71 | // Output the generated OpenAPI specification 72 | println("🎯 Generated OpenAPI 3.1.1 Document:") 73 | println("=" * 50) 74 | println(write(openAPIDoc, indent = 2)) 75 | println("=" * 50) 76 | 77 | // Verify key components 78 | println(s"OpenAPI Version: ${openAPIDoc.openapi}") 79 | println(s"Title: ${openAPIDoc.info.title}") 80 | println(s"JSON Schema Dialect: ${openAPIDoc.jsonSchemaDialect}") 81 | println(s"Number of paths: ${openAPIDoc.paths.map(_.paths.size).getOrElse(0)}") 82 | println( 83 | s"Number of components: ${openAPIDoc.components.flatMap(_.schemas).map(_.size).getOrElse(0)}" 84 | ) 85 | println(s"Number of tags: ${openAPIDoc.tags.map(_.size).getOrElse(0)}") 86 | 87 | println("\n✅ OpenAPI 3.1.1 generation test completed successfully!") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BoogieLoops: Type-Safe JSON Schema Ecosystem for Scala 3 2 | 3 | [![Build Status](https://img.shields.io/badge/build-passing-brightgreen.svg)](https://github.com/silvabyte/boogieloops) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | An ecosystem of libraries for JSON Schema generation, HTTP validation & Automatic OpenAPI Spec Generation, and LLM integration in Scala 3. Each module can be used independently or together for a complete solution. 7 | 8 | ## Installation 9 | 10 | Choose the modules you need for your project: 11 | 12 | ### Mill 13 | 14 | ```scala 15 | // Just schema validation 16 | mvn"dev.boogieloop::schema:0.5.5" 17 | 18 | // Add HTTP validation for Cask web framework 19 | mvn"dev.boogieloop::schema:0.5.5" 20 | mvn"dev.boogieloop::web:0.5.5" 21 | 22 | // Full ecosystem with LLM agent support 23 | mvn"dev.boogieloop::schema:0.5.5" 24 | mvn"dev.boogieloop::web:0.5.5" 25 | mvn"dev.boogieloop::ai:0.5.5" 26 | ``` 27 | 28 | ### SBT 29 | 30 | ```scala 31 | // Just schema validation 32 | libraryDependencies += "dev.boogieloop" %% "schema" % "0.5.5" 33 | 34 | // Add HTTP validation for Cask web framework 35 | libraryDependencies ++= Seq( 36 | "dev.boogieloop" %% "schema" % "0.5.5", 37 | "dev.boogieloop" %% "web" % "0.5.5" 38 | ) 39 | 40 | // Full ecosystem with LLM agent support 41 | libraryDependencies ++= Seq( 42 | "dev.boogieloop" %% "schema" % "0.5.5", 43 | "dev.boogieloop" %% "web" % "0.5.5", 44 | "dev.boogieloop" %% "ai" % "0.5.5" 45 | ) 46 | ``` 47 | 48 | ## The Ecosystem 49 | 50 | - **schema** (core): Type-safe JSON Schema derivation and validation 51 | - **web**: Schema-first HTTP validation + OpenAPI for Cask 52 | - **ai**: Type-safe LLM agents with structured output 53 | 54 | ## Quick Example 55 | 56 | ```scala 57 | import boogieloops.schema.bl 58 | import boogieloops.schema.derivation.Schema 59 | 60 | // Manual schema definition 61 | val userSchema = bl.Object( 62 | "name" -> bl.String(), 63 | "age" -> bl.Integer(minimum = Some(0)) 64 | ) 65 | 66 | // Or derive from case class 67 | case class User(name: String, age: Int) derives Schema 68 | ``` 69 | 70 | ## Documentation 71 | 72 | - Getting Started: [docs/README.md](./docs/README.md) 73 | - Zero to App: [docs/zero-to-app.md](./docs/zero-to-app.md) 74 | - Schema (core): [docs/schema.md](./docs/schema.md) 75 | - Web: [docs/web.md](./docs/web.md) 76 | - AI: [docs/ai.md](./docs/ai.md) 77 | - Concepts: [docs/concepts.md](./docs/concepts.md) 78 | - Troubleshooting: [docs/troubleshooting.md](./docs/troubleshooting.md) 79 | - TypeScript SDK: [docs/typescript-sdk.md](./docs/typescript-sdk.md) 80 | 81 | ## Running Examples & Commands 82 | 83 | - See all commands: `make help` 84 | - Run examples: `make schema`, `make web`, `make ai` 85 | - AI examples require `OPENAI_API_KEY` (or a local LLM endpoint) 86 | - Test everything: `make test` (override `MODULE=schema|web|ai`) 87 | 88 | AI Disclaimer: This project uses AI assistance for documentation creation as well as code generation for monotonous tasks. All architecture, design and more interesting code creation is done by a [human](https://x.com/MatSilva) 89 | 90 | ## License 91 | 92 | MIT License. See [LICENSE](LICENSE) for details. 93 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/examples/DefaultParameterTest.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.examples 2 | 3 | import boogieloops.schema.* 4 | import boogieloops.schema.derivation.Schematic 5 | 6 | /** 7 | * Example demonstrating Scala case class default parameter detection 8 | * Tests that fields with default values are not marked as required in JSON Schema 9 | */ 10 | 11 | // Test case class with all default parameters 12 | case class AllDefaults( 13 | name: String = "default_name", 14 | age: Int = 25, 15 | active: Boolean = true, 16 | score: Double = 0.0 17 | ) derives boogieloops.schema.derivation.Schematic 18 | 19 | // Test case class with mixed required, optional, and default parameters 20 | case class MixedFields( 21 | requiredField: String, // Required (no default, not Option) 22 | optionalField: Option[String], // Not required (Option type) 23 | defaultField: String = "default_value", // Not required (has default) 24 | defaultInt: Int = 42 // Not required (has default) 25 | ) derives Schematic 26 | 27 | // Test case class with enum defaults (from existing passing test) 28 | enum Color derives Schematic { 29 | case Red, Green, Blue 30 | } 31 | 32 | case class WithEnumDefaults( 33 | theme: Color = Color.Blue, 34 | priority: Int = 1 35 | ) derives Schematic 36 | 37 | object DefaultParameterTest { 38 | def main(args: Array[String]): Unit = { 39 | println("🧪 Testing Scala Case Class Default Parameter Detection") 40 | println("=" * 60) 41 | 42 | // Test 1: All defaults case class 43 | println("\n1. Testing case class with all default parameters:") 44 | testSchematic[AllDefaults]("AllDefaults") 45 | 46 | // Test 2: Mixed fields case class 47 | println("\n2. Testing case class with mixed field types:") 48 | testSchematic[MixedFields]("MixedFields") 49 | 50 | // Test 3: Enum defaults (known working case) 51 | println("\n3. Testing case class with enum defaults:") 52 | testSchematic[WithEnumDefaults]("WithEnumDefaults") 53 | 54 | println("\n✅ Default parameter detection test completed!") 55 | } 56 | 57 | def testSchematic[T](name: String)(using schema: Schematic[T]): Unit = { 58 | val json = schema.schema.toJsonSchema 59 | 60 | println(s"📋 Schema for $name:") 61 | println(ujson.write(json, indent = 2)) 62 | 63 | val requiredFields = if (json.obj.contains("required")) { 64 | json("required").arr.map(_.str).toSet 65 | } else { 66 | Set.empty[String] 67 | } 68 | 69 | println(s"🔍 Required fields: $requiredFields") 70 | 71 | // Analysis based on expected behavior 72 | name match { 73 | case "AllDefaults" => 74 | val expected = Set.empty[String] 75 | val correct = requiredFields == expected 76 | println(s"✓ Expected: $expected, Got: $requiredFields, Correct: $correct") 77 | 78 | case "MixedFields" => 79 | val expected = Set("requiredField") 80 | val correct = requiredFields == expected 81 | println(s"✓ Expected: $expected, Got: $requiredFields, Correct: $correct") 82 | 83 | case "WithEnumDefaults" => 84 | val expected = Set.empty[String] 85 | val correct = requiredFields == expected 86 | println(s"✓ Expected: $expected, Got: $requiredFields, Correct: $correct") 87 | 88 | case _ => 89 | println(s"📊 Analysis: Fields marked as required: $requiredFields") 90 | } 91 | 92 | println() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Auth, AuthToken } from './auth'; 2 | import type { 3 | BodySerializer, 4 | QuerySerializer, 5 | QuerySerializerOptions, 6 | } from './bodySerializer'; 7 | 8 | export interface Client< 9 | RequestFn = never, 10 | Config = unknown, 11 | MethodFn = never, 12 | BuildUrlFn = never, 13 | > { 14 | /** 15 | * Returns the final request URL. 16 | */ 17 | buildUrl: BuildUrlFn; 18 | connect: MethodFn; 19 | delete: MethodFn; 20 | get: MethodFn; 21 | getConfig: () => Config; 22 | head: MethodFn; 23 | options: MethodFn; 24 | patch: MethodFn; 25 | post: MethodFn; 26 | put: MethodFn; 27 | request: RequestFn; 28 | setConfig: (config: Config) => Config; 29 | trace: MethodFn; 30 | } 31 | 32 | export interface Config { 33 | /** 34 | * Auth token or a function returning auth token. The resolved value will be 35 | * added to the request payload as defined by its `security` array. 36 | */ 37 | auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; 38 | /** 39 | * A function for serializing request body parameter. By default, 40 | * {@link JSON.stringify()} will be used. 41 | */ 42 | bodySerializer?: BodySerializer | null; 43 | /** 44 | * An object containing any HTTP headers that you want to pre-populate your 45 | * `Headers` object with. 46 | * 47 | * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} 48 | */ 49 | headers?: 50 | | RequestInit['headers'] 51 | | Record< 52 | string, 53 | | string 54 | | number 55 | | boolean 56 | | (string | number | boolean)[] 57 | | null 58 | | undefined 59 | | unknown 60 | >; 61 | /** 62 | * The request method. 63 | * 64 | * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} 65 | */ 66 | method?: 67 | | 'CONNECT' 68 | | 'DELETE' 69 | | 'GET' 70 | | 'HEAD' 71 | | 'OPTIONS' 72 | | 'PATCH' 73 | | 'POST' 74 | | 'PUT' 75 | | 'TRACE'; 76 | /** 77 | * A function for serializing request query parameters. By default, arrays 78 | * will be exploded in form style, objects will be exploded in deepObject 79 | * style, and reserved characters are percent-encoded. 80 | * 81 | * This method will have no effect if the native `paramsSerializer()` Axios 82 | * API function is used. 83 | * 84 | * {@link https://swagger.io/docs/specification/serialization/#query View examples} 85 | */ 86 | querySerializer?: QuerySerializer | QuerySerializerOptions; 87 | /** 88 | * A function validating request data. This is useful if you want to ensure 89 | * the request conforms to the desired shape, so it can be safely sent to 90 | * the server. 91 | */ 92 | requestValidator?: (data: unknown) => Promise; 93 | /** 94 | * A function transforming response data before it's returned. This is useful 95 | * for post-processing data, e.g. converting ISO strings into Date objects. 96 | */ 97 | responseTransformer?: (data: unknown) => Promise; 98 | /** 99 | * A function validating response data. This is useful if you want to ensure 100 | * the response conforms to the desired shape, so it can be safely passed to 101 | * the transformers and returned to the user. 102 | */ 103 | responseValidator?: (data: unknown) => Promise; 104 | } 105 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/core/params.ts: -------------------------------------------------------------------------------- 1 | type Slot = 'body' | 'headers' | 'path' | 'query'; 2 | 3 | export type Field = 4 | | { 5 | in: Exclude; 6 | key: string; 7 | map?: string; 8 | } 9 | | { 10 | in: Extract; 11 | key?: string; 12 | map?: string; 13 | }; 14 | 15 | export interface Fields { 16 | allowExtra?: Partial>; 17 | args?: ReadonlyArray; 18 | } 19 | 20 | export type FieldsConfig = ReadonlyArray; 21 | 22 | const extraPrefixesMap: Record = { 23 | $body_: 'body', 24 | $headers_: 'headers', 25 | $path_: 'path', 26 | $query_: 'query', 27 | }; 28 | const extraPrefixes = Object.entries(extraPrefixesMap); 29 | 30 | type KeyMap = Map< 31 | string, 32 | { 33 | in: Slot; 34 | map?: string; 35 | } 36 | >; 37 | 38 | const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { 39 | if (!map) { 40 | map = new Map(); 41 | } 42 | 43 | for (const config of fields) { 44 | if ('in' in config) { 45 | if (config.key) { 46 | map.set(config.key, { 47 | in: config.in, 48 | map: config.map, 49 | }); 50 | } 51 | } else if (config.args) { 52 | buildKeyMap(config.args, map); 53 | } 54 | } 55 | 56 | return map; 57 | }; 58 | 59 | interface Params { 60 | body: unknown; 61 | headers: Record; 62 | path: Record; 63 | query: Record; 64 | } 65 | 66 | const stripEmptySlots = (params: Params) => { 67 | for (const [slot, value] of Object.entries(params)) { 68 | if (value && typeof value === 'object' && !Object.keys(value).length) { 69 | delete params[slot as Slot]; 70 | } 71 | } 72 | }; 73 | 74 | export const buildClientParams = ( 75 | args: ReadonlyArray, 76 | fields: FieldsConfig, 77 | ) => { 78 | const params: Params = { 79 | body: {}, 80 | headers: {}, 81 | path: {}, 82 | query: {}, 83 | }; 84 | 85 | const map = buildKeyMap(fields); 86 | 87 | let config: FieldsConfig[number] | undefined; 88 | 89 | for (const [index, arg] of args.entries()) { 90 | if (fields[index]) { 91 | config = fields[index]; 92 | } 93 | 94 | if (!config) { 95 | continue; 96 | } 97 | 98 | if ('in' in config) { 99 | if (config.key) { 100 | const field = map.get(config.key)!; 101 | const name = field.map || config.key; 102 | (params[field.in] as Record)[name] = arg; 103 | } else { 104 | params.body = arg; 105 | } 106 | } else { 107 | for (const [key, value] of Object.entries(arg ?? {})) { 108 | const field = map.get(key); 109 | 110 | if (field) { 111 | const name = field.map || key; 112 | (params[field.in] as Record)[name] = value; 113 | } else { 114 | const extra = extraPrefixes.find(([prefix]) => 115 | key.startsWith(prefix), 116 | ); 117 | 118 | if (extra) { 119 | const [prefix, slot] = extra; 120 | (params[slot] as Record)[ 121 | key.slice(prefix.length) 122 | ] = value; 123 | } else { 124 | for (const [slot, allowed] of Object.entries( 125 | config.allowExtra ?? {}, 126 | )) { 127 | if (allowed) { 128 | (params[slot as Slot] as Record)[key] = value; 129 | break; 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | 138 | stripEmptySlots(params); 139 | 140 | return params; 141 | }; 142 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/generators/SecurityGenerator.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.generators 2 | 3 | import boogieloops.web.* 4 | import boogieloops.web.openapi.models.* 5 | 6 | /** 7 | * Generates OpenAPI security schemes and requirements from boogieloops.web SecurityRequirement 8 | */ 9 | object SecurityGenerator { 10 | 11 | /** 12 | * Extract security schemes from security requirements 13 | */ 14 | def extractSecuritySchemes(securityRequirements: List[SecurityRequirement]) 15 | : Map[String, SecuritySchemeObject] = { 16 | securityRequirements.flatMap(convertToSecurityScheme).toMap 17 | } 18 | 19 | /** 20 | * Convert boogieloops.web SecurityRequirement to OpenAPI SecurityRequirementObject 21 | */ 22 | def convertSecurityRequirement(requirement: SecurityRequirement): SecurityRequirementObject = { 23 | requirement match { 24 | case SecurityRequirement.ApiKey(name, _) => 25 | SecurityRequirementObject(Map(name -> List.empty)) 26 | case SecurityRequirement.Bearer(_) => 27 | SecurityRequirementObject(Map("bearerAuth" -> List.empty)) 28 | case SecurityRequirement.Basic() => 29 | SecurityRequirementObject(Map("basicAuth" -> List.empty)) 30 | case SecurityRequirement.OAuth2(flows) => 31 | SecurityRequirementObject(Map("oauth2" -> flows.keys.toList)) 32 | } 33 | } 34 | 35 | /** 36 | * Convert boogieloops.web SecurityRequirement to OpenAPI SecuritySchemeObject 37 | */ 38 | private def convertToSecurityScheme(requirement: SecurityRequirement) 39 | : Option[(String, SecuritySchemeObject)] = { 40 | requirement match { 41 | case SecurityRequirement.ApiKey(name, in) => 42 | Some( 43 | name -> SecuritySchemeObject( 44 | `type` = "apiKey", 45 | description = Some(s"API Key authentication via $in"), 46 | name = Some(name), 47 | in = Some(in) 48 | ) 49 | ) 50 | 51 | case SecurityRequirement.Bearer(format) => 52 | Some( 53 | "bearerAuth" -> SecuritySchemeObject( 54 | `type` = "http", 55 | description = Some("Bearer token authentication"), 56 | scheme = Some("bearer"), 57 | bearerFormat = Some(format) 58 | ) 59 | ) 60 | 61 | case SecurityRequirement.Basic() => 62 | Some( 63 | "basicAuth" -> SecuritySchemeObject( 64 | `type` = "http", 65 | description = Some("Basic HTTP authentication"), 66 | scheme = Some("basic") 67 | ) 68 | ) 69 | 70 | // TODO: add support for OAuth 2.1 dynamic client registration 71 | case SecurityRequirement.OAuth2(flows) => 72 | Some( 73 | "oauth2" -> SecuritySchemeObject( 74 | `type` = "oauth2", 75 | description = Some("OAuth2 authentication"), 76 | flows = Some(convertOAuthFlows(flows)) 77 | ) 78 | ) 79 | } 80 | } 81 | 82 | /** 83 | * Convert OAuth2 flows from boogieloops.web to OpenAPI format 84 | */ 85 | private def convertOAuthFlows(flows: Map[String, String]): OAuthFlowsObject = { 86 | // Basic OAuth flows conversion - could be enhanced based on actual flow definitions 87 | OAuthFlowsObject( 88 | authorizationCode = flows 89 | .get("authorizationCode") 90 | .map(_ => { 91 | OAuthFlowObject( 92 | authorizationUrl = Some("https://example.com/oauth/authorize"), 93 | tokenUrl = Some("https://example.com/oauth/token"), 94 | scopes = flows 95 | ) 96 | }), 97 | clientCredentials = flows 98 | .get("clientCredentials") 99 | .map(_ => { 100 | OAuthFlowObject( 101 | tokenUrl = Some("https://example.com/oauth/token"), 102 | scopes = flows 103 | ) 104 | }) 105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /docs/web/multipart-and-decorators.md: -------------------------------------------------------------------------------- 1 | # Multipart, Streaming, and Decorators 2 | 3 | This guide covers three production essentials now supported by boogieloops.web: 4 | 5 | - Multipart uploads (body isn’t pre‑consumed by validation) 6 | - Streaming responses (pass through to Cask) 7 | - Route decorators (built‑in and custom) 8 | 9 | All examples below are implemented in: 10 | 11 | - `web/src/main/scala/boogieloops/examples/UploadStreamingAPI.scala` 12 | - `web/src/main/scala/boogieloops/examples/UploadStreamingServer.scala` 13 | 14 | ## Run the Demo Server 15 | 16 | Start the server (blocks): 17 | 18 | ```bash 19 | make example-web-upload 20 | # or customize 21 | PORT=9000 make example-web-upload 22 | ``` 23 | 24 | In another terminal, run the curl demos: 25 | 26 | ```bash 27 | # multipart upload 28 | make example-web-upload-curl-upload 29 | 30 | # streaming 1024 bytes 31 | make example-web-upload-curl-stream 32 | 33 | # decorated route (gzip + custom header) 34 | make example-web-upload-curl-decorated 35 | ``` 36 | 37 | You can override host/port for the curl targets: 38 | 39 | ```bash 40 | WEB_HOST=127.0.0.1 WEB_PORT=9000 make example-web-upload-curl 41 | ``` 42 | 43 | ## Multipart Uploads (Pass‑Through Body) 44 | 45 | When `Content-Type` is not JSON (e.g., `multipart/form-data`), boogieloops.web leaves the request body untouched. You can parse the body downstream using Undertow’s `FormParserFactory`: 46 | 47 | ```scala 48 | @Web.post( 49 | "/demo/upload", 50 | RouteSchema( 51 | summary = Some("Multipart upload"), 52 | description = Some("Accepts multipart/form-data without consuming body"), 53 | tags = List("demo"), 54 | responses = Map(200 -> ApiResponse("OK", _root_.boogieloops.schema.Schema.String())) 55 | ) 56 | ) 57 | def upload(r: ValidatedRequest): String = { 58 | val parser = FormParserFactory.builder().build().createParser(r.original.exchange) 59 | val form = parser.parseBlocking() 60 | // extract fields/files ... 61 | write(ujson.Obj("ok" -> true)) 62 | } 63 | ``` 64 | 65 | ## Streaming Responses 66 | 67 | Return a `cask.Response[geny.Readable]` from your handler; boogieloops.web passes it through unmodified. Example streams `size` bytes: 68 | 69 | ```scala 70 | @Web.get( 71 | "/demo/stream/:size", 72 | RouteSchema( 73 | summary = Some("Streaming response"), 74 | responses = Map(200 -> ApiResponse("OK", _root_.boogieloops.schema.Schema.String())) 75 | ) 76 | ) 77 | def stream(size: String, r: ValidatedRequest): cask.Response[geny.Readable] = { 78 | val n = size.toIntOption.getOrElse(0) 79 | val readable = new geny.Readable { 80 | def readBytesThrough[T](f: java.io.InputStream => T): T = { 81 | val is = new java.io.InputStream { 82 | private var remaining = n 83 | def read(): Int = if (remaining <= 0) -1 else { remaining -= 1; 'a' } 84 | } 85 | try f(is) finally is.close() 86 | } 87 | } 88 | cask.Response(readable, 200, Seq("Content-Type" -> "application/octet-stream")) 89 | } 90 | ``` 91 | 92 | ## Decorators (Built‑in + Custom) 93 | 94 | boogieloops.web routes are standard Cask endpoints, so decorators work the same. You can stack built‑ins like `@cask.decorators.compress()` and custom decorators. Example custom header decorator: 95 | 96 | ```scala 97 | class trace(header: String = "X-Trace") extends scala.annotation.Annotation with cask.router.RawDecorator { 98 | def wrapFunction(ctx: cask.Request, delegate: Delegate) = { 99 | delegate(ctx, Map()).map(resp => resp.copy(headers = resp.headers :+ (header -> "true"))) 100 | } 101 | } 102 | 103 | @cask.decorators.compress() 104 | @trace("X-Custom-Trace") 105 | @Web.get( 106 | "/demo/decorated", 107 | RouteSchema(responses = Map(200 -> ApiResponse("OK", _root_.boogieloops.schema.Schema.String()))) 108 | ) 109 | def decorated(r: ValidatedRequest): String = "decorated-ok" 110 | ``` 111 | 112 | The curl demo adds `Accept-Encoding: gzip` so you can see compression and the custom header in the response. 113 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/derivation/SchemaAnnotations.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.derivation 2 | 3 | /** 4 | * Schema annotation classes for JSON Schema 2020-12 metadata 5 | * 6 | * These annotations can be applied to case classes and fields to provide 7 | * rich metadata that gets included in the generated JSON schema. 8 | */ 9 | object SchemaAnnotations { 10 | 11 | /** 12 | * Annotation for schema title 13 | */ 14 | class title(val value: String) extends scala.annotation.StaticAnnotation 15 | 16 | /** 17 | * Annotation for schema description 18 | */ 19 | class description(val value: String) extends scala.annotation.StaticAnnotation 20 | 21 | /** 22 | * Annotation for string format validation 23 | */ 24 | class format(val value: String) extends scala.annotation.StaticAnnotation 25 | 26 | /** 27 | * Annotation for minimum length (strings/arrays) 28 | */ 29 | class minLength(val value: Int) extends scala.annotation.StaticAnnotation 30 | 31 | /** 32 | * Annotation for maximum length (strings/arrays) 33 | */ 34 | class maxLength(val value: Int) extends scala.annotation.StaticAnnotation 35 | 36 | /** 37 | * Annotation for minimum value (numbers) 38 | */ 39 | class minimum(val value: Double) extends scala.annotation.StaticAnnotation 40 | 41 | /** 42 | * Annotation for maximum value (numbers) 43 | */ 44 | class maximum(val value: Double) extends scala.annotation.StaticAnnotation 45 | 46 | /** 47 | * Annotation for regex pattern (strings) 48 | */ 49 | class pattern(val value: String) extends scala.annotation.StaticAnnotation 50 | 51 | /** 52 | * Annotation for minimum number of items (arrays) 53 | */ 54 | class minItems(val value: Int) extends scala.annotation.StaticAnnotation 55 | 56 | /** 57 | * Annotation for maximum number of items (arrays) 58 | */ 59 | class maxItems(val value: Int) extends scala.annotation.StaticAnnotation 60 | 61 | /** 62 | * Annotation for unique items constraint (arrays) 63 | */ 64 | class uniqueItems(val value: Boolean = true) extends scala.annotation.StaticAnnotation 65 | 66 | /** 67 | * Annotation for multiple of constraint (numbers) 68 | */ 69 | class multipleOf(val value: Double) extends scala.annotation.StaticAnnotation 70 | 71 | /** 72 | * Annotation for exclusive minimum (numbers) 73 | */ 74 | class exclusiveMinimum(val value: Double) extends scala.annotation.StaticAnnotation 75 | 76 | /** 77 | * Annotation for exclusive maximum (numbers) 78 | */ 79 | class exclusiveMaximum(val value: Double) extends scala.annotation.StaticAnnotation 80 | 81 | /** 82 | * Annotation for enumeration values - supports strings, numbers, and booleans 83 | */ 84 | class enumValues(val values: (String | Int | Boolean | Double | Null)*) 85 | extends scala.annotation.StaticAnnotation 86 | 87 | /** 88 | * Annotation for constant value - supports strings, numbers, and booleans 89 | */ 90 | class const(val value: String | Int | Boolean | Double | Null) 91 | extends scala.annotation.StaticAnnotation 92 | 93 | /** 94 | * Annotation for default values - supports all types via union type 95 | */ 96 | class default(val value: String | Int | Boolean | Double) 97 | extends scala.annotation.StaticAnnotation 98 | 99 | /** 100 | * Annotation for example values (as JSON strings) 101 | */ 102 | class examples(val values: String*) extends scala.annotation.StaticAnnotation 103 | 104 | /** 105 | * Annotation for read-only fields 106 | */ 107 | class readOnly(val value: Boolean = true) extends scala.annotation.StaticAnnotation 108 | 109 | /** 110 | * Annotation for write-only fields 111 | */ 112 | class writeOnly(val value: Boolean = true) extends scala.annotation.StaticAnnotation 113 | 114 | /** 115 | * Annotation for deprecated fields/schemas 116 | */ 117 | class deprecated(val value: Boolean = true) extends scala.annotation.StaticAnnotation 118 | } 119 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/types.gen.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | 3 | /** 4 | * CreateUserRequest 5 | * Request payload for creating a new user 6 | */ 7 | export type CreateUserRequest = { 8 | /** 9 | * Full name of the user 10 | */ 11 | name: string; 12 | /** 13 | * Email address 14 | */ 15 | email: string; 16 | /** 17 | * Age in years 18 | */ 19 | age: number; 20 | /** 21 | * Whether the user account should be active 22 | */ 23 | isActive?: boolean; 24 | }; 25 | 26 | /** 27 | * ErrorResponse 28 | * Error response with details 29 | */ 30 | export type ErrorResponse = { 31 | /** 32 | * Error type identifier 33 | */ 34 | error: string; 35 | /** 36 | * Human-readable error message 37 | */ 38 | message: string; 39 | /** 40 | * Additional error details 41 | */ 42 | details?: Array; 43 | }; 44 | 45 | /** 46 | * User 47 | * A user in the system 48 | */ 49 | export type User = { 50 | /** 51 | * Full name of the user 52 | */ 53 | name: string; 54 | /** 55 | * Email address 56 | */ 57 | email: string; 58 | /** 59 | * Age in years 60 | */ 61 | age: number; 62 | /** 63 | * Unique identifier for the user 64 | */ 65 | id: string; 66 | /** 67 | * Whether the user account is active 68 | */ 69 | isActive?: boolean; 70 | }; 71 | 72 | /** 73 | * UserListResponse 74 | * Paginated list of users with metadata 75 | */ 76 | export type UserListResponse = { 77 | /** 78 | * Total number of pages 79 | */ 80 | totalPages: number; 81 | /** 82 | * Total number of users matching filters 83 | */ 84 | total: number; 85 | /** 86 | * Number of users per page 87 | */ 88 | limit: number; 89 | /** 90 | * List of users for this page 91 | */ 92 | users: Array<{ 93 | /** 94 | * Full name of the user 95 | */ 96 | name: string; 97 | /** 98 | * Email address 99 | */ 100 | email: string; 101 | /** 102 | * Age in years 103 | */ 104 | age: number; 105 | /** 106 | * Unique identifier for the user 107 | */ 108 | id: string; 109 | /** 110 | * Whether the user account is active 111 | */ 112 | isActive?: boolean; 113 | }>; 114 | /** 115 | * Current page number 116 | */ 117 | page: number; 118 | }; 119 | 120 | /** 121 | * UserListQuery 122 | * Query parameters for filtering and paginating users 123 | */ 124 | export type UserListQuery = { 125 | /** 126 | * Page number (1-based) 127 | */ 128 | page?: number; 129 | /** 130 | * Number of users per page 131 | */ 132 | limit?: number; 133 | /** 134 | * Filter by name (case-insensitive partial match) 135 | */ 136 | search?: string; 137 | /** 138 | * Filter by active status 139 | */ 140 | active?: boolean; 141 | }; 142 | 143 | /** 144 | * SuccessResponse 145 | * Generic success response 146 | */ 147 | export type SuccessResponse = { 148 | /** 149 | * Success message 150 | */ 151 | message: string; 152 | }; 153 | 154 | /** 155 | * SuccessResponse 156 | * Generic success response 157 | */ 158 | export type SuccessResponse1 = { 159 | /** 160 | * Success message 161 | */ 162 | message: string; 163 | }; 164 | 165 | /** 166 | * UpdateUserRequest 167 | * Request payload for updating an existing user 168 | */ 169 | export type UpdateUserRequest = { 170 | /** 171 | * Full name of the user 172 | */ 173 | name?: string; 174 | /** 175 | * Email address 176 | */ 177 | email?: string; 178 | /** 179 | * Age in years 180 | */ 181 | age?: number; 182 | /** 183 | * Whether the user account is active 184 | */ 185 | isActive?: boolean; 186 | }; 187 | 188 | export type ClientOptions = { 189 | baseUrl: 'http://0.0.0.0:8082' | (string & {}); 190 | }; -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/models/ComponentsObject.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.models 2 | 3 | import upickle.default.* 4 | import boogieloops.web.openapi.config.* 5 | 6 | /** 7 | * Components Object - holds reusable objects for different aspects of the OAS 8 | */ 9 | case class ComponentsObject( 10 | schemas: Option[Map[String, ujson.Value]] = None, 11 | responses: Option[Map[String, ResponseObject]] = None, 12 | parameters: Option[Map[String, ParameterObject]] = None, 13 | examples: Option[Map[String, ExampleObject]] = None, 14 | requestBodies: Option[Map[String, RequestBodyObject]] = None, 15 | headers: Option[Map[String, HeaderObject]] = None, 16 | securitySchemes: Option[Map[String, SecuritySchemeObject]] = None, 17 | links: Option[Map[String, LinkObject]] = None, 18 | callbacks: Option[Map[String, CallbackObject]] = None, 19 | pathItems: Option[Map[String, PathItemObject]] = None // OpenAPI 3.1.1 addition 20 | ) derives ReadWriter 21 | 22 | /** 23 | * Schema Object - OpenAPI 3.1.1 uses JSON Schema 2020-12 directly 24 | */ 25 | case class SchemaObject( 26 | // JSON Schema 2020-12 content (from BoogieLoops) 27 | schema: ujson.Value, 28 | 29 | // OpenAPI 3.1.1 specific annotations 30 | example: Option[ujson.Value] = None, 31 | examples: Option[List[ujson.Value]] = None, 32 | externalDocs: Option[ExternalDocumentationObject] = None, 33 | discriminator: Option[DiscriminatorObject] = None, 34 | xml: Option[XMLObject] = None 35 | ) derives ReadWriter 36 | 37 | /** 38 | * Media Type Object - provides schema and examples for the media type 39 | */ 40 | case class MediaTypeObject( 41 | schema: Option[ujson.Value] = None, 42 | example: Option[ujson.Value] = None, 43 | examples: Option[Map[String, ExampleObject]] = None, // 3.1.1 enhancement 44 | encoding: Option[Map[String, EncodingObject]] = None 45 | ) derives ReadWriter 46 | 47 | /** 48 | * Example Object - OpenAPI 3.1.1 enhanced examples 49 | */ 50 | case class ExampleObject( 51 | summary: Option[String] = None, 52 | description: Option[String] = None, 53 | value: Option[ujson.Value] = None, 54 | externalValue: Option[String] = None 55 | ) derives ReadWriter 56 | 57 | /** 58 | * Header Object - describes a single header 59 | */ 60 | case class HeaderObject( 61 | description: Option[String] = None, 62 | required: Option[Boolean] = None, 63 | deprecated: Option[Boolean] = None, 64 | allowEmptyValue: Option[Boolean] = None, 65 | // Schema-based headers 66 | style: Option[String] = None, 67 | explode: Option[Boolean] = None, 68 | allowReserved: Option[Boolean] = None, 69 | schema: Option[ujson.Value] = None, 70 | example: Option[ujson.Value] = None, 71 | examples: Option[Map[String, ExampleObject]] = None, 72 | // Content-based headers 73 | content: Option[Map[String, MediaTypeObject]] = None 74 | ) derives ReadWriter 75 | 76 | /** 77 | * Link Object - represents a possible design-time link 78 | */ 79 | case class LinkObject( 80 | operationRef: Option[String] = None, 81 | operationId: Option[String] = None, 82 | parameters: Option[Map[String, ujson.Value]] = None, 83 | requestBody: Option[ujson.Value] = None, 84 | description: Option[String] = None, 85 | server: Option[ServerObject] = None 86 | ) derives ReadWriter 87 | 88 | /** 89 | * Encoding Object - defines serialization strategy for application/x-www-form-urlencoded 90 | */ 91 | case class EncodingObject( 92 | contentType: Option[String] = None, 93 | headers: Option[Map[String, HeaderObject]] = None, 94 | style: Option[String] = None, 95 | explode: Option[Boolean] = None, 96 | allowReserved: Option[Boolean] = None 97 | ) derives ReadWriter 98 | 99 | /** 100 | * Discriminator Object - used for polymorphism 101 | */ 102 | case class DiscriminatorObject( 103 | propertyName: String, 104 | mapping: Option[Map[String, String]] = None 105 | ) derives ReadWriter 106 | 107 | /** 108 | * XML Object - metadata for XML serialization 109 | */ 110 | case class XMLObject( 111 | name: Option[String] = None, 112 | namespace: Option[String] = None, 113 | prefix: Option[String] = None, 114 | attribute: Option[Boolean] = None, 115 | wrapped: Option[Boolean] = None 116 | ) derives ReadWriter 117 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/examples/UploadStreamingAPI.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.examples 2 | 3 | import cask._ 4 | import scala.annotation.unused 5 | import upickle.default._ 6 | import boogieloops.web._ 7 | import boogieloops.web.Web.ValidatedRequestReader 8 | import boogieloops.web.Web 9 | import io.undertow.server.handlers.form.FormParserFactory 10 | import os.* 11 | import os.Source.* 12 | import _root_.boogieloops.schema.* 13 | 14 | class trace(header: String = "X-Trace") extends scala.annotation.Annotation with cask.router.RawDecorator { 15 | def wrapFunction(ctx: cask.Request, delegate: Delegate) = { 16 | delegate(ctx, Map()).map(resp => resp.copy(headers = resp.headers :+ (header -> "true"))) 17 | } 18 | } 19 | 20 | class UploadStreamingAPI extends cask.MainRoutes { 21 | 22 | case class UploadResponse(fields: Map[String, String], files: List[(String, Long)]) derives ReadWriter 23 | 24 | @cask.decorators.compress() 25 | @trace() 26 | @Web.post( 27 | "/demo/upload", 28 | RouteSchema( 29 | summary = Some("Multipart upload"), 30 | description = Some("Accepts multipart/form-data without consuming body"), 31 | tags = List("demo"), 32 | responses = Map(200 -> ApiResponse("OK", bl.String())) 33 | ) 34 | ) 35 | def upload(r: ValidatedRequest): String = { 36 | val parser = FormParserFactory.builder().build().createParser(r.original.exchange) 37 | val form = parser.parseBlocking() 38 | var fields = Map.empty[String, String] 39 | var files = List.empty[(String, Long)] 40 | // Use os-lib to create a temp directory for this upload 41 | val outDir = os.temp.dir(prefix = "boogieloops-web-upload-") 42 | val it = form.iterator() 43 | while (it.hasNext) { 44 | val name = it.next() 45 | val vs = form.get(name).iterator() 46 | while (vs.hasNext) { 47 | val v = vs.next() 48 | if (v.isFileItem) { 49 | val fi = v.getFileItem 50 | files = files :+ (name -> fi.getFileSize) 51 | } else fields = fields.updated(name, v.getValue) 52 | } 53 | } 54 | // Persist a simple note.txt if provided to demonstrate os-lib 55 | fields.get("note").foreach { txt => 56 | os.write.over(outDir / "note.txt", txt) 57 | } 58 | upickle.default.write(UploadResponse(fields, files)) 59 | } 60 | 61 | @cask.decorators.compress() 62 | @trace() 63 | @Web.post( 64 | "/demo/upload-stream", 65 | RouteSchema( 66 | summary = Some("Streaming upload"), 67 | description = Some("Accepts raw request body as a stream (geny.Readable)"), 68 | tags = List("demo"), 69 | responses = Map(200 -> ApiResponse("OK", bl.String())) 70 | ) 71 | ) 72 | def uploadStream(r: ValidatedRequest): String = { 73 | val headers = r.original.headers 74 | val fileName = headers.get("x-file-name").flatMap(_.headOption).getOrElse("upload.bin") 75 | val outDir = os.temp.dir(prefix = "boogieloops-web-upload-") 76 | val dest = outDir / fileName 77 | // cask.Request implements geny.Readable & geny.Writable; os.Source.WritableSource enables writing it 78 | os.write.over(dest, r.original) 79 | val size = os.size(dest) 80 | upickle.default.write(ujson.Obj( 81 | "fileName" -> fileName, 82 | "size" -> size, 83 | "path" -> dest.toString 84 | )) 85 | } 86 | 87 | @cask.decorators.compress() 88 | @trace() 89 | @Web.get( 90 | "/demo/stream/:size", 91 | RouteSchema( 92 | summary = Some("Streaming response"), 93 | description = Some("Streams N bytes to client"), 94 | tags = List("demo"), 95 | responses = Map(200 -> ApiResponse("OK", bl.String())) 96 | ) 97 | ) 98 | def stream(size: String, @unused r: ValidatedRequest): cask.Response[String] = { 99 | val n = math.max(0, size.toIntOption.getOrElse(0)) 100 | val data = "a" * n 101 | cask.Response( 102 | data = data, 103 | statusCode = 200, 104 | headers = Seq("Content-Type" -> "application/octet-stream") 105 | ) 106 | } 107 | 108 | @cask.decorators.compress() 109 | @trace("X-Custom-Trace") 110 | @Web.get( 111 | "/demo/decorated", 112 | RouteSchema( 113 | summary = Some("Decorated route"), 114 | description = Some("Shows custom and built-in decorators with boogieloops.web"), 115 | tags = List("demo"), 116 | responses = Map(200 -> ApiResponse("OK", bl.String())) 117 | ) 118 | ) 119 | def decorated(r: ValidatedRequest): String = { 120 | "decorated-ok" 121 | } 122 | 123 | initialize() 124 | } 125 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/examples/zerotoapp/Api.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.examples.zerotoapp 2 | 3 | import cask._ 4 | import scala.annotation.unused 5 | import boogieloops.web.* 6 | import boogieloops.web.Web.ValidatedRequestReader 7 | import boogieloops.schema.derivation.Schematic 8 | import upickle.default.* 9 | 10 | /** 11 | * Zero-to-App: API stub 12 | * 13 | * Start the server: 14 | * ./mill Web.runMain boogieloops.web.examples.zerotoapp.ZeroToAppApi 15 | * 16 | * Follow docs/zero-to-app.md Steps 2–5 to flesh out endpoints and OpenAPI. 17 | */ 18 | object ZeroToAppApi extends cask.MainRoutes { 19 | 20 | // NOTE: In the real world, you'd use a real database to back this rest API. 21 | private var users: Map[String, User] = Map.empty 22 | 23 | // POST /users — create a user 24 | // Docs: Zero to App, Step 2 (Add a minimal API) 25 | @Web.post( 26 | "/users", 27 | RouteSchema( 28 | summary = Some("Create user"), 29 | body = Some(Schematic[CreateUser]), 30 | responses = Map(201 -> ApiResponse("Created", Schematic[User])) 31 | ) 32 | ) 33 | def create(req: ValidatedRequest) = { 34 | // TODO: Replace this minimal logic with the version in docs/zero-to-app.md 35 | req.getBody[CreateUser] match { 36 | case Right(in) => 37 | val id = (users.size + 1).toString 38 | val out = User(id, in.name, in.email, in.age) 39 | users = users.updated(id, out) 40 | write(out) 41 | case Left(err) => 42 | write(ujson.Obj("error" -> "validation_failed", "message" -> err.message)) 43 | } 44 | } 45 | 46 | // GET /users — list users 47 | @Web.get( 48 | "/users", 49 | RouteSchema( 50 | summary = Some("List users"), 51 | responses = Map(200 -> ApiResponse("OK", Schematic[List[User]])) 52 | ) 53 | ) 54 | def list(@unused req: ValidatedRequest) = { 55 | // TODO: Add filters/pagination later if needed 56 | write(users.values.toList) 57 | } 58 | 59 | // POST /users/:id/interests/infer — derive interests from a profile summary 60 | // This endpoint demonstrates how to normalize inferred interests into a 61 | // typed model. For a production-quality version, wire to boogieloops.ai Agent. 62 | @Web.post( 63 | "/users/:id/interests/infer", 64 | RouteSchema( 65 | summary = Some("Infer user interests from free text"), 66 | description = Some("Provide a profile summary text; get normalized interests"), 67 | body = Some(Schematic[ProfileSummary]), 68 | responses = Map(200 -> ApiResponse("OK", Schematic[UserInterests])) 69 | ) 70 | ) 71 | def inferInterests(id: String, req: ValidatedRequest) = { 72 | // Look up user (optional here; you might validate existence) 73 | val _ = id 74 | req.getBody[ProfileSummary] match { 75 | case Right(in) => 76 | // TODO: Replace the current naive normalization with boogieloops.ai Agent integration. 77 | // Pseudocode example (uncomment if you add boogieloops.ai dependency): 78 | // 79 | // import boogieloops.ai.* 80 | // import boogieloops.ai.Providers.OpenAIProvider 81 | // val agent = Agent( 82 | // name = "InterestsNormalizer", 83 | // instructions = "Extract and normalize user interests. Use concise, lowercase tags", 84 | // provider = new OpenAIProvider(sys.env("OPENAI_API_KEY")), 85 | // model = "gpt-4o-mini" 86 | // ) 87 | // val result = agent.generateObject[UserInterests]( 88 | // s"""Normalize interests from this profile:\n${in.text}""", 89 | // RequestMetadata(userId = Some(id)) 90 | // ) 91 | // result.fold(_ => fallback, _.data) 92 | 93 | // Naive Implementation 94 | // 95 | val tokens = in.text.toLowerCase.split("[^a-z0-9]+").filter(_.nonEmpty) 96 | val keywords = Set("scala", "java", "kotlin", "functional", "backend", "api", "ai", "ml", "devops") 97 | val hits = tokens.filter(keywords.contains).distinct.toList 98 | val interests = UserInterests( 99 | primary = hits.take(3), 100 | secondary = hits.drop(3).take(5), 101 | tags = hits 102 | ) 103 | write(interests) 104 | case Left(err) => 105 | write(ujson.Obj("error" -> "validation_failed", "message" -> err.message)) 106 | } 107 | } 108 | 109 | // Optional: Add swagger endpoint per docs (Step 5) 110 | // @Web.swagger("/openapi", OpenAPIConfig(title = "Quickstart API", summary = Some("Zero to App demo"))) 111 | // def openapi(): String = "" 112 | 113 | // Default port to avoid clashing with other examples 114 | override def port = 8082 115 | initialize() 116 | } 117 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/models/PathsObject.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.models 2 | 3 | import upickle.default.* 4 | import boogieloops.web.openapi.config.* 5 | 6 | /** 7 | * Path Item Object - describes operations available on a single path 8 | */ 9 | case class PathItemObject( 10 | summary: Option[String] = None, 11 | description: Option[String] = None, 12 | get: Option[OperationObject] = None, 13 | put: Option[OperationObject] = None, 14 | post: Option[OperationObject] = None, 15 | delete: Option[OperationObject] = None, 16 | options: Option[OperationObject] = None, 17 | head: Option[OperationObject] = None, 18 | patch: Option[OperationObject] = None, 19 | trace: Option[OperationObject] = None, 20 | servers: Option[List[ServerObject]] = None, 21 | parameters: Option[List[ParameterObject]] = None 22 | ) derives ReadWriter 23 | 24 | /** 25 | * Operation Object - describes a single API operation on a path 26 | */ 27 | case class OperationObject( 28 | tags: Option[List[String]] = None, 29 | summary: Option[String] = None, 30 | description: Option[String] = None, 31 | externalDocs: Option[ExternalDocumentationObject] = None, 32 | operationId: Option[String] = None, 33 | parameters: Option[List[ParameterObject]] = None, 34 | requestBody: Option[RequestBodyObject] = None, 35 | responses: ResponsesObject, 36 | callbacks: Option[Map[String, CallbackObject]] = None, // 3.1.1 webhooks support 37 | deprecated: Option[Boolean] = None, 38 | security: Option[List[SecurityRequirementObject]] = None, 39 | servers: Option[List[ServerObject]] = None 40 | ) derives ReadWriter 41 | 42 | /** 43 | * Parameter Object - describes a single operation parameter 44 | */ 45 | case class ParameterObject( 46 | name: String, 47 | in: String, // "query", "header", "path", "cookie" 48 | description: Option[String] = None, 49 | required: Option[Boolean] = None, 50 | deprecated: Option[Boolean] = None, 51 | allowEmptyValue: Option[Boolean] = None, 52 | // Schema-based parameters 53 | style: Option[String] = None, 54 | explode: Option[Boolean] = None, 55 | allowReserved: Option[Boolean] = None, 56 | schema: Option[ujson.Value] = None, 57 | example: Option[ujson.Value] = None, 58 | examples: Option[Map[String, ExampleObject]] = None, 59 | // Content-based parameters 60 | content: Option[Map[String, MediaTypeObject]] = None 61 | ) derives ReadWriter 62 | 63 | /** 64 | * Request Body Object - describes a single request body 65 | */ 66 | case class RequestBodyObject( 67 | description: Option[String] = None, 68 | content: Map[String, MediaTypeObject], 69 | required: Option[Boolean] = None 70 | ) derives ReadWriter 71 | 72 | /** 73 | * Responses Object - container for the expected responses of an operation 74 | * OpenAPI spec requires responses to serialize as a flat map with status codes as keys, 75 | * with optional "default" key, not wrapped in a "responses" field 76 | */ 77 | case class ResponsesObject( 78 | default: Option[ResponseObject] = None, 79 | responses: Map[String, ResponseObject] = Map.empty // HTTP status codes as keys 80 | ) 81 | 82 | object ResponsesObject { 83 | given ReadWriter[ResponsesObject] = readwriter[ujson.Value].bimap( 84 | obj => { 85 | val map = ujson.Obj() 86 | obj.default.foreach(d => map("default") = writeJs(d)) 87 | obj.responses.foreach { case (code, response) => 88 | map(code) = writeJs(response) 89 | } 90 | map 91 | }, 92 | json => { 93 | val obj = json.obj 94 | val default = obj.get("default").map(read[ResponseObject](_)) 95 | val responses = obj.filterNot(_._1 == "default").map { case (k, v) => 96 | k -> read[ResponseObject](v) 97 | }.toMap 98 | ResponsesObject(default, responses) 99 | } 100 | ) 101 | } 102 | 103 | /** 104 | * Response Object - describes a single response from an API Operation 105 | */ 106 | case class ResponseObject( 107 | description: String, 108 | headers: Option[Map[String, HeaderObject]] = None, 109 | content: Option[Map[String, MediaTypeObject]] = None, 110 | links: Option[Map[String, LinkObject]] = None 111 | ) derives ReadWriter 112 | 113 | /** 114 | * Callback Object - A map of possible out-of band callbacks 115 | * OpenAPI spec requires callbacks to serialize as a flat map, not wrapped in a "callbacks" field 116 | */ 117 | case class CallbackObject( 118 | callbacks: Map[String, PathItemObject] = Map.empty 119 | ) 120 | 121 | object CallbackObject { 122 | given ReadWriter[CallbackObject] = readwriter[Map[String, PathItemObject]].bimap( 123 | obj => obj.callbacks, 124 | map => CallbackObject(map) 125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/primitives/NumberSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.primitives 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * Number schema type with JSON Schema 2020-12 validation keywords 8 | */ 9 | case class NumberSchema( 10 | minimum: Option[Double] = None, 11 | maximum: Option[Double] = None, 12 | exclusiveMinimum: Option[Double] = None, 13 | exclusiveMaximum: Option[Double] = None, 14 | multipleOf: Option[Double] = None, 15 | const: Option[Double] = None 16 | ) extends Schema { 17 | 18 | override def toJsonSchema: ujson.Value = { 19 | val schema = ujson.Obj("type" -> ujson.Str("number")) 20 | 21 | minimum.foreach(min => schema("minimum") = ujson.Num(min)) 22 | maximum.foreach(max => schema("maximum") = ujson.Num(max)) 23 | exclusiveMinimum.foreach(min => schema("exclusiveMinimum") = ujson.Num(min)) 24 | exclusiveMaximum.foreach(max => schema("exclusiveMaximum") = ujson.Num(max)) 25 | multipleOf.foreach(mul => schema("multipleOf") = ujson.Num(mul)) 26 | const.foreach(c => schema("const") = ujson.Num(c)) 27 | 28 | title.foreach(t => schema("title") = ujson.Str(t)) 29 | description.foreach(d => schema("description") = ujson.Str(d)) 30 | default.foreach(d => schema("default") = d) 31 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 32 | 33 | schema 34 | } 35 | 36 | /** 37 | * Validate a ujson.Value against this number schema 38 | */ 39 | def validate(value: ujson.Value): ValidationResult = { 40 | validate(value, ValidationContext()) 41 | } 42 | 43 | /** 44 | * Validate a ujson.Value against this number schema with context 45 | */ 46 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 47 | // Type check for ujson.Num 48 | value match { 49 | case ujson.Num(numberValue) => 50 | // Inline validation logic 51 | val minErrors = minimum.fold(List.empty[boogieloops.schema.ValidationError]) { min => 52 | if (numberValue < min) 53 | List(boogieloops.schema.ValidationError.OutOfRange(Some(min), None, numberValue, context.path)) 54 | else Nil 55 | } 56 | 57 | val maxErrors = maximum.fold(List.empty[boogieloops.schema.ValidationError]) { max => 58 | if (numberValue > max) 59 | List(boogieloops.schema.ValidationError.OutOfRange(None, Some(max), numberValue, context.path)) 60 | else Nil 61 | } 62 | 63 | val exclusiveMinErrors = exclusiveMinimum.fold(List.empty[boogieloops.schema.ValidationError]) { min => 64 | if (numberValue <= min) 65 | List(boogieloops.schema.ValidationError.OutOfRange(Some(min), None, numberValue, context.path)) 66 | else Nil 67 | } 68 | 69 | val exclusiveMaxErrors = exclusiveMaximum.fold(List.empty[boogieloops.schema.ValidationError]) { max => 70 | if (numberValue >= max) 71 | List(boogieloops.schema.ValidationError.OutOfRange(None, Some(max), numberValue, context.path)) 72 | else Nil 73 | } 74 | 75 | val multipleErrors = multipleOf.fold(List.empty[boogieloops.schema.ValidationError]) { mul => 76 | if (numberValue % mul != 0) 77 | List(boogieloops.schema.ValidationError.MultipleOfViolation(mul, numberValue, context.path)) 78 | else Nil 79 | } 80 | 81 | val constErrors = const.fold(List.empty[boogieloops.schema.ValidationError]) { c => 82 | if (numberValue != c) 83 | List(boogieloops.schema.ValidationError.TypeMismatch(c.toString, numberValue.toString, context.path)) 84 | else Nil 85 | } 86 | 87 | val allErrors = 88 | minErrors ++ maxErrors ++ exclusiveMinErrors ++ exclusiveMaxErrors ++ multipleErrors ++ constErrors 89 | 90 | if (allErrors.isEmpty) { 91 | ValidationResult.valid() 92 | } else { 93 | ValidationResult.invalid(allErrors) 94 | } 95 | case _ => 96 | // Non-number ujson.Value type - return TypeMismatch error 97 | val error = boogieloops.schema.ValidationError.TypeMismatch("number", getValueType(value), context.path) 98 | ValidationResult.invalid(error) 99 | } 100 | } 101 | 102 | /** 103 | * Get string representation of ujson.Value type for error messages 104 | */ 105 | private def getValueType(value: ujson.Value): String = { 106 | value match { 107 | case _: ujson.Str => "string" 108 | case _: ujson.Num => "number" 109 | case _: ujson.Bool => "boolean" 110 | case ujson.Null => "null" 111 | case _: ujson.Arr => "array" 112 | case _: ujson.Obj => "object" 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/examples/BasicUsage.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.examples 2 | 3 | import boogieloops.schema.* 4 | 5 | /** 6 | * Basic usage examples of the BoogieLoops library 7 | */ 8 | object BasicUsage { 9 | 10 | def main(args: Array[String]): Unit = { 11 | println("🎉 BoogieLoops Library - JSON Schema generation using upickle for Scala 3!") 12 | println("=" * 50) 13 | 14 | // Basic primitive schemas 15 | println("\n1. Primitive Schemas:") 16 | 17 | val stringSchema = bl.String( 18 | minLength = Some(1), 19 | maxLength = Some(100), 20 | pattern = Some("^[a-zA-Z]+$") 21 | ) 22 | println(s"String Schema: ${stringSchema.toJsonSchema}") 23 | 24 | val numberSchema = bl.Number( 25 | minimum = Some(0.0), 26 | maximum = Some(100.0), 27 | multipleOf = Some(0.1) 28 | ) 29 | println(s"Number Schema: ${numberSchema.toJsonSchema}") 30 | 31 | val integerSchema = bl.Integer( 32 | minimum = Some(1), 33 | maximum = Some(1000) 34 | ) 35 | println(s"Integer Schema: ${integerSchema.toJsonSchema}") 36 | 37 | // Complex schemas 38 | println("\n2. Complex Schemas:") 39 | 40 | val userSchema = bl.Object( 41 | "id" -> bl.String(), 42 | "name" -> bl.String(minLength = Some(1)), 43 | "email" -> bl.String(format = Some("email")), 44 | "age" -> bl.Integer(minimum = Some(0)).optional 45 | ) 46 | println(s"User Schema: ${userSchema.toJsonSchema}") 47 | 48 | val arraySchema = bl.Array( 49 | bl.String(), 50 | minItems = Some(1), 51 | maxItems = Some(10), 52 | uniqueItems = Some(true) 53 | ) 54 | println(s"Array Schema: ${arraySchema.toJsonSchema}") 55 | 56 | // Null handling with modifier pattern 57 | println("\n3. Null Handling:") 58 | 59 | val nullableString = bl.String().nullable 60 | println(s"Nullable String: ${nullableString.toJsonSchema}") 61 | 62 | val optionalString = bl.String().optional 63 | println(s"Optional String: ${optionalString.toJsonSchema}") 64 | 65 | val optionalNullableString = bl.String().optional.nullable 66 | println(s"Optional Nullable String: ${optionalNullableString.toJsonSchema}") 67 | 68 | // JSON Schema 2020-12 composition keywords 69 | println("\n4. Composition Keywords:") 70 | 71 | val anyOfSchema = bl.AnyOf( 72 | bl.String(), 73 | bl.Number() 74 | ) 75 | println(s"AnyOf Schema: ${anyOfSchema.toJsonSchema}") 76 | 77 | val oneOfSchema = bl.OneOf( 78 | bl.String(), 79 | bl.Integer() 80 | ) 81 | println(s"OneOf Schema: ${oneOfSchema.toJsonSchema}") 82 | 83 | val allOfSchema = bl.AllOf( 84 | bl.Object("name" -> bl.String()), 85 | bl.Object("age" -> bl.Integer()) 86 | ) 87 | println(s"AllOf Schema: ${allOfSchema.toJsonSchema}") 88 | 89 | val notSchema = bl.Not(bl.String()) 90 | println(s"Not Schema: ${notSchema.toJsonSchema}") 91 | 92 | // Conditional schemas (if/then/else) 93 | println("\n5. Conditional Schemas:") 94 | 95 | val conditionalSchema = bl.If( 96 | condition = bl.Object("type" -> bl.String()), 97 | thenSchema = bl.Object("name" -> bl.String()), 98 | elseSchema = bl.Object("id" -> bl.Integer()) 99 | ) 100 | println(s"Conditional Schema: ${conditionalSchema.toJsonSchema}") 101 | 102 | // References 103 | println("\n6. References:") 104 | 105 | val refSchema = bl.Ref("#/$defs/User") 106 | println(s"Reference Schema: ${refSchema.toJsonSchema}") 107 | 108 | val dynamicRefSchema = bl.DynamicRef("#user") 109 | println(s"Dynamic Reference Schema: ${dynamicRefSchema.toJsonSchema}") 110 | 111 | // Complex nested example 112 | println("\n7. Complex Nested Example:") 113 | 114 | val productSchema = bl.Object( 115 | "id" -> bl.String(), 116 | "name" -> bl.String(minLength = Some(1)), 117 | "price" -> bl.Number(minimum = Some(0)), 118 | "category" -> bl.OneOf( 119 | bl.String(), 120 | bl.Object("id" -> bl.String(), "name" -> bl.String()) 121 | ), 122 | "tags" -> bl.Array(bl.String()).optional, 123 | "metadata" -> bl.Object().optional.nullable 124 | ) 125 | 126 | println(s"Product Schema: ${productSchema.toJsonSchema}") 127 | 128 | // Demonstrate JSON Schema 2020-12 compliance 129 | println("\n8. JSON Schema 2020-12 Compliance:") 130 | 131 | val compliantSchema = bl 132 | .Object( 133 | "version" -> bl.String() 134 | ) 135 | .withSchema(bl.MetaSchemaUrl) 136 | .withId("https://example.com/schemas/product") 137 | .withTitle("Product Schema") 138 | .withDescription("A schema for product objects") 139 | 140 | println(s"Compliant Schema: ${compliantSchema.toJsonSchema}") 141 | 142 | println("\n🎯 All examples completed successfully!") 143 | println("BoogieLoops provides full JSON Schema 2020-12 compliance with TypeBox-like ergonomics!") 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /makefiles/examples.mk: -------------------------------------------------------------------------------- 1 | ##@ Examples 2 | 3 | # Defaults for example endpoints 4 | WEB_HOST ?= localhost 5 | WEB_PORT ?= 8080 6 | 7 | # High-level groups 8 | .PHONY: examples examples-all 9 | examples: examples-schema ## Run core schema examples 10 | examples-all: examples-schema example-web-openapi examples-ai ## Run all example groups 11 | 12 | # schema (core library) examples 13 | .PHONY: examples-schema example-schema-basic example-schema-complex example-schema-validation example-schema-derivation example-schema-annotations example-schema-enum example-schema-sealed 14 | examples-schema: ## Run common schema examples (basic, complex, validation) 15 | @$(MILL) schema.runMain boogieloops.schema.examples.BasicUsage 16 | @$(MILL) schema.runMain boogieloops.schema.examples.ComplexTypes 17 | @$(MILL) schema.runMain boogieloops.schema.examples.Validation 18 | example-schema-basic: ## Run schema BasicUsage example 19 | @$(MILL) schema.runMain boogieloops.schema.examples.BasicUsage 20 | example-schema-complex: ## Run schema ComplexTypes example 21 | @$(MILL) schema.runMain boogieloops.schema.examples.ComplexTypes 22 | example-schema-validation: ## Run schema Validation example 23 | @$(MILL) schema.runMain boogieloops.schema.examples.Validation 24 | example-schema-derivation: ## Run schema MirrorDerivedExamples 25 | @$(MILL) schema.runMain boogieloops.schema.examples.MirrorDerivedExamples 26 | example-schema-annotations: ## Run schema AnnotationExample 27 | @$(MILL) schema.runMain boogieloops.schema.examples.AnnotationExample 28 | example-schema-enum: ## Run schema EnumExample 29 | @$(MILL) schema.runMain boogieloops.schema.examples.EnumExample 30 | example-schema-sealed: ## Run schema SealedTraitExample 31 | @$(MILL) schema.runMain boogieloops.schema.examples.SealedTraitExample 32 | 33 | # web (HTTP/API) examples 34 | .PHONY: example-web-api example-web-upload example-web-openapi 35 | example-web-api: ## Start web User CRUD API server (blocks) 36 | @$(MILL) web.runMain boogieloops.web.examples.UserCrudAPI 37 | example-web-upload: ## Start web Upload/Streaming demo server (blocks) 38 | @$(MILL) web.runMain boogieloops.web.examples.UploadStreamingServer 39 | .PHONY: example-web-upload-curl example-web-upload-curl-upload example-web-upload-curl-stream example-web-upload-curl-decorated 40 | example-web-upload-curl: ## Run demo curl requests against the Upload/Streaming server 41 | @$(MAKE) example-web-upload-curl-upload 42 | @$(MAKE) example-web-upload-curl-stream 43 | @$(MAKE) example-web-upload-curl-decorated 44 | example-web-upload-curl-upload: ## Curl: multipart upload demo 45 | @echo "🔸 Multipart upload -> http://$(WEB_HOST):$(WEB_PORT)/demo/upload" 46 | @TMP=$$(mktemp); echo "hello from boogieloops" > $$TMP; \ 47 | curl -sS -i -X POST \ 48 | -F file=@$$TMP \ 49 | -F note=hello \ 50 | http://$(WEB_HOST):$(WEB_PORT)/demo/upload; \ 51 | rm -f $$TMP 52 | example-web-upload-curl-stream: ## Curl: streaming response demo 53 | @echo "🔸 Streaming 1024 bytes -> http://$(WEB_HOST):$(WEB_PORT)/demo/stream/1024" 54 | @curl -sS -i http://$(WEB_HOST):$(WEB_PORT)/demo/stream/1024 55 | example-web-upload-curl-decorated: ## Curl: decorated route demo (shows custom headers) 56 | @echo "🔸 Decorated route -> http://$(WEB_HOST):$(WEB_PORT)/demo/decorated" 57 | @curl -sS -i -H "Accept-Encoding: gzip" http://$(WEB_HOST):$(WEB_PORT)/demo/decorated 58 | example-web-openapi: ## Run only the OpenAPI generator example 59 | @$(MILL) web.runMain boogieloops.web.examples.OpenAPITest 60 | 61 | # ai (LLM agents) examples 62 | .PHONY: examples-ai example-ai-openai example-ai-anthropic example-ai-local 63 | examples-ai: ## Run ai bundled examples (requires API keys) 64 | @$(MILL) ai.runMain boogieloops.ai.examples.Examples 65 | example-ai-openai: ## Run ai OpenAI provider examples 66 | @$(MILL) ai.runMain boogieloops.ai.examples.OpenAIExample 67 | example-ai-anthropic: ## Run ai Anthropic provider examples 68 | @$(MILL) ai.runMain boogieloops.ai.examples.AnthropicExample 69 | example-ai-local: ## Run ai OpenAI-compatible local provider examples 70 | @$(MILL) ai.runMain boogieloops.ai.examples.OpenAICompatibleExample 71 | 72 | # Back-compat convenience aliases (from README / previous Makefile) 73 | .PHONY: schema web web-upload web-curl ai ai-demo ai-openai ai-anthropic ai-local 74 | schema: examples-schema ## Alias: Run schema examples 75 | web: example-web-api ## Alias: Run web API server example 76 | web-upload: example-web-upload ## Alias: Run web Upload/Streaming demo server 77 | web-curl: example-web-upload-curl ## Alias: Run curl demos against Upload/Streaming server 78 | ai: examples-ai ## Alias: Run ai examples bundle 79 | ai-demo: examples-ai ## Alias: Run ai examples bundle 80 | ai-openai: example-ai-openai ## Alias: Run ai OpenAI examples 81 | ai-anthropic: example-ai-anthropic ## Alias: Run ai Anthropic examples 82 | ai-local: example-ai-local ## Alias: Run ai local examples 83 | 84 | # Zero to App quickstart (web) 85 | .PHONY: example-web-zeroapp 86 | example-web-zeroapp: ## Start Zero to App quickstart server (blocks) 87 | @$(MILL) web.runMain boogieloops.web.examples.zerotoapp.ZeroToAppApi 88 | -------------------------------------------------------------------------------- /typescript-sdk/src/client/core/pathSerializer.ts: -------------------------------------------------------------------------------- 1 | interface SerializeOptions 2 | extends SerializePrimitiveOptions, 3 | SerializerOptions {} 4 | 5 | interface SerializePrimitiveOptions { 6 | allowReserved?: boolean; 7 | name: string; 8 | } 9 | 10 | export interface SerializerOptions { 11 | /** 12 | * @default true 13 | */ 14 | explode: boolean; 15 | style: T; 16 | } 17 | 18 | export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; 19 | export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; 20 | type MatrixStyle = 'label' | 'matrix' | 'simple'; 21 | export type ObjectStyle = 'form' | 'deepObject'; 22 | type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; 23 | 24 | interface SerializePrimitiveParam extends SerializePrimitiveOptions { 25 | value: string; 26 | } 27 | 28 | export const separatorArrayExplode = (style: ArraySeparatorStyle) => { 29 | switch (style) { 30 | case 'label': 31 | return '.'; 32 | case 'matrix': 33 | return ';'; 34 | case 'simple': 35 | return ','; 36 | default: 37 | return '&'; 38 | } 39 | }; 40 | 41 | export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { 42 | switch (style) { 43 | case 'form': 44 | return ','; 45 | case 'pipeDelimited': 46 | return '|'; 47 | case 'spaceDelimited': 48 | return '%20'; 49 | default: 50 | return ','; 51 | } 52 | }; 53 | 54 | export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { 55 | switch (style) { 56 | case 'label': 57 | return '.'; 58 | case 'matrix': 59 | return ';'; 60 | case 'simple': 61 | return ','; 62 | default: 63 | return '&'; 64 | } 65 | }; 66 | 67 | export const serializeArrayParam = ({ 68 | allowReserved, 69 | explode, 70 | name, 71 | style, 72 | value, 73 | }: SerializeOptions & { 74 | value: unknown[]; 75 | }) => { 76 | if (!explode) { 77 | const joinedValues = ( 78 | allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) 79 | ).join(separatorArrayNoExplode(style)); 80 | switch (style) { 81 | case 'label': 82 | return `.${joinedValues}`; 83 | case 'matrix': 84 | return `;${name}=${joinedValues}`; 85 | case 'simple': 86 | return joinedValues; 87 | default: 88 | return `${name}=${joinedValues}`; 89 | } 90 | } 91 | 92 | const separator = separatorArrayExplode(style); 93 | const joinedValues = value 94 | .map((v) => { 95 | if (style === 'label' || style === 'simple') { 96 | return allowReserved ? v : encodeURIComponent(v as string); 97 | } 98 | 99 | return serializePrimitiveParam({ 100 | allowReserved, 101 | name, 102 | value: v as string, 103 | }); 104 | }) 105 | .join(separator); 106 | return style === 'label' || style === 'matrix' 107 | ? separator + joinedValues 108 | : joinedValues; 109 | }; 110 | 111 | export const serializePrimitiveParam = ({ 112 | allowReserved, 113 | name, 114 | value, 115 | }: SerializePrimitiveParam) => { 116 | if (value === undefined || value === null) { 117 | return ''; 118 | } 119 | 120 | if (typeof value === 'object') { 121 | throw new Error( 122 | 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', 123 | ); 124 | } 125 | 126 | return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; 127 | }; 128 | 129 | export const serializeObjectParam = ({ 130 | allowReserved, 131 | explode, 132 | name, 133 | style, 134 | value, 135 | valueOnly, 136 | }: SerializeOptions & { 137 | value: Record | Date; 138 | valueOnly?: boolean; 139 | }) => { 140 | if (value instanceof Date) { 141 | return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; 142 | } 143 | 144 | if (style !== 'deepObject' && !explode) { 145 | let values: string[] = []; 146 | Object.entries(value).forEach(([key, v]) => { 147 | values = [ 148 | ...values, 149 | key, 150 | allowReserved ? (v as string) : encodeURIComponent(v as string), 151 | ]; 152 | }); 153 | const joinedValues = values.join(','); 154 | switch (style) { 155 | case 'form': 156 | return `${name}=${joinedValues}`; 157 | case 'label': 158 | return `.${joinedValues}`; 159 | case 'matrix': 160 | return `;${name}=${joinedValues}`; 161 | default: 162 | return joinedValues; 163 | } 164 | } 165 | 166 | const separator = separatorObjectExplode(style); 167 | const joinedValues = Object.entries(value) 168 | .map(([key, v]) => 169 | serializePrimitiveParam({ 170 | allowReserved, 171 | name: style === 'deepObject' ? `${name}[${key}]` : key, 172 | value: v as string, 173 | }), 174 | ) 175 | .join(separator); 176 | return style === 'label' || style === 'matrix' 177 | ? separator + joinedValues 178 | : joinedValues; 179 | }; 180 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/modifiers/DefsSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.modifiers 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | 6 | /** 7 | * JSON Schema $defs keyword implementation 8 | * 9 | * The $defs keyword provides a location for reusable schema definitions within a schema document. 10 | * These definitions can be referenced using $ref with a JSON Pointer to the definition. 11 | * 12 | * According to JSON Schema 2020-12, $defs is the standardized way to define reusable schemas 13 | * (replacing the draft-07 "definitions" keyword). 14 | * 15 | * This class supports two modes: 16 | * 1. Wrapper mode: Add $defs to an existing schema (underlying is Some) 17 | * 2. Standalone mode: Create a schema containing only $defs (underlying is None) 18 | * 19 | * Example: 20 | * { 21 | * "$defs": { 22 | * "User": { 23 | * "type": "object", 24 | * "properties": { 25 | * "name": { "type": "string" } 26 | * } 27 | * } 28 | * }, 29 | * "type": "object", 30 | * "properties": { 31 | * "user": { "$ref": "#/$defs/User" } 32 | * } 33 | * } 34 | */ 35 | case class DefsSchema( 36 | underlying: Option[Schema], 37 | defsValue: Map[String, Schema] 38 | ) extends Schema { 39 | 40 | override def $defs: Option[Map[String, Schema]] = Some(defsValue) 41 | 42 | // Delegate all other core vocabulary to underlying schema (if present) 43 | override def $schema: Option[String] = underlying.flatMap(_.$schema) 44 | override def $id: Option[String] = underlying.flatMap(_.$id) 45 | override def $ref: Option[String] = underlying.flatMap(_.$ref) 46 | override def $dynamicRef: Option[String] = underlying.flatMap(_.$dynamicRef) 47 | override def $dynamicAnchor: Option[String] = underlying.flatMap(_.$dynamicAnchor) 48 | override def $vocabulary: Option[Map[String, Boolean]] = underlying.flatMap(_.$vocabulary) 49 | override def $comment: Option[String] = underlying.flatMap(_.$comment) 50 | 51 | // Delegate all metadata to underlying schema (if present) 52 | override def title: Option[String] = underlying.flatMap(_.title) 53 | override def description: Option[String] = underlying.flatMap(_.description) 54 | override def default: Option[ujson.Value] = underlying.flatMap(_.default) 55 | override def examples: Option[List[ujson.Value]] = underlying.flatMap(_.examples) 56 | override def readOnly: Option[Boolean] = underlying.flatMap(_.readOnly) 57 | override def writeOnly: Option[Boolean] = underlying.flatMap(_.writeOnly) 58 | override def deprecated: Option[Boolean] = underlying.flatMap(_.deprecated) 59 | 60 | override def toJsonSchema: ujson.Value = { 61 | val base = underlying match { 62 | case Some(u) => u.toJsonSchema 63 | case None => 64 | // Standalone mode: create a new schema with only $defs 65 | val schema = ujson.Obj() 66 | // Add meta-data keywords if present (from standalone usage) 67 | title.foreach(t => schema("title") = ujson.Str(t)) 68 | description.foreach(d => schema("description") = ujson.Str(d)) 69 | schema 70 | } 71 | 72 | if (defsValue.nonEmpty) { 73 | val defsObj = ujson.Obj() 74 | defsValue.foreach { case (name, schema) => 75 | defsObj(name) = schema.toJsonSchema 76 | } 77 | base("$defs") = defsObj 78 | } 79 | base 80 | } 81 | 82 | // Override modifier methods to maintain chaining 83 | override def optional: Schema = boogieloops.schema.OptionalSchema(this) 84 | override def nullable: Schema = boogieloops.schema.NullableSchema(this) 85 | override def withDefault(value: ujson.Value): Schema = boogieloops.schema.DefaultSchema(this, value) 86 | 87 | // Override with* methods to preserve $defs while adding other metadata 88 | override def withTitle(title: String): Schema = TitleSchema(this, title) 89 | override def withDescription(desc: String): Schema = DescriptionSchema(this, desc) 90 | override def withSchema(schema: String): Schema = SchemaModifier(this, schema) 91 | override def withId(id: String): Schema = IdSchema(this, id) 92 | 93 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 94 | underlying match { 95 | case Some(u) => 96 | // If there's an underlying schema, delegate validation to it 97 | u.validate(value, context) 98 | case None => 99 | // $defs itself doesn't validate anything - it just provides definitions 100 | // The actual validation happens when the definitions are referenced 101 | ValidationResult.valid() 102 | } 103 | } 104 | 105 | /** 106 | * Get a definition by name 107 | */ 108 | def getDefinition(name: String): Option[Schema] = { 109 | defsValue.get(name) 110 | } 111 | 112 | /** 113 | * Check if a definition exists 114 | */ 115 | def hasDefinition(name: String): Boolean = { 116 | defsValue.contains(name) 117 | } 118 | 119 | /** 120 | * Get all definition names 121 | */ 122 | def definitionNames: Set[String] = { 123 | defsValue.keySet 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/primitives/StringSchema.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.schema.primitives 2 | 3 | import boogieloops.schema.Schema 4 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 5 | import scala.util.Try 6 | 7 | /** 8 | * String schema type with JSON Schema 2020-12 validation keywords 9 | */ 10 | case class StringSchema( 11 | minLength: Option[Int] = None, 12 | maxLength: Option[Int] = None, 13 | pattern: Option[String] = None, 14 | format: Option[String] = None, 15 | const: Option[String] = None 16 | ) extends Schema { 17 | 18 | override def toJsonSchema: ujson.Value = { 19 | val schema = ujson.Obj("type" -> ujson.Str("string")) 20 | 21 | minLength.foreach(min => schema("minLength") = ujson.Num(min)) 22 | maxLength.foreach(max => schema("maxLength") = ujson.Num(max)) 23 | pattern.foreach(p => schema("pattern") = ujson.Str(p)) 24 | format.foreach(f => schema("format") = ujson.Str(f)) 25 | const.foreach(c => schema("const") = ujson.Str(c)) 26 | 27 | title.foreach(t => schema("title") = ujson.Str(t)) 28 | description.foreach(d => schema("description") = ujson.Str(d)) 29 | default.foreach(d => schema("default") = d) 30 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 31 | 32 | schema 33 | } 34 | 35 | /** 36 | * Validate a ujson.Value against this string schema 37 | */ 38 | def validate(value: ujson.Value): ValidationResult = { 39 | validate(value, ValidationContext()) 40 | } 41 | 42 | /** 43 | * Validate a ujson.Value against this string schema with context 44 | */ 45 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 46 | // Type check for ujson.Str 47 | value match { 48 | case ujson.Str(stringValue) => 49 | // Inline validation logic 50 | val minLengthErrors = minLength.fold(List.empty[boogieloops.schema.ValidationError]) { min => 51 | if (stringValue.length < min) 52 | List(boogieloops.schema.ValidationError.MinLengthViolation(min, stringValue.length, context.path)) 53 | else Nil 54 | } 55 | 56 | val maxLengthErrors = maxLength.fold(List.empty[boogieloops.schema.ValidationError]) { max => 57 | if (stringValue.length > max) 58 | List(boogieloops.schema.ValidationError.MaxLengthViolation(max, stringValue.length, context.path)) 59 | else Nil 60 | } 61 | 62 | val patternErrors = pattern.fold(List.empty[boogieloops.schema.ValidationError]) { p => 63 | if (!stringValue.matches(p)) 64 | List(boogieloops.schema.ValidationError.PatternMismatch(p, stringValue, context.path)) 65 | else Nil 66 | } 67 | 68 | val constErrors = const.fold(List.empty[boogieloops.schema.ValidationError]) { c => 69 | if (stringValue != c) 70 | List(boogieloops.schema.ValidationError.TypeMismatch(c, stringValue, context.path)) 71 | else Nil 72 | } 73 | 74 | // Format validation - this is a basic implementation... 75 | val formatErrors = format.fold(List.empty[boogieloops.schema.ValidationError]) { f => 76 | val isValid = f match { 77 | case "email" => 78 | stringValue.matches("""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$""") 79 | case "uri" => Try(java.net.URI(stringValue)).isSuccess 80 | case "uuid" => 81 | stringValue.matches( 82 | """^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$""" 83 | ) 84 | case "date" => stringValue.matches("""^\d{4}-\d{2}-\d{2}$""") 85 | case "time" => stringValue.matches("""^\d{2}:\d{2}:\d{2}""") 86 | case "date-time" => stringValue.matches("""^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""") 87 | // TODO: add support for more formats, enable custom format registration as well 88 | // TODO: maybe change format to enum to provide type safety and expected behavior 89 | case _ => true // Unknown formats are not validated 90 | } 91 | 92 | if (!isValid) 93 | List(boogieloops.schema.ValidationError.InvalidFormat(f, stringValue, context.path)) 94 | else Nil 95 | } 96 | 97 | val allErrors = minLengthErrors ++ maxLengthErrors ++ patternErrors ++ constErrors ++ formatErrors 98 | 99 | if (allErrors.isEmpty) { 100 | ValidationResult.valid() 101 | } else { 102 | ValidationResult.invalid(allErrors) 103 | } 104 | case _ => 105 | // Non-string ujson.Value type - return TypeMismatch error 106 | val error = boogieloops.schema.ValidationError.TypeMismatch("string", getValueType(value), context.path) 107 | ValidationResult.invalid(error) 108 | } 109 | } 110 | 111 | /** 112 | * Get string representation of ujson.Value type for error messages 113 | */ 114 | private def getValueType(value: ujson.Value): String = { 115 | value match { 116 | case _: ujson.Str => "string" 117 | case _: ujson.Num => "number" 118 | case _: ujson.Bool => "boolean" 119 | case ujson.Null => "null" 120 | case _: ujson.Arr => "array" 121 | case _: ujson.Obj => "object" 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Web/src/main/scala/boogieloops/openapi/generators/ComponentsGenerator.scala: -------------------------------------------------------------------------------- 1 | package boogieloops.web.openapi.generators 2 | 3 | import boogieloops.web.* 4 | import boogieloops.web.openapi.models.* 5 | import boogieloops.web.openapi.config.* 6 | import _root_.boogieloops.schema.Schema 7 | 8 | /** 9 | * Generates OpenAPI Components Object with schema deduplication 10 | */ 11 | object ComponentsGenerator { 12 | 13 | /** 14 | * Extract reusable components from all routes 15 | */ 16 | def extractComponents( 17 | allRoutes: Map[String, RouteSchema], 18 | config: OpenAPIConfig 19 | ): ComponentsObject = { 20 | val schemas = if (config.extractComponents) extractSchemas(allRoutes) else None 21 | val securitySchemes = extractSecuritySchemes(allRoutes) 22 | 23 | ComponentsObject( 24 | schemas = schemas, 25 | securitySchemes = if (securitySchemes.nonEmpty) Some(securitySchemes) else None, 26 | responses = extractCommonResponses(allRoutes), 27 | parameters = extractCommonParameters(allRoutes), 28 | requestBodies = extractCommonRequestBodies(allRoutes) 29 | ) 30 | } 31 | 32 | /** 33 | * Extract and deduplicate schemas from all routes 34 | */ 35 | private def extractSchemas(allRoutes: Map[String, RouteSchema]) 36 | : Option[Map[String, ujson.Value]] = { 37 | val allSchemas = collectAllSchemas(allRoutes) 38 | val deduplicatedSchemas = deduplicateSchemas(allSchemas) 39 | val namedSchemas = generateSchemaNames(deduplicatedSchemas) 40 | 41 | if (namedSchemas.nonEmpty) { 42 | Some(namedSchemas.map { case (name, schema) => 43 | name -> schema.toJsonSchema // Direct JSON Schema, no wrapper 44 | }) 45 | } else None 46 | } 47 | 48 | /** 49 | * Collect all schemas from routes 50 | */ 51 | private def collectAllSchemas(allRoutes: Map[String, RouteSchema]): List[Schema] = { 52 | allRoutes.values.flatMap { schema => 53 | List( 54 | schema.body, 55 | schema.query, 56 | schema.headers, 57 | schema.params 58 | ).flatten ++ schema.responses.values.map(_.schema) ++ 59 | schema.responses.values.flatMap(_.headers).toList 60 | }.toList 61 | } 62 | 63 | /** 64 | * Deduplicate schemas based on their JSON representation 65 | */ 66 | private def deduplicateSchemas(schemas: List[Schema]): List[Schema] = { 67 | schemas 68 | .groupBy(SchemaConverter.schemaHash) 69 | .values 70 | .map(_.head) 71 | .toList 72 | } 73 | 74 | /** 75 | * Generate meaningful names for schemas 76 | */ 77 | private def generateSchemaNames(schemas: List[Schema]): Map[String, Schema] = { 78 | val nameCounter = scala.collection.mutable.Map[String, Int]() 79 | 80 | schemas.map { schema => 81 | val baseName = SchemaConverter.generateSchemaName(schema) 82 | val finalName = nameCounter.get(baseName) match { 83 | case None => 84 | nameCounter(baseName) = 1 85 | baseName 86 | case Some(count) => 87 | nameCounter(baseName) = count + 1 88 | s"$baseName$count" 89 | } 90 | finalName -> schema 91 | }.toMap 92 | } 93 | 94 | /** 95 | * Extract security schemes from routes 96 | */ 97 | private def extractSecuritySchemes(allRoutes: Map[String, RouteSchema]) 98 | : Map[String, SecuritySchemeObject] = { 99 | val allSecurityRequirements = allRoutes.values.flatMap(_.security).toList 100 | SecurityGenerator.extractSecuritySchemes(allSecurityRequirements) 101 | } 102 | 103 | /** 104 | * Extract common response objects 105 | */ 106 | private def extractCommonResponses(allRoutes: Map[String, RouteSchema]) 107 | : Option[Map[String, ResponseObject]] = { 108 | // Find commonly used responses across routes 109 | val allResponses = allRoutes.values.flatMap(_.responses.values).toList 110 | val responsesByDescription = allResponses.groupBy(_.description) 111 | 112 | val commonResponses = responsesByDescription 113 | .filter(_._2.size > 1) 114 | .map { case (description, responses) => 115 | val response = responses.head 116 | sanitizeResponseName(description) -> ResponseObject( 117 | description = response.description, 118 | content = Some( 119 | Map( 120 | "application/json" -> MediaTypeObject( 121 | schema = Some(response.schema.toJsonSchema) 122 | ) 123 | ) 124 | ) 125 | ) 126 | } 127 | .toMap 128 | 129 | if (commonResponses.nonEmpty) Some(commonResponses) else None 130 | } 131 | 132 | /** 133 | * Extract common parameters 134 | */ 135 | private def extractCommonParameters(allRoutes: Map[String, RouteSchema]) 136 | : Option[Map[String, ParameterObject]] = { 137 | // Could extract commonly used parameters across routes 138 | None 139 | } 140 | 141 | /** 142 | * Extract common request bodies 143 | */ 144 | private def extractCommonRequestBodies(allRoutes: Map[String, RouteSchema]) 145 | : Option[Map[String, RequestBodyObject]] = { 146 | // Could extract commonly used request bodies 147 | None 148 | } 149 | 150 | private def sanitizeResponseName(description: String): String = { 151 | description 152 | .replaceAll("[^a-zA-Z0-9_]", "") 153 | .split("\\s+") 154 | .map(_.capitalize) 155 | .mkString("") 156 | .take(50) // Limit length 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Schema/src/main/scala/boogieloops/primitives/EnumSchema.scala: -------------------------------------------------------------------------------- 1 | // scalafix:off DisableSyntax.null 2 | // Disabling for entire file because EnumSchema needs to support null as a valid enum value 3 | // according to JSON Schema specification - enums can contain null as one of the allowed values 4 | package boogieloops.schema.primitives 5 | 6 | import boogieloops.schema.Schema 7 | import boogieloops.schema.validation.{ValidationResult, ValidationContext} 8 | import upickle.default.* 9 | 10 | /** 11 | * Enum schema type supporting both string-only and mixed data type enums 12 | * 13 | * This class provides comprehensive enum support for JSON Schema 2020-12, allowing enums with homogeneous string values or 14 | * heterogeneous mixed types including strings, numbers, booleans, and null. 15 | */ 16 | case class EnumSchema( 17 | enumValues: List[ujson.Value] 18 | ) extends Schema { 19 | 20 | /** 21 | * Check if this is a string-only enum (all values are strings) 22 | */ 23 | def isStringEnum: Boolean = enumValues.forall(_.isInstanceOf[ujson.Str]) 24 | 25 | /** 26 | * Get string values if this is a string-only enum 27 | */ 28 | def getStringValues: Option[List[String]] = { 29 | if (isStringEnum) { 30 | Some(enumValues.map(_.str)) 31 | } else { 32 | None 33 | } 34 | } 35 | 36 | override def toJsonSchema: ujson.Value = { 37 | val schema = ujson.Obj() 38 | 39 | // For string enums, set type to "string" for better compatibility 40 | if (isStringEnum) { 41 | schema("type") = ujson.Str("string") 42 | } 43 | 44 | // Set the enum values - this is the core of enum schema 45 | schema("enum") = ujson.Arr(enumValues*) 46 | 47 | // Add metadata fields 48 | title.foreach(t => schema("title") = ujson.Str(t)) 49 | description.foreach(d => schema("description") = ujson.Str(d)) 50 | default.foreach(d => schema("default") = d) 51 | examples.foreach(e => schema("examples") = ujson.Arr(e*)) 52 | 53 | schema 54 | } 55 | 56 | /** 57 | * Check if a value type is compatible with this enum 58 | */ 59 | def isValidType(value: ujson.Value): Boolean = { 60 | // Get the types of allowed enum values 61 | val allowedTypes = enumValues.map(_.getClass).toSet 62 | allowedTypes.contains(value.getClass) 63 | } 64 | 65 | /** 66 | * Get all the distinct types present in this enum 67 | */ 68 | def getValueTypes: Set[String] = { 69 | enumValues.map { 70 | case _: ujson.Str => "string" 71 | case _: ujson.Num => "number" 72 | case ujson.True | ujson.False => "boolean" 73 | case ujson.Null => "null" 74 | case _: ujson.Obj => "object" 75 | case _: ujson.Arr => "array" 76 | }.toSet 77 | } 78 | 79 | /** 80 | * Validate a ujson.Value against this enum schema using ValidationResult 81 | */ 82 | def validateResult(value: ujson.Value): ValidationResult = { 83 | validate(value, ValidationContext()) 84 | } 85 | 86 | /** 87 | * Validate a ujson.Value against this enum schema with context using ValidationResult 88 | */ 89 | override def validate(value: ujson.Value, context: ValidationContext): ValidationResult = { 90 | if (enumValues.contains(value)) { 91 | ValidationResult.valid() 92 | } else { 93 | val allowedValues = enumValues.map(writeJs(_)).mkString(", ") 94 | val error = 95 | boogieloops.schema.ValidationError.TypeMismatch(allowedValues, writeJs(value).toString, context.path) 96 | ValidationResult.invalid(error) 97 | } 98 | } 99 | } 100 | 101 | object EnumSchema { 102 | 103 | /** 104 | * Create a string-only enum from string values 105 | */ 106 | def fromStrings(values: List[String]): EnumSchema = { 107 | new EnumSchema(values.map(ujson.Str(_))) 108 | } 109 | 110 | /** 111 | * Create a string-only enum from string values (varargs) 112 | */ 113 | def fromStrings(values: String*): EnumSchema = { 114 | fromStrings(values.toList) 115 | } 116 | 117 | /** 118 | * Create a mixed enum from ujson.Value list 119 | */ 120 | def fromValues(values: List[ujson.Value]): EnumSchema = { 121 | EnumSchema(values) 122 | } 123 | 124 | /** 125 | * Create a mixed enum from ujson.Value varargs 126 | */ 127 | def fromValues(values: ujson.Value*): EnumSchema = { 128 | EnumSchema(values.toList) 129 | } 130 | 131 | /** 132 | * Create a mixed enum from different types (convenience method) 133 | */ 134 | def mixed(values: (String | Int | Boolean | Double | Null)*): EnumSchema = { 135 | val jsonValues = values.map { 136 | case s: String => ujson.Str(s) 137 | case i: Int => ujson.Num(i) 138 | case d: Double => ujson.Num(d) 139 | case b: Boolean => if (b) ujson.True else ujson.False 140 | case null => ujson.Null 141 | }.toList 142 | EnumSchema(jsonValues) 143 | } 144 | 145 | /** 146 | * Create a numeric enum from numeric values 147 | */ 148 | def fromNumbers(values: Double*): EnumSchema = { 149 | EnumSchema(values.map(ujson.Num(_)).toList) 150 | } 151 | 152 | /** 153 | * Create an integer enum from integer values 154 | */ 155 | def fromInts(values: Int*): EnumSchema = { 156 | EnumSchema(values.map(v => ujson.Num(v.toDouble)).toList) 157 | } 158 | 159 | /** 160 | * Create a boolean enum (typically just [true, false]) 161 | */ 162 | def fromBooleans(values: Boolean*): EnumSchema = { 163 | EnumSchema(values.map(b => if (b) ujson.True else ujson.False).toList) 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /docs/zero-to-app.md: -------------------------------------------------------------------------------- 1 | # Zero to App (10-15 minutes) 2 | 3 | Build a tiny, validated HTTP app using the BoogieLoops ecosystem. 4 | 5 | What you'll build: 6 | 7 | - Define a `User` model with schema (schema + validation) 8 | - Create a minimal API with web (POST/GET) 9 | - Test with curl 10 | - Optional: add a tiny ai endpoint for structured AI output 11 | 12 | Prereqs 13 | 14 | - Scala 3.6.2+, JDK 17+ 15 | - Mill launcher in repo (`./mill`) 16 | - Run `make help` to see commands 17 | 18 | 1. Create a User model (schema) 19 | 20 | Open `web/src/main/scala/boogieloops/examples/zerotoapp/Models.scala` and add/update: 21 | 22 | ```scala 23 | // File: schema/src/main/scala/quickstart/Models.scala 24 | package quickstart 25 | 26 | import boogieloops.schema.derivation.Schema 27 | 28 | @Schema.title("CreateUser") 29 | case class CreateUser( 30 | @Schema.minLength(1) name: String, 31 | @Schema.format("email") email: String, 32 | @Schema.minimum(0) age: Int 33 | ) derives Schema 34 | 35 | @Schema.title("User") 36 | case class User( 37 | id: String, 38 | name: String, 39 | email: String, 40 | age: Int 41 | ) derives Schema 42 | ``` 43 | 44 | 2. Add a minimal API (web) 45 | 46 | Open `web/src/main/scala/boogieloops/examples/zerotoapp/Api.scala`. The basic POST/GET endpoints are scaffolded; proceed to run it. 47 | 48 | 3. Run it 49 | 50 | ```bash 51 | make example-web-zeroapp 52 | ``` 53 | 54 | 4. Test with curl 55 | 56 | ```bash 57 | # Create a user (validated by schema) 58 | curl -s -X POST localhost:8082/users \ 59 | -H 'Content-Type: application/json' \ 60 | -d '{"name":"Ada","email":"ada@lovelace.org","age":28}' | jq 61 | 62 | # List users 63 | curl -s localhost:8082/users | jq 64 | 65 | # Try an invalid payload (should return a validation error) 66 | curl -s -X POST localhost:8082/users \ 67 | -H 'Content-Type: application/json' \ 68 | -d '{"name":"","email":"nope","age":-5}' | jq 69 | ``` 70 | 71 | 5. Serve OpenAPI (optional) 72 | 73 | Open `web/src/main/scala/boogieloops/examples/zerotoapp/Api.scala` and add this endpoint method: 74 | 75 | ```scala 76 | // Add this endpoint to quickstart.Api 77 | import boogieloops.web.openapi.config.OpenAPIConfig 78 | 79 | @Web.swagger( 80 | "/openapi", 81 | OpenAPIConfig( 82 | title = "Quickstart API", 83 | summary = Some("Zero to App demo"), 84 | description = "Auto-generated from RouteSchema", 85 | version = "1.0.0" 86 | ) 87 | ) 88 | def openapi(): String = "" // auto-generated spec 89 | ``` 90 | 91 | Example: 92 | 93 | ```bash 94 | curl -s http://localhost:8082/openapi | jq '.info.title, .openapi' 95 | ``` 96 | 97 | 6. Optional: Add a tiny ai endpoint 98 | 99 | ```scala 100 | // File: ai/src/main/scala/quickstart/Wiz.scala 101 | package quickstart 102 | 103 | import boogieloops.ai.* 104 | import boogieloops.ai.providers.OpenAIProvider 105 | import boogieloops.schema.derivation.Schema 106 | import upickle.default.* 107 | 108 | case class Summary(@Schema.minLength(1) text: String) derives Schema, ReadWriter 109 | 110 | object Wiz { 111 | lazy val agent = Agent( 112 | name = "Summarizer", 113 | instructions = "Summarize briefly.", 114 | provider = new OpenAIProvider(sys.env("OPENAI_API_KEY")), 115 | model = "gpt-4o-mini" 116 | ) 117 | } 118 | ``` 119 | 120 | Wire it into the API: 121 | 122 | ```scala 123 | // In quickstart.Api 124 | @Web.post( 125 | "/summaries", 126 | RouteSchema( 127 | summary = Some("Summarize text"), 128 | body = Some(Schema[Summary]), 129 | responses = Map(200 -> ApiResponse("OK", Schema[Summary])) 130 | ) 131 | ) 132 | def summarize(req: ValidatedRequest) = { 133 | req.getBody[Summary] match { 134 | case Right(in) => 135 | Wiz.agent.generateObject[Summary]("Summarize: " + in.text, RequestMetadata()) match { 136 | case Right(obj) => write(obj.data) 137 | case Left(err) => write(ujson.Obj("error" -> err.toString)) 138 | } 139 | case Left(err) => write(ujson.Obj("error" -> err.message)) 140 | } 141 | } 142 | ``` 143 | 144 | Run again and POST to `/summaries` (requires `OPENAI_API_KEY`), or point Wiz at a local OpenAI-compatible server. 145 | 146 | 7. Infer Interests (no AI required) 147 | 148 | The Zero-to-App server includes an endpoint that normalizes interests from a free-text profile summary. This version is AI-free by default and can later be swapped to an ai Agent. 149 | 150 | ```bash 151 | # Create a user first (if you haven't already) 152 | curl -s -X POST localhost:8082/users \ 153 | -H 'Content-Type: application/json' \ 154 | -d '{"name":"Ada","email":"ada@lovelace.org","age":28}' | jq 155 | 156 | # Infer interests for user id 1 from a profile summary 157 | curl -s -X POST localhost:8082/users/1/interests/infer \ 158 | -H 'Content-Type: application/json' \ 159 | -d '{"text":"Scala backend engineer into APIs, functional programming, ML and DevOps."}' | jq 160 | 161 | # Example output (shape): 162 | # { 163 | # "primary": ["scala","backend","api"], 164 | # "secondary": ["functional","ml","devops"], 165 | # "tags": ["scala","backend","api","functional","ml","devops"], 166 | # "notes": null 167 | # } 168 | ``` 169 | 170 | Troubleshooting 171 | 172 | - 400s: ensure `Content-Type: application/json` and valid JSON 173 | - Port in use: change `override def port` or free 8082 174 | - ai: set `OPENAI_API_KEY` or use an OpenAI-compatible local endpoint (LM Studio/Ollama) 175 | 176 | Next 177 | 178 | - Explore module guides: schema (docs/schema.md), web (docs/web.md), ai (docs/ai.md) 179 | - See `make help` for useful dev commands 180 | --------------------------------------------------------------------------------