diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..2b2a8b3e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: + - v2-dev + pull_request: + branches: + - master + - v2-dev + +jobs: + test: + name: Crystal ${{ matrix.crystal }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + crystal: [latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Crystal + uses: oprypin/install-crystal@v1 + with: + crystal: ${{ matrix.crystal }} + + - name: Install dependencies + run: shards install + + - name: Run specs + # Defined order for now: the suite has pre-existing order-dependent + # specs (respond_with + others fail under --order random regardless of + # recent changes). Tracked as a cleanup task before switching to random. + run: crystal spec + + - name: Check formatting + run: crystal tool format --check diff --git a/.gitignore b/.gitignore index c21ddbd80..2d30a8027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /doc/ -/docs/ /bin/ /.shards/ /.vscode/ @@ -10,7 +9,7 @@ lib/ /tmp shard.lock .crystal-version -package-lock.json -node_modules /myapp -cli.dwarf +*.dwarf +*.bak +*.png diff --git a/Backers.md b/Backers.md index ca7fdfe7d..935371446 100644 --- a/Backers.md +++ b/Backers.md @@ -1,3 +1,3 @@ -As of 2019-05-30, Amber is graciously supported by: +As of 2025-07-25, Amber is graciously supported by: -Universal Layer - https://ulayer.net/ +AgentC Consulting - https://agentc.consulting diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7d7bb0052 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,188 @@ +# Changelog + +## 2.0.0-dev (unreleased) + +This release represents a major architectural revision of the Amber framework. +The primary goals were to remove all runtime dependencies, modernize the +Crystal language patterns in use, and add first-class support for background +jobs, email, and typed configuration. The CLI has been extracted to a separate +repository. + +### Breaking Changes + +#### CLI removed from framework + +The `amber` CLI tool — generators, scaffolding, database commands, `amber +watch`, `amber routes`, `amber encrypt`, `amber exec` — has been removed from +this repository. Amber V2 is a library shard, not a CLI tool. + +A replacement CLI is maintained as a separate project: +https://github.com/amberframework/amber_cli + +#### Zero runtime dependencies + +All runtime shards have been removed from `shard.yml`. The following were +either dropped or internalized: + +| Dependency | Disposition | +|---|---| +| `redis` / `crystal-redis` | Replaced by adapter pattern; memory adapter is default | +| `amber_router` | Internalized into `src/amber/router/engine/` | +| `backtracer` | Internalized into `src/amber/` | +| `exception_page` | Internalized into `src/amber/` | +| `kilt` | Removed; ECR only | +| `slang` | Removed; ECR only | +| `pg` / `mysql` / `sqlite3` | Removed; add the driver you need at app level | +| `compiled_license` | Removed | +| `micrate` | Removed; handle migrations at app level | + +The only remaining dependency is `ameba` (development only) for linting. + +#### Template engine: ECR only + +Kilt and Slang have been removed. Templates must use Crystal's built-in ECR +(Embedded Crystal). Rename `.slang` files to `.ecr` and convert syntax +accordingly. See [docs/migration-guide.md](docs/migration-guide.md) for a +conversion table. + +#### YAML.mapping replaced with YAML::Serializable + +The deprecated `YAML.mapping` macro no longer works in Crystal 1.x. All +framework internals have been updated to `include YAML::Serializable`. Any +application code using `YAML.mapping` must be updated; the change is +mechanical (see migration guide). + +#### Configuration restructured + +The flat YAML configuration format has been replaced with typed, sectioned +configuration structs. Sections: `server`, `database`, `session`, `logging`, +`jobs`, `mailer`. Every property can be overridden with an environment variable +using the `AMBER_{SECTION}_{KEY}` naming convention (e.g., +`AMBER_SERVER_PORT=8080`, `AMBER_DATABASE_URL=postgres://...`). + +The `Amber::Server.configure` block no longer accepts a block parameter; +properties are set directly inside the block. + +#### Session defaults changed + +Default session store changed from `redis` to `signed_cookie`. Default adapter +is `memory` (no external service required). The `expires` field is an integer +(seconds). + +### New Features + +#### Schema API + +A type-safe request validation and coercion layer. Define a schema class with +typed fields and validators (required, length, format, range, pattern, enum). +Parsers handle JSON, multipart, query string, and XML bodies. Existing +`params["key"]` usage continues to work unchanged via `SchemaParamsWrapper`; +the Schema API is opt-in per action. + +See [docs/guides/schema-api.md](docs/guides/schema-api.md). + +#### Typed configuration with environment variable overrides + +`Amber::Configuration::AppConfig` holds all framework settings as typed Crystal +structs. Any field can be overridden at runtime via environment variables +following the `AMBER_{SECTION}_{KEY}` pattern without changing YAML files. +Custom configuration sections can be registered in the same registry. + +See [docs/guides/configuration.md](docs/guides/configuration.md). + +#### Built-in background jobs + +A work-stealing job queue that runs inside idle web workers — no separate +process required. Includes retry logic and a pluggable adapter interface (memory +adapter ships by default). Redis and other backends can be added via custom +adapters. + +See [docs/guides/background-jobs.md](docs/guides/background-jobs.md). + +#### Built-in mailer + +Fluent API for composing and sending email. Ships with an SMTP adapter and a +memory adapter for testing. The memory adapter records sent mail so tests can +assert on it without a real mail server. + +See [docs/guides/mailer.md](docs/guides/mailer.md). + +#### WebSocket modernization + +- Message decoders: plug in a decoder to parse incoming frames into typed + messages before they reach your channel handler +- Presence tracking: built-in presence API that tracks which users are + connected and broadcasts join/leave events +- Connection recovery: automatic reconnect with exponential backoff and + last-message-id replay +- Improved error handling and channel lifecycle callbacks + +See [docs/guides/websockets.md](docs/guides/websockets.md). + +#### Named routes, constraints, and API versioning + +- `route_url(:route_name, param: value)` — generate URLs by name rather than + hardcoded strings +- Route constraints: host, subdomain, format, and custom lambda constraints +- API versioning middleware (`ApiVersion` pipe) with header- and + URL-segment-based version detection +- Route introspection: list all registered routes with names, methods, and + paths at runtime + +See [docs/guides/routing.md](docs/guides/routing.md). + +#### Built-in markdown renderer + +A GFM-compatible Markdown renderer with autolinks, footnotes, table-of-contents +generation, and syntax highlighting hooks. No external dependency. + +See [docs/guides/markdown.md](docs/guides/markdown.md). + +#### Action helpers + +Helper methods for views: form builders, URL helpers, asset helpers, text +formatting, and number formatting. + +See [docs/guides/action-helpers.md](docs/guides/action-helpers.md). + +#### Built-in test framework + +`Amber::Testing::ContextBuilder` simulates HTTP requests against your +controllers without starting a real server. Includes WebSocket test helpers and +assertion methods for response status, headers, body content, and redirects. + +See [docs/guides/testing.md](docs/guides/testing.md). + +#### Session security improvements + +`MessageEncryptor` and `MessageVerifier` updated to SHA-256. SameSite cookie +attribute support. Key rotation support. Session regeneration on privilege +escalation. + +### Adapter Pattern + +Sessions, PubSub, jobs, and mailer now all use a pluggable adapter interface. +The `AdapterFactory` allows registering custom backends at startup. This is the +supported path for adding Redis sessions, Redis pub/sub, or any other backend +without coupling the framework to a specific shard. + +### Documentation + +A full documentation suite has been added under `docs/`: + +- [Migration guide](docs/migration-guide.md) — complete V1 to V2 upgrade instructions +- [Schema API guide](docs/guides/schema-api.md) +- [Configuration guide](docs/guides/configuration.md) +- [Routing guide](docs/guides/routing.md) (named routes, constraints, API versioning) +- [WebSocket guide](docs/guides/websockets.md) +- [Background jobs guide](docs/guides/background-jobs.md) +- [Mailer guide](docs/guides/mailer.md) +- [Testing guide](docs/guides/testing.md) +- [Markdown guide](docs/guides/markdown.md) +- [Action helpers guide](docs/guides/action-helpers.md) +- [LSP setup guide](docs/guides/lsp-setup.md) (Claude Code integration) + +--- + +For changes prior to V2, see the git log at +https://github.com/amberframework/amber/commits/master diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ffef25239 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +## Project Overview + +Amber V2 is a web application framework written in Crystal. It provides an efficient, cohesive framework that embraces Crystal's language philosophies with near-zero runtime dependencies. + +**Version**: 2.0.0-dev +**Crystal**: >= 1.0.0, < 2.0 +**License**: MIT +**Dependencies**: ameba (dev only) — framework is entirely self-contained at runtime + +## Development Commands + +```bash +# Install dependencies (ameba only) +shards install + +# Run all tests and checks (recommended) +./bin/amber_spec + +# Individual commands +crystal spec # Run test suite +crystal spec spec/amber/controller/base_spec.cr # Single file +crystal tool format --check # Check formatting +crystal tool format # Auto-format +./bin/ameba # Run linter +crystal docs # Generate API docs → docs/ +``` + +## Architecture Overview + +### Core Subsystems + +1. **Controllers** (`src/amber/controller/`): Base controller with before/after filters, rendering, redirects, CSRF helpers, route helpers, responders, i18n + +2. **Router** (`src/amber/router/`): HTTP routing with params, cookies, sessions, flash messages, file uploads. V2 adds named routes, constraints (host, subdomain, format, custom), and API versioning + +3. **Router Engine** (`src/amber/router/engine/`): Internalized radix-tree router (formerly amber_router shard). Segment-based matching with fixed, variable, and glob segments + +4. **Middleware Pipes** (`src/amber/pipes/`): Pipeline-based HTTP request processing — CSRF, Session, Flash, Logger, CORS, Static, Error, ClientIp, ApiVersion, PoweredByAmber + +5. **WebSockets** (`src/amber/websockets/`): Channel-based real-time communication with presence tracking, message decoders, connection recovery, and adapter-backed PubSub + +6. **Configuration** (`src/amber/configuration/`): Typed configuration structs with YAML loading, environment variable overrides (`AMBER_` prefix), validation, and custom registry + +7. **Schema API** (`src/amber/schema/`): Request validation and type coercion. Define schemas with field types, validators (required, length, format, range, pattern, enum), and parsers (JSON, multipart, query, XML) + +8. **Jobs** (`src/amber/jobs/`): Background job processing with work-stealing pattern — idle web instances pick up queued jobs. Retry logic, queue adapters + +9. **Mailer** (`src/amber/mailer/`): Email composition with fluent API, SMTP adapter, memory adapter for testing + +10. **Testing** (`src/amber/testing/`): ContextBuilder for request simulation, assertions, WebSocket test helpers + +11. **Adapters** (`src/amber/adapters/`): Pluggable backends for sessions (Memory, with adapter interface for Redis/others) and PubSub (Memory, with adapter interface). Factory pattern for runtime selection + +12. **Markdown** (`src/amber/markdown/`): GFM-compatible renderer with autolinks, footnotes, table of contents, syntax highlighting + +13. **Session Security** (`src/amber/support/`): MessageEncryptor and MessageVerifier with SHA256, SameSite cookies, key rotation, session regeneration + +14. **DSL** (`src/amber/dsl/`): Macros for server configuration, router definition, pipeline composition, and callback registration + +### Template Engine + +ECR (Embedded Crystal) only — part of Crystal stdlib. Kilt/Slang/Liquid have been removed. + +### Key Design Patterns + +- **Adapter Pattern**: Sessions, PubSub, jobs, and mailer use adapters for different backends +- **Pipeline Pattern**: HTTP requests flow through configurable middleware pipes +- **DSL Approach**: Router and server configuration use Crystal macros for clean syntax +- **Work-Stealing**: Background jobs distributed across idle web instances +- **Convention over Configuration**: Sensible defaults with typed overrides + +### Using Amber Without HTTP Server (Native Apps) + +Amber can be used as a pattern library for native desktop/mobile apps without starting the HTTP server. This is useful when you want Amber's configuration, controllers-as-event-handlers, and process managers but use a native event loop instead. + +**Settings API:** +```crystal +require "amber" + +# Correct: access settings directly +Amber.settings.name = "My Native App" +puts Amber.env # => "development" + +# WRONG: Amber::Server.configure creates an HTTP server instance +# Amber::Server.configure { |s| s.name = "..." } # Don't do this + +# WRONG: Amber::Server.settings doesn't exist +# Amber::Server.settings.name # NoMethodError +``` + +**Key point:** `Amber.settings` returns `Amber::Environment::Settings` and can be used standalone. `Amber::Server.configure` yields settings but also instantiates `Amber::Server.instance` which initializes HTTP-related adapters. + +### External Dependencies + +- **Grant ORM**: Separate shard — ActiveRecord-style ORM for V2 (publishing to the amberframework org soon) +- **CLI**: Separate project (`amber_cli`) — not part of this repo +- **Asset Pipeline**: Separate project (`amberframework/asset_pipeline`) — not part of this repo + +## Key File Paths + +| Area | Path | +|------|------| +| Entry point | `src/amber.cr` | +| Configuration | `src/amber/configuration/` | +| Controllers | `src/amber/controller/` | +| Router + Engine | `src/amber/router/`, `src/amber/router/engine/` | +| Named Routes | `src/amber/router/named_routes.cr` | +| Constraints | `src/amber/router/constraints/` | +| Middleware | `src/amber/pipes/` | +| WebSockets | `src/amber/websockets/` | +| Schema API | `src/amber/schema/` | +| Jobs | `src/amber/jobs/` | +| Mailer | `src/amber/mailer/` | +| Testing | `src/amber/testing/` | +| Adapters | `src/amber/adapters/` | +| Markdown | `src/amber/markdown/` | +| Session Security | `src/amber/support/message_encryptor.cr`, `message_verifier.cr` | +| DSL | `src/amber/dsl/` | diff --git a/Makefile b/Makefile index 73870235f..5719d05c6 100644 --- a/Makefile +++ b/Makefile @@ -1,42 +1,10 @@ -PREFIX=/usr/local -INSTALL_DIR=$(PREFIX)/bin -AMBER_SYSTEM=$(INSTALL_DIR)/amber +# Makefile for Amber framework +# This file can be used for any custom build tasks if needed -OUT_DIR=$(CURDIR)/bin -AMBER=$(OUT_DIR)/amber -AMBER_SOURCES=$(shell find src/ -type f -name '*.cr') +.PHONY: all test -all: build +all: + @echo "Amber is a web framework library, no build needed" -build: lib $(AMBER) - -lib: - @shards install --production - -$(AMBER): $(AMBER_SOURCES) | $(OUT_DIR) - @echo "Building amber in $@" - @crystal build -o $@ src/amber/cli.cr -p --no-debug - -$(OUT_DIR) $(INSTALL_DIR): - @mkdir -p $@ - -run: - $(AMBER) - -install: build | $(INSTALL_DIR) - @rm -f $(AMBER_SYSTEM) - @cp $(AMBER) $(AMBER_SYSTEM) - -link: build | $(INSTALL_DIR) - @echo "Symlinking $(AMBER) to $(AMBER_SYSTEM)" - @ln -s $(AMBER) $(AMBER_SYSTEM) - -force_link: build | $(INSTALL_DIR) - @echo "Symlinking $(AMBER) to $(AMBER_SYSTEM)" - @ln -sf $(AMBER) $(AMBER_SYSTEM) - -clean: - rm -rf $(AMBER) - -distclean: - rm -rf $(AMBER) .crystal .shards libs lib +test: + crystal spec \ No newline at end of file diff --git a/README.md b/README.md index b46ea7a4b..0b7af6570 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,70 @@ _Amber makes building web applications fast, simple, and enjoyable - with fewer bugs and blazing fast performance._ -[![Build Status](https://travis-ci.org/amberframework/amber.svg?branch=master)](https://travis-ci.org/amberframework/amber) [![Version](https://img.shields.io/github/tag/amberframework/amber.svg?maxAge=360&label=version)](https://github.com/amberframework/amber/releases/latest) [![License](https://img.shields.io/github/license/amberframework/amber.svg)](https://github.com/amberframework/amber/blob/master/LICENSE) -[![Liberapay patrons](https://img.shields.io/liberapay/patrons/amber-framework.svg?label=liberapay%20patrons)](https://liberapay.com/amber-framework/) +# Welcome! Introducing Amber -# Welcome to Amber +**Amber V2 is in active development (pre-release beta).** The `v2-dev` branch is stable enough for experimentation and early adoption but is not yet recommended for production. See [docs/migration-guide.md](docs/migration-guide.md) for a complete list of breaking changes from V1, and [amberframework/amber_cli](https://github.com/amberframework/amber_cli) for the standalone CLI tool that replaces the built-in generator commands. -**Amber** is a web application framework written in [Crystal](https://crystal-lang.org/) inspired by Kemal, Rails, Phoenix and other popular application frameworks. +**Amber** is a web application framework written in [Crystal](https://crystal-lang.org/) inspired by Kemal, Rails, Phoenix, Flutter and other popular application frameworks. -The purpose of Amber is not to create yet another framework, but to take advantage of the beautiful Crystal language capabilities and provide engineers and the Crystal community with an efficient, cohesive, well maintained web framework that embraces the language philosophies, conventions, and guidelines. +The original purpose of Amber was not to create yet another framework, but to take advantage of the beautiful Crystal language capabilities and provide engineers and the Crystal community with an efficient, cohesive, well maintained web framework that embraces the language philosophies, conventions, and guidelines. + +This is still mostly true, however, in the era of AI things are changing. Amber is embracing the use of AI for coding help and has begun making significant changes to support enabling automous AI assistants (aka coding agents) for writing projects. Amber borrows concepts that have already been battle tested and successful, and embraces new concepts through team and community collaboration and analysis, which also aligns with Crystal's philosophy. +## How Complete Is Amber? + +The goal for V2 is to cover every aspect of a modern web application. Here is where that stands today, honestly: + +**In this shard now:** + +1. MVC - controllers, ECR views, and a router with named routes, constraints, and API versioning +2. Schema API - type-safe request validation and params handling +3. Background jobs - built-in work-stealing job system with retries and a dead-letter queue +4. Transactional emails - built-in mailer with SMTP and memory adapters +5. WebSockets - channels with presence tracking, message decoders, and connection recovery + +**In the wider V2 ecosystem (separate shards):** + +6. CLI with generators, dev workflow, and an LSP server - [amberframework/amber_cli](https://github.com/amberframework/amber_cli) +7. ActiveRecord-style ORM (Grant) and file attachments - in active development, publishing to the amberframework org soon + +**On the roadmap (not built yet):** + +8. Cloud file storage, users & authentication, audit logging, API documentation generation, reactive views, MCP, and SBOM tooling + + +## How Amber Is Embracing AI + +On our blog in April of 2023, it was declared that AI would be treated as a first-class citizen. But, what does that really mean? At the time, I had 3 points: + +1. Enabling AI for the development process. +2. Helping generate and maintain documentation. +3. Creating training material for public documentation that can be published and eventually picked up for use in training SOTA models. + +Well, a lot has changed since then. The good news is that those objectives are just as relavent now as they were when I put them forward. + +### 1. Enabling AI For The Development Process + +Over the last year and a half, code editors that empower AI models that aren't specifically trained on a framework or technology have made significant progress. Cursor is the primary code editor that I've been using since the very beginning. Copilot was a fun introduction, however the real power of developing with an AI model is when you are using a tool like Cursor, Windsurf and other AI-native development environments. + +This has lead to the development of documentation as "rules" in the code base itself. Some documentation is explicitly written as rules for Cursor that will automatically be attached to the agents context window when it's doing work. Other documentation will just be markdown files in a `/help` directory. There's a combination of these two approaches that has been converging over the first half of 2025 that I think will become the prominent method of having models work within knowledge domains that they are unfamiliar with. + +### 2. Helping Generate And Maintain Documentation + +When I initially wrote about this, this objective was just a pie in the sky idea. However, all of the models have regressed to a point where this is just an average day writing code. I'm grateful that models have progressed. At this point, I don't have a specific workflow that I've developed outside of just doing things like prompting a model to look at a git diff and then generate some documentation (or update existing documentation) and working with it to reach an acceptable level of clarity and cohesion. + +### 3. Creating Training Material For Public Documentation & Training Material + +This part here is actually blurring together with number one. The reason being is because models are moving so quickly that they're gaining such power so fast. In the last two years, models are more than four times what they were since I originally wrote that objective. With the current pace of doubling in capabilities every seven months, I found that we have a better alignment with focusing on patterns with instructions and rules and just clear obvious development patterns that models intuitively understand is more effective than specifically creating training material. + +However, I want to be clear that this has not gone away from being a priority. I still think that there's going to be power in coming up with this training material so that we can fine-tune smaller models that people can then run on their local devices. Being able to use these small language models that are specialized in writing crystal and amber specifically is going to mean someone does not have to have a subscription to a very expensive frontier model in order to be productive. People will be able to discover amber, use amber, and use the resources that they already have available and without ongoing budgetary constraints. + + ## Community Questions? Join our [Discord](https://discord.gg/vwvP5zakSn). @@ -38,50 +88,17 @@ Read Amber documentation on https://docs.amberframework.org/amber ## Installation & Usage -#### Homebrew (macOS/Linux) - -``` -brew install amber -``` - -#### Linux - -Currently, the only option to install on Linux is from source. At the time of this writing, v1.4.1 is the current stable release, but you should use the most recent tag in place of that. - -``` -sudo apt-get install libreadline-dev libsqlite3-dev libpq-dev libmysqlclient-dev libssl-dev libyaml-dev libpcre3-dev libevent-dev -git clone https://github.com/amberframework/amber.git -cd amber -git checkout v1.4.1 -shards install -make -sudo make install -``` - -If you're using ArchLinux or similar distro try: - -``` -yay -S amber -``` - -#### Common - -To compile a local `bin/amber` per project use `shards build amber` - -To use it as dependency, add this to your application's `shard.yml`: +Add this to your application's `shard.yml`: ```yaml dependencies: amber: github: amberframework/amber + branch: v2-dev ``` [Read Amber quick start guide](https://docs.amberframework.org/amber/getting-started) -[Read Amber CLI commands usage](https://docs.amberframework.org/amber/cli) - -[Read more about Amber CLI installation guide](https://docs.amberframework.org/amber/guides/installation) - ## Have an Amber-based Project? Use Amber badge @@ -91,21 +108,6 @@ Use Amber badge ```markdown [![Amber Framework](https://img.shields.io/badge/using-amber_framework-orange.svg)](https://amberframework.org/) ``` -## Release Checklist - -- Test and release all dependencies -- Test everything locally -- Run `crelease 0.36.0` -- repoint amber to master branch in `src/amber/cli/templates/app/shard.yml.ecr` template -- update release notes -- update homebrew version and sha -- update linux repositories -- build and deploy docker image: - - verify Dockerfile is using the latest crystal version - - `docker login` - - `docker build -t amberframework/amber:0.36.0` - - `docker push amberframework/amber:0.36.0` - ## Contributing Contributing to Amber can be a rewarding way to learn, teach, and build experience in just about any skill you can imagine. You don’t have to become a lifelong contributor to enjoy participating in Amber. diff --git a/benchmarks/cross-framework/NOTES.md b/benchmarks/cross-framework/NOTES.md new file mode 100644 index 000000000..a6db52272 --- /dev/null +++ b/benchmarks/cross-framework/NOTES.md @@ -0,0 +1,83 @@ +# Cross-Framework Router Benchmark: Fairness Notes + +## What We Are Comparing + +These benchmarks compare **routing engines only** -- the component that takes +a URL path and matches it against a set of registered route patterns. We are +NOT comparing full web frameworks, which include middleware pipelines, +controller dispatch, request/response handling, template rendering, and many +other subsystems that affect real-world performance. + +## Routers Tested + +| Router | Used By | Strategy | +|--------|---------|----------| +| Amber V2 engine | Amber V2 | Segment tree with hash-indexed fixed segments | +| amber_router v0.4.4 | Amber V1 | Segment tree with linear scan | +| radix v0.4.1 | Kemal | Radix tree (compressed trie) | + +## Why Lucky and Athena Are Not Included + +Lucky and Athena use **compile-time routing** via Crystal macros and +annotations. Their routes are resolved during compilation, meaning there is no +runtime router object to benchmark. This is a fundamentally different +architectural trade-off: they exchange longer compile times for zero runtime +dispatch cost. This strategy is not directly comparable in micro-benchmarks +that measure runtime iterations per second. + +## Why Spider-Gazelle Is Not Included + +Spider-Gazelle does not have a standalone routing shard. Their +`action-controller` framework (v7.6.1) internally uses `lucky_router` from +the Lucky framework. Since Lucky's router relies on compile-time macro +resolution, there is no standalone runtime routing engine to benchmark. + +## Path Matching Only + +Some routers (like `radix`) are purely path-based and do not support HTTP +method dispatch (GET, POST, etc.). To ensure a fair comparison, all benchmarks +test **path matching only** -- finding the route that matches a given URL path +regardless of HTTP method. + +## Same Machine, Same Crystal Version + +All benchmarks were run on the same machine in the same session using Crystal +1.18.2. Amber V1 and V2 data was collected in prior benchmark runs on the +same hardware; radix benchmarks were run fresh. The `--release` flag was used +for all compiled benchmark binaries. + +## Route Patterns + +All routers were loaded with identical route patterns at each tier: +- **Fixed segments**: `/resource_N/action_M` (60% of routes) +- **Variable segments**: `/resource_N/:id/action_M` (35% of routes) +- **Glob segments**: `/resource_N/files/*filepath` (5% of routes) + +Using 30 resources and 10 actions, this scheme supports up to 10,000 unique +routes per tier without collisions. + +## Glob Support Varies + +All three routers support glob (catch-all / wildcard) patterns, though with +slightly different internal strategies. The `radix` shard handles globs inline +during its trie traversal. The Amber routers use dedicated glob segment types +within their segment trees. + +## Not-Found Behavior + +Not-found lookup performance varies significantly between implementations due +to architectural differences in early-exit strategies. The `radix` shard can +reject unknown paths very quickly at the trie root level when the first +characters do not match any stored prefix, resulting in much higher not-found +IPS values. This is an inherent advantage of the compressed trie data +structure for rejection, not necessarily an indicator of overall routing +quality. + +## Benchmark Methodology + +- **Warmup**: 2 seconds per measurement +- **Calculation**: 5 seconds per measurement +- **Tool**: Crystal's `Benchmark.ips` (reports iterations per second) +- **Memory**: Crystal's `Benchmark.memory` (reports bytes allocated per call) +- **Tiers tested**: 50, 100, 500, 1,000, 5,000, 10,000 registered routes +- **Compilation**: `--release` flag (LLVM optimizations enabled) diff --git a/benchmarks/cross-framework/results/comparison.json b/benchmarks/cross-framework/results/comparison.json new file mode 100644 index 000000000..84f9f88be --- /dev/null +++ b/benchmarks/cross-framework/results/comparison.json @@ -0,0 +1,422 @@ +[ + { + "router": "Amber V2", + "tier": 50.0, + "lookup_type": "fixed", + "ips": 3391337.09, + "memory_bytes": 16.0 + }, + { + "router": "Amber V2", + "tier": 50.0, + "lookup_type": "variable", + "ips": 4830523.56, + "memory_bytes": 4112.0 + }, + { + "router": "Amber V2", + "tier": 50.0, + "lookup_type": "glob", + "ips": 2357239.13, + "memory_bytes": 128.0 + }, + { + "router": "Amber V2", + "tier": 50.0, + "lookup_type": "notfound", + "ips": 5820628.08, + "memory_bytes": 16.0 + }, + { + "router": "Amber V2", + "tier": 100.0, + "lookup_type": "fixed", + "ips": 3355379.12, + "memory_bytes": 4096.0 + }, + { + "router": "Amber V2", + "tier": 100.0, + "lookup_type": "variable", + "ips": 4444034.69, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 100.0, + "lookup_type": "glob", + "ips": 2323343.81, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 100.0, + "lookup_type": "notfound", + "ips": 5659140.8, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 500.0, + "lookup_type": "fixed", + "ips": 3061312.6, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 500.0, + "lookup_type": "variable", + "ips": 4211997.31, + "memory_bytes": 4096.0 + }, + { + "router": "Amber V2", + "tier": 500.0, + "lookup_type": "glob", + "ips": 2155478.05, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 500.0, + "lookup_type": "notfound", + "ips": 5299937.17, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 1000.0, + "lookup_type": "fixed", + "ips": 2871729.0, + "memory_bytes": 6336.0 + }, + { + "router": "Amber V2", + "tier": 1000.0, + "lookup_type": "variable", + "ips": 3948592.92, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 1000.0, + "lookup_type": "glob", + "ips": 2039651.27, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 1000.0, + "lookup_type": "notfound", + "ips": 5001599.29, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 5000.0, + "lookup_type": "fixed", + "ips": 2560189.04, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 5000.0, + "lookup_type": "variable", + "ips": 3578016.52, + "memory_bytes": 4096.0 + }, + { + "router": "Amber V2", + "tier": 5000.0, + "lookup_type": "glob", + "ips": 1844764.45, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 5000.0, + "lookup_type": "notfound", + "ips": 4559748.42, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 10000.0, + "lookup_type": "fixed", + "ips": 2408580.46, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 10000.0, + "lookup_type": "variable", + "ips": 3303381.35, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 10000.0, + "lookup_type": "glob", + "ips": 1738898.28, + "memory_bytes": 0.0 + }, + { + "router": "Amber V2", + "tier": 10000.0, + "lookup_type": "notfound", + "ips": 4163191.56, + "memory_bytes": 0.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 100.0, + "lookup_type": "fixed", + "ips": 2552480.79, + "memory_bytes": 128.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 100.0, + "lookup_type": "variable", + "ips": 3538918.88, + "memory_bytes": 128.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 100.0, + "lookup_type": "glob", + "ips": 1643861.16, + "memory_bytes": 400.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 100.0, + "lookup_type": "notfound", + "ips": 3055038.52, + "memory_bytes": 128.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 5000.0, + "lookup_type": "fixed", + "ips": 1820287.98, + "memory_bytes": 0.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 5000.0, + "lookup_type": "variable", + "ips": 2160601.46, + "memory_bytes": 0.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 5000.0, + "lookup_type": "glob", + "ips": 67433.43, + "memory_bytes": 33920.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 5000.0, + "lookup_type": "notfound", + "ips": 2446383.37, + "memory_bytes": 0.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 10000.0, + "lookup_type": "fixed", + "ips": 1509978.3, + "memory_bytes": 0.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 10000.0, + "lookup_type": "variable", + "ips": 1789543.37, + "memory_bytes": 0.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 10000.0, + "lookup_type": "glob", + "ips": 31216.64, + "memory_bytes": 66624.0 + }, + { + "router": "Amber V1 (amber_router)", + "tier": 10000.0, + "lookup_type": "notfound", + "ips": 2370498.34, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 50.0, + "lookup_type": "fixed", + "ips": 3677383.72, + "memory_bytes": 48.0 + }, + { + "router": "radix (Kemal)", + "tier": 50.0, + "lookup_type": "variable", + "ips": 2966294.0, + "memory_bytes": 80.0 + }, + { + "router": "radix (Kemal)", + "tier": 50.0, + "lookup_type": "glob", + "ips": 2575014.19, + "memory_bytes": 4288.0 + }, + { + "router": "radix (Kemal)", + "tier": 50.0, + "lookup_type": "notfound", + "ips": 31947308.13, + "memory_bytes": 48.0 + }, + { + "router": "radix (Kemal)", + "tier": 100.0, + "lookup_type": "fixed", + "ips": 3987789.59, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 100.0, + "lookup_type": "variable", + "ips": 3001834.92, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 100.0, + "lookup_type": "glob", + "ips": 2566983.37, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 100.0, + "lookup_type": "notfound", + "ips": 32405852.44, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 500.0, + "lookup_type": "fixed", + "ips": 3116856.2, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 500.0, + "lookup_type": "variable", + "ips": 2565746.66, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 500.0, + "lookup_type": "glob", + "ips": 2440927.29, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 500.0, + "lookup_type": "notfound", + "ips": 28624152.43, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 1000.0, + "lookup_type": "fixed", + "ips": 3169460.6, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 1000.0, + "lookup_type": "variable", + "ips": 2153287.76, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 1000.0, + "lookup_type": "glob", + "ips": 2032716.52, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 1000.0, + "lookup_type": "notfound", + "ips": 15479523.97, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 5000.0, + "lookup_type": "fixed", + "ips": 1991679.16, + "memory_bytes": 4032.0 + }, + { + "router": "radix (Kemal)", + "tier": 5000.0, + "lookup_type": "variable", + "ips": 1699430.32, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 5000.0, + "lookup_type": "glob", + "ips": 1826799.28, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 5000.0, + "lookup_type": "notfound", + "ips": 11771372.12, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 10000.0, + "lookup_type": "fixed", + "ips": 2055326.3, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 10000.0, + "lookup_type": "variable", + "ips": 1816790.87, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 10000.0, + "lookup_type": "glob", + "ips": 1975102.57, + "memory_bytes": 0.0 + }, + { + "router": "radix (Kemal)", + "tier": 10000.0, + "lookup_type": "notfound", + "ips": 13953504.6, + "memory_bytes": 0.0 + } +] \ No newline at end of file diff --git a/benchmarks/cross-framework/shard.yml b/benchmarks/cross-framework/shard.yml new file mode 100644 index 000000000..693fb7b4f --- /dev/null +++ b/benchmarks/cross-framework/shard.yml @@ -0,0 +1,16 @@ +name: router-benchmarks +version: 0.1.0 + +dependencies: + amber_router: + github: amberframework/amber-router + version: ~> 0.4.4 + radix: + github: luislavena/radix + version: ~> 0.4.1 + +# Spider-Gazelle does not have a standalone router shard. +# Their action-controller framework uses lucky_router internally. +# See NOTES.md for details. + +crystal: ">= 1.0.0" diff --git a/benchmarks/cross-framework/src/benchmark.cr b/benchmarks/cross-framework/src/benchmark.cr new file mode 100644 index 000000000..efda38244 --- /dev/null +++ b/benchmarks/cross-framework/src/benchmark.cr @@ -0,0 +1,270 @@ +require "benchmark" +require "json" +require "./routers/kemal_radix" + +# Cross-framework router benchmark runner. +# +# Runs fresh benchmarks for the radix shard (used by Kemal). +# Amber V1 and V2 data is loaded from existing benchmark results +# to avoid namespace conflicts between the old and new router +# implementations (both define Amber::Router::RouteSet but with +# incompatible struct/class definitions for internal types). +# +# Routes follow a consistent pattern using 30 resources and 10 actions, +# matching the same distribution used in the Amber benchmarks. + +RESOURCES = (1..30).map { |i| "resource_#{i}" } +ACTIONS = (1..10).map { |i| "action_#{i}" } +TIERS = [50, 100, 500, 1000, 5000, 10000] + +# Generates route definitions as an array of {path, payload} tuples. +# Distribution matches the Amber benchmarks: +# - Fixed: 60% - /resource_N/action_M +# - Variable: 35% - /resource_N/:id/action_M +# - Glob: 5% - /resource_N/files/*filepath +def generate_routes(count : Int32) : Array({String, Symbol}) + routes = [] of {String, Symbol} + + # Fixed routes: 60% of total + num_fixed = (count * 0.6).to_i + num_fixed.times do |i| + resource = RESOURCES[i % RESOURCES.size] + action = ACTIONS[(i // RESOURCES.size) % ACTIONS.size] + group = i // (RESOURCES.size * ACTIONS.size) + if group == 0 + routes << {"/#{resource}/#{action}", :"fixed_#{i}"} + else + routes << {"/api/v#{group}/#{resource}/#{action}", :"fixed_#{i}"} + end + end + + # Variable routes: 35% of total + num_variable = (count * 0.35).to_i + num_variable.times do |i| + resource = RESOURCES[i % RESOURCES.size] + action = ACTIONS[(i // RESOURCES.size) % ACTIONS.size] + group = i // (RESOURCES.size * ACTIONS.size) + if group == 0 + routes << {"/#{resource}/:id/#{action}", :"variable_#{i}"} + else + routes << {"/api/v#{group}/#{resource}/:id/#{action}", :"variable_#{i}"} + end + end + + # Glob routes: 5% of total + num_glob = (count * 0.05).to_i + num_glob.times do |i| + resource = RESOURCES[i % RESOURCES.size] + group = i // RESOURCES.size + if group == 0 + routes << {"/#{resource}/files/*filepath", :"glob_#{i}"} + else + routes << {"/#{resource}/assets/v#{group}/*filepath", :"glob_#{i}"} + end + end + + routes +end + +# Sample lookup paths for each lookup type. +def fixed_lookup_path(tier : Int32) : String + i = [tier // 3, 1].max + resource = RESOURCES[i % RESOURCES.size] + action = ACTIONS[(i // RESOURCES.size) % ACTIONS.size] + group = i // (RESOURCES.size * ACTIONS.size) + if group == 0 + "/#{resource}/#{action}" + else + "/api/v#{group}/#{resource}/#{action}" + end +end + +def variable_lookup_path(tier : Int32) : String + i = [tier // 5, 1].max + resource = RESOURCES[i % RESOURCES.size] + action = ACTIONS[(i // RESOURCES.size) % ACTIONS.size] + group = i // (RESOURCES.size * ACTIONS.size) + if group == 0 + "/#{resource}/42/#{action}" + else + "/api/v#{group}/#{resource}/42/#{action}" + end +end + +def glob_lookup_path(tier : Int32) : String + "/resource_1/files/path/to/deep/nested/file.txt" +end + +def notfound_lookup_path : String + "/definitely/not/a/real/route/anywhere" +end + +alias BenchEntry = Hash(String, String | Float64) + +# --- Load existing Amber benchmark data --- + +def load_amber_v2_data : Array(BenchEntry) + entries = [] of BenchEntry + path = File.join(__DIR__, "..", "..", "results", "final_expanded.json") + + unless File.exists?(path) + STDERR.puts "WARNING: Amber V2 data not found at #{path}" + return entries + end + + raw = Array(Hash(String, String)).from_json(File.read(path)) + raw.each do |row| + tier = row["tier"].to_f64 + next unless TIERS.includes?(tier.to_i) + + # Extract each lookup type from the row + [ + {"fixed", row["ips_fixed"], row["memory_fixed_bytes"]}, + {"variable", row["ips_variable"], row["memory_variable_bytes"]}, + {"glob", row["ips_glob"], row["memory_glob_bytes"]}, + {"notfound", row["ips_notfound"], row["memory_notfound_bytes"]}, + ].each do |(lookup_type, ips_str, mem_str)| + entries << { + "router" => "Amber V2", + "tier" => tier, + "lookup_type" => lookup_type, + "ips" => ips_str.to_f64, + "memory_bytes" => mem_str.to_f64, + } of String => String | Float64 + end + end + + entries +end + +def load_amber_v1_data : Array(BenchEntry) + entries = [] of BenchEntry + path = File.join(__DIR__, "..", "..", "results", "baseline_expanded.json") + + unless File.exists?(path) + STDERR.puts "WARNING: Amber V1 data not found at #{path}" + return entries + end + + raw = Array(Hash(String, String)).from_json(File.read(path)) + raw.each do |row| + tier = row["tier"].to_f64 + next unless TIERS.includes?(tier.to_i) + + [ + {"fixed", row["ips_fixed"], row["memory_fixed_bytes"]}, + {"variable", row["ips_variable"], row["memory_variable_bytes"]}, + {"glob", row["ips_glob"], row["memory_glob_bytes"]}, + {"notfound", row["ips_notfound"], row["memory_notfound_bytes"]}, + ].each do |(lookup_type, ips_str, mem_str)| + entries << { + "router" => "Amber V1 (amber_router)", + "tier" => tier, + "lookup_type" => lookup_type, + "ips" => ips_str.to_f64, + "memory_bytes" => mem_str.to_f64, + } of String => String | Float64 + end + end + + entries +end + +# --- Run fresh radix benchmarks --- + +def run_radix_benchmarks : Array(BenchEntry) + entries = [] of BenchEntry + + TIERS.each do |tier| + routes = generate_routes(tier) + router = KemalRadixRouter.new + + # Register all routes + reg_time = Benchmark.realtime do + routes.each do |path, payload| + router.add_route(path, payload) + end + end + + puts "\n#{"=" * 60}" + puts " radix (Kemal) - TIER: #{tier} routes (#{routes.size} registered in #{reg_time.total_milliseconds.round(3)}ms)" + puts "=" * 60 + + # Lookup paths + fp = fixed_lookup_path(tier) + vp = variable_lookup_path(tier) + gp = glob_lookup_path(tier) + nfp = notfound_lookup_path + + # Verify lookups work correctly + puts " Verify: fixed=#{router.lookup(fp)} variable=#{router.lookup(vp)} glob=#{router.lookup(gp)} notfound=#{router.lookup(nfp)}" + + # Memory benchmarks + mem_fixed = Benchmark.memory { router.lookup(fp) } + mem_variable = Benchmark.memory { router.lookup(vp) } + mem_glob = Benchmark.memory { router.lookup(gp) } + mem_notfound = Benchmark.memory { router.lookup(nfp) } + + puts " Memory: fixed=#{mem_fixed}B variable=#{mem_variable}B glob=#{mem_glob}B notfound=#{mem_notfound}B" + + # IPS benchmarks for each lookup type + [ + {:fixed, fp, mem_fixed}, + {:variable, vp, mem_variable}, + {:glob, gp, mem_glob}, + {:notfound, nfp, mem_notfound}, + ].each do |(lookup_type, path, memory)| + job = Benchmark.ips(warmup: 2.seconds, calculation: 5.seconds) do |x| + x.report("radix #{lookup_type} #{tier}r") { router.lookup(path) } + end + + ips_value = job.items.first.mean + + entries << { + "router" => "radix (Kemal)", + "tier" => tier.to_f64, + "lookup_type" => lookup_type.to_s, + "ips" => ips_value.round(2), + "memory_bytes" => memory.to_f64, + } of String => String | Float64 + + puts " #{lookup_type}: #{ips_value.round(0)} IPS, #{memory} bytes/lookup" + end + end + + entries +end + +# --- Main --- + +puts "Cross-Framework Router Benchmark" +puts "Crystal #{Crystal::VERSION}" +puts "================================\n" + +# Load existing data +puts "Loading existing Amber V2 data..." +amber_v2_data = load_amber_v2_data +puts " Loaded #{amber_v2_data.size} entries" + +puts "Loading existing Amber V1 data..." +amber_v1_data = load_amber_v1_data +puts " Loaded #{amber_v1_data.size} entries" + +# Run fresh radix benchmarks +puts "\nRunning fresh radix (Kemal) benchmarks..." +radix_data = run_radix_benchmarks + +# Merge all results +all_results = amber_v2_data + amber_v1_data + radix_data + +# Write combined results +output_path = File.join(__DIR__, "..", "results", "comparison.json") +Dir.mkdir_p(File.dirname(output_path)) +File.write(output_path, all_results.to_pretty_json) + +puts "\n\n#{"=" * 60}" +puts "Results written to #{output_path}" +puts "Total entries: #{all_results.size}" +puts " Amber V2: #{amber_v2_data.size} entries" +puts " Amber V1: #{amber_v1_data.size} entries" +puts " radix: #{radix_data.size} entries" diff --git a/benchmarks/cross-framework/src/routers/amber_v1.cr b/benchmarks/cross-framework/src/routers/amber_v1.cr new file mode 100644 index 000000000..519ea1519 --- /dev/null +++ b/benchmarks/cross-framework/src/routers/amber_v1.cr @@ -0,0 +1,23 @@ +require "amber_router" + +# Adapter for the amber_router v0.4.4 shard (used by Amber V1). +# Uses a segment tree with linear scan for lookups. +class AmberV1Router + getter name : String = "Amber V1 (amber_router)" + + def initialize + @router = Amber::Router::RouteSet(Symbol).new + end + + def add_route(path : String, payload : Symbol) : Nil + @router.add(path, payload) + end + + def lookup(path : String) : Bool + @router.find(path).found? + end + + def supports_glob? : Bool + true + end +end diff --git a/benchmarks/cross-framework/src/routers/amber_v2.cr b/benchmarks/cross-framework/src/routers/amber_v2.cr new file mode 100644 index 000000000..3a27494d6 --- /dev/null +++ b/benchmarks/cross-framework/src/routers/amber_v2.cr @@ -0,0 +1,23 @@ +require "../../../../src/amber/router/engine" + +# Adapter for the Amber V2 internalized routing engine. +# Uses hash-indexed fixed segments for O(1) lookups. +class AmberV2Router + getter name : String = "Amber V2" + + def initialize + @router = Amber::Router::RouteSet(Symbol).new + end + + def add_route(path : String, payload : Symbol) : Nil + @router.add(path, payload) + end + + def lookup(path : String) : Bool + @router.find(path).found? + end + + def supports_glob? : Bool + true + end +end diff --git a/benchmarks/cross-framework/src/routers/kemal_radix.cr b/benchmarks/cross-framework/src/routers/kemal_radix.cr new file mode 100644 index 000000000..9fd593feb --- /dev/null +++ b/benchmarks/cross-framework/src/routers/kemal_radix.cr @@ -0,0 +1,27 @@ +require "radix" + +# Adapter for the radix shard (used by Kemal). +# Uses a compressed radix tree (trie) for lookups. +# +# Note: radix does not support HTTP method dispatch -- it is purely +# a path-matching engine. This is fine for our comparison since we +# are benchmarking path matching performance specifically. +class KemalRadixRouter + getter name : String = "radix (Kemal)" + + def initialize + @tree = Radix::Tree(Symbol).new + end + + def add_route(path : String, payload : Symbol) : Nil + @tree.add(path, payload) + end + + def lookup(path : String) : Bool + @tree.find(path).found? + end + + def supports_glob? : Bool + true + end +end diff --git a/benchmarks/results/baseline.json b/benchmarks/results/baseline.json new file mode 100644 index 000000000..5024dff06 --- /dev/null +++ b/benchmarks/results/baseline.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.02575", + "memory_fixed_bytes": "4224", + "memory_variable_bytes": "4224", + "memory_glob_bytes": "400", + "memory_notfound_bytes": "128" + }, + { + "tier": "200", + "registration_ms": "0.05225", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "4160", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.151542", + "memory_fixed_bytes": "576", + "memory_variable_bytes": "4192", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.408375", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "12464", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.762875", + "memory_fixed_bytes": "1152", + "memory_variable_bytes": "0", + "memory_glob_bytes": "14144", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/baseline_expanded.json b/benchmarks/results/baseline_expanded.json new file mode 100644 index 000000000..7408d2479 --- /dev/null +++ b/benchmarks/results/baseline_expanded.json @@ -0,0 +1,41 @@ +[ + { + "tier": "100", + "source": "amber_router shard v0.4.4", + "registration_ms": "0.038375", + "memory_fixed_bytes": "128", + "memory_variable_bytes": "128", + "memory_glob_bytes": "400", + "memory_notfound_bytes": "128", + "ips_fixed": "2552480.79", + "ips_variable": "3538918.88", + "ips_glob": "1643861.16", + "ips_notfound": "3055038.52" + }, + { + "tier": "5000", + "source": "amber_router shard v0.4.4", + "registration_ms": "2.839708", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "33920", + "memory_notfound_bytes": "0", + "ips_fixed": "1820287.98", + "ips_variable": "2160601.46", + "ips_glob": "67433.43", + "ips_notfound": "2446383.37" + }, + { + "tier": "10000", + "source": "amber_router shard v0.4.4", + "registration_ms": "6.0035", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "66624", + "memory_notfound_bytes": "0", + "ips_fixed": "1509978.3", + "ips_variable": "1789543.37", + "ips_glob": "31216.64", + "ips_notfound": "2370498.34" + } +] \ No newline at end of file diff --git a/benchmarks/results/final_expanded.json b/benchmarks/results/final_expanded.json new file mode 100644 index 000000000..1ff75364b --- /dev/null +++ b/benchmarks/results/final_expanded.json @@ -0,0 +1,98 @@ +[ + { + "tier": "50", + "registration_ms": "0.02925", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16", + "ips_fixed": "3391337.09", + "ips_variable": "4830523.56", + "ips_glob": "2357239.13", + "ips_notfound": "5820628.08" + }, + { + "tier": "100", + "registration_ms": "0.027541", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0", + "ips_fixed": "3355379.12", + "ips_variable": "4444034.69", + "ips_glob": "2323343.81", + "ips_notfound": "5659140.8" + }, + { + "tier": "200", + "registration_ms": "0.047625", + "memory_fixed_bytes": "7632", + "memory_variable_bytes": "0", + "memory_glob_bytes": "3840", + "memory_notfound_bytes": "0", + "ips_fixed": "3199943.29", + "ips_variable": "4324775.23", + "ips_glob": "2217242.33", + "ips_notfound": "5427331.62" + }, + { + "tier": "500", + "registration_ms": "0.196333", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "4096", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0", + "ips_fixed": "3061312.6", + "ips_variable": "4211997.31", + "ips_glob": "2155478.05", + "ips_notfound": "5299937.17" + }, + { + "tier": "1000", + "registration_ms": "0.375375", + "memory_fixed_bytes": "6336", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0", + "ips_fixed": "2871729.0", + "ips_variable": "3948592.92", + "ips_glob": "2039651.27", + "ips_notfound": "5001599.29" + }, + { + "tier": "2000", + "registration_ms": "0.667042", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "624", + "memory_notfound_bytes": "0", + "ips_fixed": "2517970.97", + "ips_variable": "3473443.52", + "ips_glob": "1796801.83", + "ips_notfound": "4406299.23" + }, + { + "tier": "5000", + "registration_ms": "2.375292", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "4096", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0", + "ips_fixed": "2560189.04", + "ips_variable": "3578016.52", + "ips_glob": "1844764.45", + "ips_notfound": "4559748.42" + }, + { + "tier": "10000", + "registration_ms": "4.5465", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0", + "ips_fixed": "2408580.46", + "ips_variable": "3303381.35", + "ips_glob": "1738898.28", + "ips_notfound": "4163191.56" + } +] \ No newline at end of file diff --git a/benchmarks/results/final_optimized.json b/benchmarks/results/final_optimized.json new file mode 100644 index 000000000..fb0d51e19 --- /dev/null +++ b/benchmarks/results/final_optimized.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.02525", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.0515", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "4064", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.206625", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "4080", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.368834", + "memory_fixed_bytes": "4032", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.659542", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/micro_20260213_172511.json b/benchmarks/results/micro_20260213_172511.json new file mode 100644 index 000000000..5024dff06 --- /dev/null +++ b/benchmarks/results/micro_20260213_172511.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.02575", + "memory_fixed_bytes": "4224", + "memory_variable_bytes": "4224", + "memory_glob_bytes": "400", + "memory_notfound_bytes": "128" + }, + { + "tier": "200", + "registration_ms": "0.05225", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "4160", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.151542", + "memory_fixed_bytes": "576", + "memory_variable_bytes": "4192", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.408375", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "12464", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.762875", + "memory_fixed_bytes": "1152", + "memory_variable_bytes": "0", + "memory_glob_bytes": "14144", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/micro_20260213_172834.json b/benchmarks/results/micro_20260213_172834.json new file mode 100644 index 000000000..333d61ae2 --- /dev/null +++ b/benchmarks/results/micro_20260213_172834.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.031167", + "memory_fixed_bytes": "4112", + "memory_variable_bytes": "16", + "memory_glob_bytes": "176", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.062375", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.26175", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "384", + "memory_glob_bytes": "4160", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "1.244875", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.844541", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "3744", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/micro_20260213_173202.json b/benchmarks/results/micro_20260213_173202.json new file mode 100644 index 000000000..b45482b20 --- /dev/null +++ b/benchmarks/results/micro_20260213_173202.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.027333", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.109334", + "memory_fixed_bytes": "4160", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.152125", + "memory_fixed_bytes": "2752", + "memory_variable_bytes": "0", + "memory_glob_bytes": "2048", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.319208", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.845667", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "3456" + } +] \ No newline at end of file diff --git a/benchmarks/results/micro_20260213_173518.json b/benchmarks/results/micro_20260213_173518.json new file mode 100644 index 000000000..89ad413c4 --- /dev/null +++ b/benchmarks/results/micro_20260213_173518.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.025291", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.051709", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "4192", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.129833", + "memory_fixed_bytes": "4832", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.389292", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.452709", + "memory_fixed_bytes": "2464", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/micro_20260213_173753.json b/benchmarks/results/micro_20260213_173753.json new file mode 100644 index 000000000..fb0d51e19 --- /dev/null +++ b/benchmarks/results/micro_20260213_173753.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.02525", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.0515", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "4064", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.206625", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "4080", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.368834", + "memory_fixed_bytes": "4032", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.659542", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/opt_4_segment_split.json b/benchmarks/results/opt_4_segment_split.json new file mode 100644 index 000000000..333d61ae2 --- /dev/null +++ b/benchmarks/results/opt_4_segment_split.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.031167", + "memory_fixed_bytes": "4112", + "memory_variable_bytes": "16", + "memory_glob_bytes": "176", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.062375", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.26175", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "384", + "memory_glob_bytes": "4160", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "1.244875", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.844541", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "3744", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/results/opt_5_terminal_struct.json b/benchmarks/results/opt_5_terminal_struct.json new file mode 100644 index 000000000..b45482b20 --- /dev/null +++ b/benchmarks/results/opt_5_terminal_struct.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.027333", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.109334", + "memory_fixed_bytes": "4160", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.152125", + "memory_fixed_bytes": "2752", + "memory_variable_bytes": "0", + "memory_glob_bytes": "2048", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.319208", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.845667", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "3456" + } +] \ No newline at end of file diff --git a/benchmarks/results/opt_6_index_and_splitpath.json b/benchmarks/results/opt_6_index_and_splitpath.json new file mode 100644 index 000000000..89ad413c4 --- /dev/null +++ b/benchmarks/results/opt_6_index_and_splitpath.json @@ -0,0 +1,42 @@ +[ + { + "tier": "50", + "registration_ms": "0.025291", + "memory_fixed_bytes": "16", + "memory_variable_bytes": "4112", + "memory_glob_bytes": "128", + "memory_notfound_bytes": "16" + }, + { + "tier": "200", + "registration_ms": "0.051709", + "memory_fixed_bytes": "4096", + "memory_variable_bytes": "0", + "memory_glob_bytes": "4192", + "memory_notfound_bytes": "0" + }, + { + "tier": "500", + "registration_ms": "0.129833", + "memory_fixed_bytes": "4832", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "1000", + "registration_ms": "0.389292", + "memory_fixed_bytes": "0", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + }, + { + "tier": "2000", + "registration_ms": "0.452709", + "memory_fixed_bytes": "2464", + "memory_variable_bytes": "0", + "memory_glob_bytes": "0", + "memory_notfound_bytes": "0" + } +] \ No newline at end of file diff --git a/benchmarks/router_micro.cr b/benchmarks/router_micro.cr new file mode 100644 index 000000000..5bc009230 --- /dev/null +++ b/benchmarks/router_micro.cr @@ -0,0 +1,140 @@ +require "benchmark" +require "json" +require "../src/amber/router/engine" + +# Generate realistic route sets at each tier. +# Distribution: 60% fixed, 25% variable, 10% constrained, 5% glob. +# Designed to produce unique routes up to 10000+ without collisions. +def generate_routes(count : Int32) : Array({String, Symbol}) + routes = [] of {String, Symbol} + resources = ["users", "posts", "comments", "tags", "categories", + "products", "orders", "invoices", "settings", "teams", + "projects", "tasks", "notifications", "messages", "files", + "accounts", "roles", "permissions", "sessions", "tokens", + "webhooks", "events", "logs", "audits", "reports", + "dashboards", "widgets", "charts", "exports", "imports"] + + actions = ["index", "new", "create", "search", "export", + "list", "show", "stats", "archive", "restore"] + + # 60% fixed -- unique via resource/action/version/group combos + num_fixed = (count * 0.6).to_i + base_combos = resources.size * actions.size # 30 * 10 = 300 + num_fixed.times do |i| + resource = resources[i % resources.size] + action = actions[(i // resources.size) % actions.size] + group = i // base_combos + if group == 0 + routes << {"/#{resource}/#{action}", :"fixed_#{i}"} + elsif group <= 3 + routes << {"/api/v#{group}/#{resource}/#{action}", :"fixed_#{i}"} + else + routes << {"/api/v#{((group - 1) % 3) + 1}/g#{group}/#{resource}/#{action}", :"fixed_#{i}"} + end + end + + # 25% variable -- unique via resource + suffix combos + num_variable = (count * 0.25).to_i + num_variable.times do |i| + resource = resources[i % resources.size] + suffix = i // resources.size + if suffix == 0 + routes << {"/#{resource}/:id", :"variable_#{i}"} + else + routes << {"/#{resource}/:id/detail#{suffix}", :"variable_#{i}"} + end + end + + # 10% constrained -- unique via resource + suffix combos + num_constrained = (count * 0.1).to_i + num_constrained.times do |i| + resource = resources[i % resources.size] + suffix = i // resources.size + if suffix == 0 + routes << {"/#{resource}/:id/edit", :"constrained_#{i}"} + else + routes << {"/#{resource}/:id/edit#{suffix}", :"constrained_#{i}"} + end + end + + # 5% glob -- unique via version + index + num_glob = (count * 0.05).to_i + num_glob.times do |i| + routes << {"/assets/v#{(i % 5) + 1}/*path#{i}", :"glob_#{i}"} + end + + routes +end + +# Build a populated RouteSet from generated routes +def build_router(routes : Array({String, Symbol})) : Amber::Router::RouteSet(Symbol) + router = Amber::Router::RouteSet(Symbol).new + routes.each do |path, payload| + router.add(path, payload) + end + router +end + +tiers = [50, 100, 200, 500, 1000, 2000, 5000, 10000] + +results = [] of Hash(String, String) + +tiers.each do |tier| + routes = generate_routes(tier) + + # --- Registration benchmark --- + reg_time = Benchmark.realtime do + build_router(routes) + end + + # --- Build router for lookup tests --- + router = build_router(routes) + + # Select representative lookup paths + fixed_path = "/users/index" + variable_path = "/users/#{rand(10000)}" + glob_path = "/assets/v1/some/deep/nested/path" + notfound_path = "/definitely/not/a/real/#{rand(100000)}/route" + + # --- Memory per lookup --- + mem_fixed = Benchmark.memory { router.find(fixed_path) } + mem_variable = Benchmark.memory { router.find(variable_path) } + mem_glob = Benchmark.memory { router.find(glob_path) } + mem_notfound = Benchmark.memory { router.find(notfound_path) } + + puts "\n=== Tier: #{tier} routes (registered in #{reg_time.total_milliseconds.round(3)}ms) ===" + puts " Memory per lookup: fixed=#{mem_fixed}B variable=#{mem_variable}B glob=#{mem_glob}B notfound=#{mem_notfound}B" + + # Capture IPS results from the benchmark job + job = Benchmark.ips(calculation: 5.seconds, warmup: 2.seconds) do |x| + x.report("#{tier}r fixed ") { router.find(fixed_path) } + x.report("#{tier}r variable ") { router.find(variable_path) } + x.report("#{tier}r glob ") { router.find(glob_path) } + x.report("#{tier}r notfound ") { router.find(notfound_path) } + end + + # Extract IPS (mean) from job items: fixed, variable, glob, notfound + items = job.items + ips_fixed = items[0].mean + ips_variable = items[1].mean + ips_glob = items[2].mean + ips_notfound = items[3].mean + + results << { + "tier" => tier.to_s, + "registration_ms" => reg_time.total_milliseconds.round(6).to_s, + "memory_fixed_bytes" => mem_fixed.to_s, + "memory_variable_bytes" => mem_variable.to_s, + "memory_glob_bytes" => mem_glob.to_s, + "memory_notfound_bytes" => mem_notfound.to_s, + "ips_fixed" => ips_fixed.round(2).to_s, + "ips_variable" => ips_variable.round(2).to_s, + "ips_glob" => ips_glob.round(2).to_s, + "ips_notfound" => ips_notfound.round(2).to_s, + } +end + +# Output as JSON for tracking +output_path = "benchmarks/results/final_expanded.json" +File.write(output_path, results.to_pretty_json) +puts "\nResults written to #{output_path}" diff --git a/bin/amber_spec b/bin/amber_spec deleted file mode 100755 index 222a9d739..000000000 --- a/bin/amber_spec +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -echo "Running './bin/ameba':" -./bin/ameba - -echo "\nRunning 'crystal tool format --check':" -crystal tool format --check - -echo "\nRunning 'crystal spec':" -crystal spec - -echo "\nRunning 'crystal spec ./spec/build_spec_granite.cr':" -crystal spec ./spec/build_spec_granite.cr diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..9ba293dc2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Amber V2 Documentation + +## Getting Started + +- [Getting Started](getting-started.md) -- Create a minimal Amber V2 application from scratch + +## Guides + +- [Routing](guides/routing.md) -- Route definitions, HTTP methods, resources, namespaces, pipelines, constraints, API versioning, named routes, and WebSocket routes +- [Configuration](guides/configuration.md) -- Typed configuration system, environment YAML files, environment variable overrides, custom config sections, and validation +- [Schema API](guides/schema-api.md) -- Type-safe parameter handling with field declarations, type coercion, validation, custom validators, nested schemas, and content-type parsers +- [Action Helpers](guides/action-helpers.md) -- Form helpers, URL helpers, asset tags, tag helpers, text formatting, and number formatting +- [WebSockets](guides/websockets.md) -- Channel-based real-time communication, client sockets, message protocol, decoders, presence tracking, connection recovery, and error handling +- [Background Jobs](guides/background-jobs.md) -- Job definition, enqueueing, delayed and scheduled jobs, retries, dead-letter queue, workers, work stealing, and queue adapters +- [Mailer](guides/mailer.md) -- Email composition with fluent API, attachments, SMTP delivery, memory adapter for testing, and custom delivery adapters +- [Testing](guides/testing.md) -- Request helpers, test responses, assertions, controller testing, context builder, and WebSocket testing +- [Markdown](guides/markdown.md) -- Markdown to HTML rendering, GFM extensions, table of contents, footnotes, syntax highlighting, and safe mode + +## Migration + +- [Migration Guide](migration-guide.md) -- Migrating from Amber V1 to V2, covering all breaking changes with before/after code examples diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..825dde593 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,203 @@ +# Getting Started + +This guide walks through creating a minimal Amber V2 application from scratch. By the end, you will have a working web server that responds to HTTP requests with a rendered HTML page. + +## Prerequisites + +- [Crystal](https://crystal-lang.org/install/) >= 1.0.0 +- A text editor + +## Create a New Project + +Create a new Crystal project using `crystal init`: + +```bash +crystal init app my_app +cd my_app +``` + +## Add Amber as a Dependency + +Edit `shard.yml` to add the Amber framework: + +```yaml +name: my_app +version: 0.1.0 + +dependencies: + amber: + github: amberframework/amber + branch: v2-dev + +crystal: ">= 1.0.0" +``` + +Install dependencies: + +```bash +shards install +``` + +## Project Structure + +Create the following directory structure: + +``` +my_app/ + src/ + my_app.cr + controllers/ + home_controller.cr + views/ + home/ + index.ecr + layouts/ + application.ecr +``` + +Create the directories: + +```bash +mkdir -p src/controllers src/views/home src/views/layouts +``` + +## Create a Controller + +Create `src/controllers/home_controller.cr`: + +```crystal +require "amber/controller/base" + +class HomeController < Amber::Controller::Base + def index + @title = "Welcome" + render("index.ecr") + end +end +``` + +The `render` macro looks for the template at `src/views/home/index.ecr` based on the controller name. It wraps the template in the default layout at `src/views/layouts/application.ecr`. + +## Create Views + +Create the layout at `src/views/layouts/application.ecr`: + +```ecr + + + + + My App + + + <%= content %> + + +``` + +The `<%= content %>` placeholder is where the action template is inserted. + +Create the index template at `src/views/home/index.ecr`: + +```ecr +

<%= @title %>

+

Hello from Amber V2!

+``` + +## Configure the Server + +Edit `src/my_app.cr` to configure routing and start the server: + +```crystal +require "amber" +require "./controllers/*" + +Amber::Server.configure do + pipeline :web do + plug Amber::Pipe::Error.new + plug Amber::Pipe::Logger.new + end + + routes :web do + get "/", HomeController, :index + end +end + +Amber::Server.start +``` + +This configures a `:web` pipeline with error handling and request logging, maps `GET /` to `HomeController#index`, and starts the HTTP server. + +## Run the Application + +```bash +crystal run src/my_app.cr +``` + +Visit `http://localhost:3000` in your browser to see your page. + +## Configuration + +By default, Amber listens on `localhost:3000`. To change the host or port, create a YAML configuration file or use environment variables. + +### Environment Variables + +```bash +AMBER_SERVER_HOST=0.0.0.0 AMBER_SERVER_PORT=8080 crystal run src/my_app.cr +``` + +### YAML Configuration + +Create `config/environments/development.yml`: + +```yaml +name: "my_app" + +server: + host: "localhost" + port: 3000 + secret_key_base: "a_random_string_at_least_32_characters_long" + +logging: + severity: "debug" + colorize: true +``` + +See the [Configuration Guide](guides/configuration.md) for all available settings. + +## Adding More Routes + +Add additional routes and controllers as your application grows: + +```crystal +Amber::Server.configure do + pipeline :web do + plug Amber::Pipe::Error.new + plug Amber::Pipe::Logger.new + plug Amber::Pipe::Session.new + plug Amber::Pipe::Flash.new + plug Amber::Pipe::CSRF.new + end + + routes :web do + get "/", HomeController, :index + resources "users", UsersController + resources "posts", PostsController + end +end +``` + +The `resources` macro generates all seven RESTful routes (index, new, create, show, edit, update, destroy) for a given controller. + +## Next Steps + +- [Routing](guides/routing.md) -- Route definitions, resources, namespaces, constraints, and API versioning +- [Configuration](guides/configuration.md) -- Environment YAML, environment variables, and custom config sections +- [Action Helpers](guides/action-helpers.md) -- Form helpers, URL helpers, asset tags, and text formatting +- [Schema API](guides/schema-api.md) -- Type-safe, validated parameter handling +- [WebSockets](guides/websockets.md) -- Real-time communication with channels and presence tracking +- [Background Jobs](guides/background-jobs.md) -- Asynchronous job processing +- [Mailer](guides/mailer.md) -- Email delivery with SMTP and memory adapters +- [Testing](guides/testing.md) -- Request helpers, assertions, and controller testing +- [Markdown](guides/markdown.md) -- Markdown rendering with GFM support +- [Migration Guide](migration-guide.md) -- Migrating from Amber V1 to V2 diff --git a/docs/guides/action-helpers.md b/docs/guides/action-helpers.md new file mode 100644 index 000000000..c75331360 --- /dev/null +++ b/docs/guides/action-helpers.md @@ -0,0 +1,437 @@ +# Action Helpers + +Amber V2 includes a comprehensive set of view helpers that are available in all controllers and ECR templates. These helpers generate HTML elements with proper escaping, CSRF protection, and semantic markup. + +All helpers are included automatically via `Amber::Controller::Base`. + +## FormHelpers + +Generate HTML form elements with automatic CSRF token inclusion and method override support. + +### form_for + +Generates a `
` tag with automatic CSRF token inclusion. For non-GET/POST methods (PUT, PATCH, DELETE), a hidden `_method` field is emitted. + +```crystal +form_for("/users", method: "POST") { "" } +# => +# +# +#
+ +form_for("/users/1", method: "PATCH") { "" } +# =>
+# +# +# +#
+``` + +In an ECR template: + +```ecr +<%= form_for("/users", method: "POST") { %> +
+ <%= label("name") %> + <%= text_field("name", class: "form-control") %> +
+
+ <%= label("email") %> + <%= email_field("email", class: "form-control") %> +
+ <%= submit_button("Create User", class: "btn btn-primary") %> +<% } %> +``` + +### Input Fields + +```crystal +text_field("name") +# => + +text_field("name", value: "John", class: "form-control") +# => + +email_field("email") +# => + +password_field("password") +# => + +number_field("quantity", value: 1) +# => + +hidden_field("user_id", "42") +# => +``` + +### text_area + +```crystal +text_area("bio") +# => + +text_area("bio", value: "Hello world", rows: "5") +# => +``` + +### select_field + +Accepts an array of strings or an array of `{label, value}` tuples: + +```crystal +select_field("color", ["Red", "Blue", "Green"]) +# => + +select_field("color", [{"Red", "red"}, {"Blue", "blue"}], selected: "blue") +# => +``` + +### checkbox and radio_button + +```crystal +checkbox("remember_me") +# => + +checkbox("terms", checked: true) +# => + +radio_button("color", "red") +# => + +radio_button("color", "blue", checked: true) +# => +``` + +### label + +```crystal +label("email") +# => + +label("email", text: "Your Email Address") +# => +``` + +### submit_button + +```crystal +submit_button +# => + +submit_button("Save Changes", class: "btn btn-primary") +# => +``` + +## URLHelpers + +Generate anchor tags and navigation elements. + +### link_to + +```crystal +link_to("Home", "/") +# => Home + +link_to("Profile", "/users/1", class: "nav-link") +# => Profile +``` + +### button_to + +Generates a form containing a single submit button. Useful for delete links and other actions that should not be plain anchor tags: + +```crystal +button_to("Delete", "/users/1", method: "DELETE") +# =>
+# +# +# +#
+``` + +### mail_to + +```crystal +mail_to("support@example.com") +# => support@example.com + +mail_to("support@example.com", text: "Contact Us") +# => Contact Us +``` + +### link_back + +Generates a link to the previous page using the `Referer` header, falling back to `"#"` if no referer is present: + +```crystal +link_back +# => Back + +link_back(text: "Go Back", class: "btn") +# => Go Back +``` + +## AssetHelpers + +Generate HTML tags for images, stylesheets, and JavaScript files. + +### image_tag + +```crystal +image_tag("/images/logo.png") +# => + +image_tag("/images/logo.png", alt: "Logo", width: "200") +# => Logo +``` + +### stylesheet_link_tag + +```crystal +stylesheet_link_tag("/css/app.css") +# => + +stylesheet_link_tag("/css/print.css", media: "print") +# => +``` + +### javascript_include_tag + +```crystal +javascript_include_tag("/js/app.js") +# => + +javascript_include_tag("/js/app.js", defer: true) +# => +``` + +### favicon_tag + +```crystal +favicon_tag +# => + +favicon_tag("/images/icon.png") +# => +``` + +## TagHelpers + +Low-level HTML tag generation helpers. + +### tag + +Generates a self-closing HTML tag: + +```crystal +tag("br") +# =>
+ +tag("img", src: "/logo.png", alt: "Logo") +# => Logo + +tag("input", type: "text", name: "email", required: true) +# => +``` + +### content_tag + +Generates an HTML tag with content (string or block): + +```crystal +content_tag("p", "Hello") +# =>

Hello

+ +content_tag("div", "Note", class: "alert") +# =>
Note
+ +content_tag("ul", class: "nav") { "
  • Item 1
  • Item 2
  • " } +# => +``` + +Boolean attributes: `true` produces a valueless attribute (e.g., `disabled`), `false` and `nil` values are omitted. + +## TextHelpers + +Text manipulation helpers for views. + +### truncate + +```crystal +truncate("Hello World", length: 8) +# => "Hello..." + +truncate("Hello World", length: 8, omission: ">>") +# => "Hello >>" + +truncate("Hi", length: 10) +# => "Hi" +``` + +### pluralize + +```crystal +pluralize(1, "person") +# => "1 person" + +pluralize(2, "person") +# => "2 persons" + +pluralize(2, "person", "people") +# => "2 people" + +pluralize(0, "item") +# => "0 items" +``` + +### highlight + +Wraps occurrences of a phrase in an HTML tag (case-insensitive): + +```crystal +highlight("Hello World", "World") +# => "Hello World" + +highlight("You found it", "found", tag: "em") +# => "You found it" +``` + +### simple_format + +Converts newlines to `
    ` tags and wraps paragraphs in `

    ` tags: + +```crystal +simple_format("Hello\nWorld") +# => "

    Hello
    World

    " + +simple_format("Para1\n\nPara2") +# => "

    Para1

    Para2

    " +``` + +### word_wrap + +```crystal +word_wrap("A very long sentence that needs wrapping", line_width: 20) +``` + +### strip_tags + +Removes all HTML tags from a string: + +```crystal +strip_tags("

    Hello World

    ") +# => "Hello World" +``` + +### escape_html + +```crystal +escape_html("") +# => "<script>alert('xss')</script>" +``` + +## NumberHelpers + +Number formatting helpers for views. + +### number_with_delimiter + +```crystal +number_with_delimiter(1234567) +# => "1,234,567" + +number_with_delimiter(1234.5) +# => "1,234.5" + +number_with_delimiter(1234567, delimiter: ".") +# => "1.234.567" +``` + +### number_to_currency + +```crystal +number_to_currency(1234.5) +# => "$1,234.50" + +number_to_currency(1234.5, unit: "EUR") +# => "EUR1,234.50" + +number_to_currency(1234.567, precision: 3) +# => "$1,234.567" +``` + +### number_to_percentage + +```crystal +number_to_percentage(75.5) +# => "75.5%" + +number_to_percentage(75.567, precision: 2) +# => "75.57%" +``` + +### number_to_human_size + +```crystal +number_to_human_size(500) +# => "500 Bytes" + +number_to_human_size(1024) +# => "1.00 KB" + +number_to_human_size(1048576) +# => "1.00 MB" + +number_to_human_size(1073741824) +# => "1.00 GB" +``` + +## Using Helpers in ECR Templates + +All helpers are available directly in ECR templates because they are included in the controller: + +```ecr + + + <%= stylesheet_link_tag("/css/app.css") %> + <%= javascript_include_tag("/js/app.js", defer: true) %> + <%= favicon_tag %> + + + + +

    Users (<%= pluralize(@users.size, "user") %>)

    + + <%= form_for("/users", method: "POST") { %> + <%= label("name") %> + <%= text_field("name", class: "form-control") %> + <%= label("email") %> + <%= email_field("email", class: "form-control") %> + <%= submit_button("Create", class: "btn btn-primary") %> + <% } %> + +

    Total sales: <%= number_to_currency(@total_sales) %>

    +

    Disk usage: <%= number_to_human_size(@disk_bytes) %>

    + + +``` + +## Source Files + +- `src/amber/controller/helpers/form_helpers.cr` -- FormHelpers module +- `src/amber/controller/helpers/url_helpers.cr` -- URLHelpers module +- `src/amber/controller/helpers/asset_helpers.cr` -- AssetHelpers module +- `src/amber/controller/helpers/tag_helpers.cr` -- TagHelpers module +- `src/amber/controller/helpers/text_helpers.cr` -- TextHelpers module +- `src/amber/controller/helpers/number_helpers.cr` -- NumberHelpers module diff --git a/docs/guides/background-jobs.md b/docs/guides/background-jobs.md new file mode 100644 index 000000000..a684762df --- /dev/null +++ b/docs/guides/background-jobs.md @@ -0,0 +1,377 @@ +# Background Jobs + +Amber::Jobs provides in-process background job processing for tasks that should not block HTTP request handling. Jobs are defined as classes with a `perform` method, serialized to JSON for queue storage, and executed by configurable worker fibers. Use background jobs for sending emails, processing uploads, calling external APIs, or any work that takes more than a few hundred milliseconds. + +## Quick Start + +```crystal +# Define a job +class SendWelcomeEmail < Amber::Jobs::Job + include JSON::Serializable + + property user_id : Int64 + + def initialize(@user_id : Int64) + end + + def perform + user = User.find(user_id) + WelcomeMailer.new(user.name, user.email) + .to(user.email) + .subject("Welcome!") + .deliver + end +end + +# Register the job class (required for deserialization) +Amber::Jobs.register(SendWelcomeEmail) + +# Enqueue a job +SendWelcomeEmail.new(user_id: 42_i64).enqueue +``` + +## Defining Jobs + +Every job must inherit from `Amber::Jobs::Job`, include `JSON::Serializable`, and implement the abstract `perform` method. Any instance variables decorated with `property` will be serialized when the job is enqueued and deserialized when executed. + +```crystal +class ProcessUpload < Amber::Jobs::Job + include JSON::Serializable + + property file_path : String + property user_id : Int64 + + def initialize(@file_path : String, @user_id : Int64) + end + + def perform + # Your processing logic here + end +end +``` + +### Job Registration + +Every job class must be registered with the Amber::Jobs system so the worker can reconstruct the job instance from its serialized JSON payload: + +```crystal +Amber::Jobs.register(ProcessUpload) +Amber::Jobs.register(SendWelcomeEmail) +``` + +Registration is typically done at application startup, before any jobs are enqueued or workers are started. + +### Customizing Queue and Retry Behavior + +Override class methods to customize per-job behavior: + +```crystal +class CriticalNotification < Amber::Jobs::Job + include JSON::Serializable + + property message : String + + def initialize(@message : String) + end + + def perform + # Send critical notification + end + + # Override the default queue name (default: "default") + def self.queue : String + "critical" + end + + # Override the maximum retry attempts (default: 3) + def self.max_retries : Int32 + 5 + end + + # Override the retry backoff strategy (default: exponential 2^attempt seconds) + def self.retry_backoff(attempt : Int32) : Time::Span + (attempt * 10).seconds # Linear backoff: 10s, 20s, 30s, ... + end +end +``` + +## Enqueueing Jobs + +The `enqueue` method places the job in the queue for background processing. It returns a `JobEnvelope` containing the job's metadata. + +```crystal +# Enqueue to the default queue +SendWelcomeEmail.new(user_id: 42_i64).enqueue + +# Enqueue with a delay +SendWelcomeEmail.new(user_id: 42_i64).enqueue(delay: 5.minutes) + +# Enqueue to a specific queue (overrides the class default) +SendWelcomeEmail.new(user_id: 42_i64).enqueue(queue: "low_priority") + +# Capture the envelope for tracking +envelope = SendWelcomeEmail.new(user_id: 42_i64).enqueue +puts envelope.id # UUID string +puts envelope.status # JobEnvelope::Status::Pending +``` + +### JobEnvelope + +The `JobEnvelope` struct wraps a job with metadata for queue management: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `String` | Unique UUID for this job instance | +| `job_class` | `String` | Fully qualified Crystal class name | +| `payload` | `String` | JSON-serialized job data | +| `queue` | `String` | Queue name (e.g., "default", "critical") | +| `attempts` | `Int32` | Number of execution attempts so far | +| `max_retries` | `Int32` | Maximum retry attempts before marking dead | +| `scheduled_at` | `Time` | When the job should execute | +| `created_at` | `Time` | When the job was originally enqueued | +| `status` | `Status` | Current lifecycle status (Pending, Running, Completed, Failed, Dead) | +| `last_error` | `String?` | Error message from the most recent failure | + +## Configuration + +Configuration is set through the `Amber::Jobs` module: + +```crystal +Amber::Jobs.configure do |config| + config.adapter = :memory + config.queues = ["default", "critical", "low"] + config.workers = 2 + config.polling_interval = 1.second + config.scheduler_interval = 5.seconds + config.work_stealing_enabled = true + config.is_auto_start_enabled = true +end +``` + +### Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `adapter` | `:memory` | Queue storage backend | +| `list_of_queues` / `queues` | `["default"]` | Queue names in priority order | +| `number_of_workers` / `workers` | `1` | Number of worker fibers | +| `polling_interval` | `1.second` | How often workers check for jobs | +| `scheduler_interval` | `5.seconds` | How often the scheduler promotes delayed jobs | +| `is_work_stealing_enabled` / `work_stealing_enabled` | `false` | Enable idle-server job processing | +| `is_auto_start_enabled` | `false` | Auto-start workers when Amber server starts | + +### YAML Configuration + +Jobs can also be configured via your environment YAML files: + +```yaml +jobs: + adapter: "memory" + workers: 2 + auto_start: true + polling_interval_seconds: 1.0 + scheduler_interval_seconds: 5.0 + work_stealing: false +``` + +All jobs configuration values can be overridden with environment variables: + +```bash +AMBER_JOBS_ADAPTER=memory +AMBER_JOBS_WORKERS=4 +AMBER_JOBS_AUTO_START=true +AMBER_JOBS_POLLING_INTERVAL_SECONDS=0.5 +AMBER_JOBS_WORK_STEALING=true +``` + +## Workers + +Workers run in background fibers, polling configured queues for available jobs. They check queues in priority order, processing the first available job found. + +```crystal +# Workers are created and started automatically by Amber::Jobs.start +# Manual worker creation (for advanced use): +worker = Amber::Jobs::Worker.new( + adapter: Amber::Jobs.adapter, + list_of_queues: ["critical", "default", "low"], + polling_interval: 1.second +) +worker.start +``` + +### Work Stealing + +When work stealing is enabled, the system spawns additional workers that only process jobs when no HTTP requests are pending. This allows idle web server instances to contribute to job processing without impacting request latency. + +```crystal +Amber::Jobs.configure do |config| + config.work_stealing_enabled = true +end +``` + +The work-stealing worker monitors `Worker.pending_request_count` (a class-level atomic counter) and only dequeues jobs when this count is zero. + +## Scheduler + +The Scheduler runs in a background fiber and periodically checks for delayed or scheduled jobs that are ready to execute. In the `MemoryQueueAdapter`, scheduled job promotion also happens during dequeue, so the scheduler serves as an additional safety check. + +```crystal +scheduler = Amber::Jobs::Scheduler.new( + adapter: Amber::Jobs.adapter, + interval: 5.seconds +) +scheduler.start +``` + +The scheduler is started automatically when you call `Amber::Jobs.start`. + +## Queue Adapters + +### MemoryQueueAdapter (Default) + +The built-in `MemoryQueueAdapter` stores jobs in memory using Mutex-protected data structures. It supports scheduled jobs, completion tracking, failure tracking, and dead job management. + +Suitable for: development, testing, and single-instance applications. + +Limitations: job data is lost when the application restarts. + +### Writing a Custom Adapter + +Inherit from `Amber::Jobs::QueueAdapter` and implement all abstract methods: + +```crystal +class RedisQueueAdapter < Amber::Jobs::QueueAdapter + def initialize(@redis : Redis::Client) + end + + def enqueue(envelope : JobEnvelope) : Nil + @redis.lpush("queue:#{envelope.queue}", envelope.to_json) + end + + def dequeue(queue : String) : JobEnvelope? + if data = @redis.rpop("queue:#{queue}") + JobEnvelope.from_json(data) + end + end + + def schedule(envelope : JobEnvelope, at : Time) : Nil + @redis.zadd("scheduled", at.to_unix.to_f, envelope.to_json) + end + + def size(queue : String) : Int32 + @redis.llen("queue:#{queue}").to_i32 + end + + def clear(queue : String) : Nil + @redis.del("queue:#{queue}") + end + + def mark_completed(id : String) : Nil + @redis.hset("completed", id, Time.utc.to_rfc3339) + end + + def mark_failed(id : String, error : String) : Nil + @redis.hset("failed", id, error) + end + + def retry_failed(id : String) : Nil + if data = @redis.hdel("failed", id) + # Re-enqueue logic + end + end + + def dead_jobs : Array(JobEnvelope) + [] of JobEnvelope + end + + def all_jobs : Array(JobEnvelope) + [] of JobEnvelope + end +end + +# Set the custom adapter +Amber::Jobs.adapter = RedisQueueAdapter.new(Redis::Client.new) +``` + +### QueueAdapter Abstract Methods + +| Method | Description | +|--------|-------------| +| `enqueue(envelope)` | Add a job to the queue | +| `dequeue(queue)` | Remove and return the next ready job, or nil | +| `schedule(envelope, at)` | Schedule a job for future execution | +| `size(queue)` | Count of pending jobs in a queue | +| `clear(queue)` | Remove all jobs from a queue | +| `mark_completed(id)` | Mark a job as completed | +| `mark_failed(id, error)` | Mark a job as failed | +| `retry_failed(id)` | Re-enqueue a failed job | +| `dead_jobs` | Return all dead jobs | +| `all_jobs` | Return all tracked jobs | +| `close` | Optional cleanup on shutdown | +| `healthy?` | Optional health check (default: true) | + +## Starting and Stopping + +```crystal +# Start the jobs system (workers + scheduler) +Amber::Jobs.start + +# Stop all workers and the scheduler +Amber::Jobs.stop + +# Reset state (useful in tests) +Amber::Jobs.reset +``` + +When `is_auto_start_enabled` is true in configuration, `Amber::Jobs.start` is called automatically when the Amber server starts. + +## Testing Jobs + +Test jobs by calling `perform` directly or by using the `MemoryQueueAdapter` to verify enqueueing behavior: + +```crystal +describe SendWelcomeEmail do + before_each do + Amber::Jobs.reset + Amber::Jobs.register(SendWelcomeEmail) + end + + it "performs the job" do + job = SendWelcomeEmail.new(user_id: 1_i64) + # Call perform directly to test the job logic + job.perform + end + + it "enqueues to the correct queue" do + envelope = SendWelcomeEmail.new(user_id: 1_i64).enqueue + envelope.queue.should eq("default") + end + + it "enqueues with delay" do + envelope = SendWelcomeEmail.new(user_id: 1_i64).enqueue(delay: 5.minutes) + envelope.scheduled_at.should be > Time.utc + end + + it "processes through the worker" do + SendWelcomeEmail.new(user_id: 1_i64).enqueue + + worker = Amber::Jobs::Worker.new( + adapter: Amber::Jobs.adapter, + list_of_queues: ["default"] + ) + worker.process_next_job.should be_true + worker.jobs_processed.should eq(1) + end +end +``` + +## Source Files + +- `src/amber/jobs.cr` -- Module entry point, registry, start/stop +- `src/amber/jobs/job.cr` -- Abstract Job base class +- `src/amber/jobs/job_envelope.cr` -- JobEnvelope struct with lifecycle management +- `src/amber/jobs/worker.cr` -- Worker fiber with polling and retry logic +- `src/amber/jobs/scheduler.cr` -- Scheduler fiber for delayed job promotion +- `src/amber/jobs/configuration.cr` -- Configuration class +- `src/amber/jobs/queue_adapter.cr` -- Abstract QueueAdapter base class +- `src/amber/jobs/memory_queue_adapter.cr` -- In-memory adapter implementation diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 000000000..daf8e3375 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,320 @@ +# Configuration + +Amber V2 uses a typed configuration system organized into sections. Configuration is loaded from YAML files, with every value overridable via environment variables. Custom application configuration can be registered alongside the built-in sections. + +## Quick Start + +Create `config/environments/development.yml`: + +```yaml +name: "my_app" + +server: + host: "localhost" + port: 3000 + secret_key_base: "a_long_random_string_at_least_32_characters" + +session: + key: "my_app.session" + store: "signed_cookie" + adapter: "memory" + +logging: + severity: "debug" + colorize: true +``` + +## Configuration Sections + +### AppConfig (Top Level) + +The `Amber::Configuration::AppConfig` class holds all configuration sections: + +| Section | Class | Description | +|---------|-------|-------------| +| `name` | `String` | Application name (default: "Amber_App") | +| `server` | `ServerConfig` | HTTP server settings | +| `database` | `DatabaseConfig` | Database connection | +| `session` | `SessionConfig` | Session management | +| `pubsub` | `PubSubConfig` | WebSocket pub/sub | +| `logging` | `LoggingConfig` | Log output settings | +| `jobs` | `JobsConfig` | Background jobs | +| `mailer` | `MailerConfig` | Email delivery | +| `static` | `StaticConfig` | Static file serving | +| `secrets` | `Hash(String, String)` | Key-value secret storage | + +### ServerConfig + +```yaml +server: + host: "0.0.0.0" # Bind address (default: "localhost") + port: 3000 # Listen port (default: 3000) + port_reuse: true # Enable SO_REUSEPORT (default: true) + process_count: 1 # Number of server processes (default: 1) + secret_key_base: "..." # Secret for session signing (required in production) + ssl: + key_file: "config/ssl/key.pem" # SSL private key path + cert_file: "config/ssl/cert.pem" # SSL certificate path +``` + +Validation rules: +- `port` must be between 1 and 65535 +- `process_count` must be at least 1 +- `secret_key_base` must be set in production +- `secret_key_base` should be at least 32 characters when set +- SSL files must exist when SSL is enabled + +### SessionConfig + +```yaml +session: + key: "amber.session" # Cookie name (default: "amber.session") + store: "signed_cookie" # Store type (default: "signed_cookie") + expires: 3600 # Session TTL in seconds (default: 0 = session cookie) + adapter: "memory" # Backend adapter (default: "memory") +``` + +Valid `store` values: `"signed_cookie"`, `"encrypted_cookie"`, `"redis"` + +### DatabaseConfig + +```yaml +database: + url: "postgres://localhost/my_app_development" +``` + +### LoggingConfig + +```yaml +logging: + severity: "debug" # Log level (default: "debug") + colorize: true # Color output (default: true) + color: "light_cyan" # Log color +``` + +### JobsConfig + +```yaml +jobs: + adapter: "memory" # Queue backend (default: "memory") + workers: 2 # Number of worker fibers (default: 1) + work_stealing: false # Enable idle-server processing (default: false) + polling_interval_seconds: 1.0 # Worker poll interval (default: 1.0) + scheduler_interval_seconds: 5.0 # Scheduler interval (default: 5.0) + auto_start: false # Auto-start on server boot (default: false) +``` + +### MailerConfig + +```yaml +mailer: + adapter: "memory" # Delivery backend (default: "memory") + default_from: "noreply@example.com" + smtp: + host: "smtp.example.com" + port: 587 + username: "user@example.com" + password: "secret" + use_tls: true + helo_domain: "example.com" +``` + +### PubSubConfig + +```yaml +pubsub: + adapter: "memory" # PubSub backend (default: "memory") +``` + +### StaticConfig + +```yaml +static: + # Static file serving configuration +``` + +## Environment Variable Overrides + +Every configuration property can be overridden with an environment variable. Environment variables always take highest priority, overriding both YAML file values and compiled-in defaults. + +The naming convention is `AMBER_{SECTION}_{KEY}`: + +| Environment Variable | Config Property | +|---------------------|----------------| +| `AMBER_NAME` | `name` | +| `AMBER_SERVER_HOST` | `server.host` | +| `AMBER_SERVER_PORT` | `server.port` | +| `AMBER_SERVER_PORT_REUSE` | `server.port_reuse` | +| `AMBER_SERVER_PROCESS_COUNT` | `server.process_count` | +| `AMBER_SERVER_SECRET_KEY_BASE` | `server.secret_key_base` | +| `AMBER_SERVER_SSL_KEY_FILE` | `server.ssl.key_file` | +| `AMBER_SERVER_SSL_CERT_FILE` | `server.ssl.cert_file` | +| `AMBER_DATABASE_URL` | `database.url` | +| `AMBER_SESSION_KEY` | `session.key` | +| `AMBER_SESSION_STORE` | `session.store` | +| `AMBER_SESSION_EXPIRES` | `session.expires` | +| `AMBER_SESSION_ADAPTER` | `session.adapter` | +| `AMBER_PUBSUB_ADAPTER` | `pubsub.adapter` | +| `AMBER_LOGGING_SEVERITY` | `logging.severity` | +| `AMBER_LOGGING_COLORIZE` | `logging.colorize` | +| `AMBER_LOGGING_COLOR` | `logging.color` | +| `AMBER_JOBS_ADAPTER` | `jobs.adapter` | +| `AMBER_JOBS_WORKERS` | `jobs.workers` | +| `AMBER_JOBS_WORK_STEALING` | `jobs.work_stealing` | +| `AMBER_JOBS_POLLING_INTERVAL_SECONDS` | `jobs.polling_interval_seconds` | +| `AMBER_JOBS_SCHEDULER_INTERVAL_SECONDS` | `jobs.scheduler_interval_seconds` | +| `AMBER_JOBS_AUTO_START` | `jobs.auto_start` | +| `AMBER_MAILER_ADAPTER` | `mailer.adapter` | +| `AMBER_MAILER_DEFAULT_FROM` | `mailer.default_from` | +| `AMBER_MAILER_SMTP_HOST` | `mailer.smtp.host` | +| `AMBER_MAILER_SMTP_PORT` | `mailer.smtp.port` | +| `AMBER_MAILER_SMTP_USERNAME` | `mailer.smtp.username` | +| `AMBER_MAILER_SMTP_PASSWORD` | `mailer.smtp.password` | +| `AMBER_MAILER_SMTP_USE_TLS` | `mailer.smtp.use_tls` | +| `AMBER_MAILER_SMTP_HELO_DOMAIN` | `mailer.smtp.helo_domain` | + +Boolean environment variables accept `"true"`, `"1"`, `"yes"` (case-insensitive) as true. All other values are treated as false. + +## Custom Configuration Sections + +Register custom configuration types to add application-specific settings that load from YAML and support environment variable overrides. + +### Defining a Custom Config + +```crystal +struct StripeConfig + include YAML::Serializable + + property api_key : String = "" + property webhook_secret : String = "" + property currency : String = "usd" + + def initialize + end +end + +# Register with Amber's configuration system +Amber::Configuration.register(:stripe, StripeConfig) +``` + +### YAML Configuration + +Add your custom section to the environment YAML file: + +```yaml +name: "my_app" + +server: + port: 3000 + +stripe: + api_key: "sk_test_..." + webhook_secret: "whsec_..." + currency: "usd" +``` + +### Accessing Custom Config + +```crystal +stripe = Amber.settings.custom(:stripe, StripeConfig) +stripe.api_key # => "sk_test_..." +stripe.webhook_secret # => "whsec_..." +stripe.currency # => "usd" +``` + +## Configuration Validation + +All configuration sections include built-in validation. Call `validate!` to check all settings at startup: + +```crystal +config = Amber::Configuration::AppConfig.from_yaml(File.read("config/environments/production.yml")) +config.validate!(Amber::Environment::Env.new("production")) +# Raises Amber::Exceptions::ConfigurationError if any validation fails +``` + +Validation checks include: +- Port range (1-65535) +- Process count (>= 1) +- Secret key base required in production +- Secret key base minimum length (32 characters) +- SSL file existence when SSL is enabled +- Valid session store type +- Valid logging severity level + +## Configuration Priority + +Configuration values are resolved in this order (highest priority first): + +1. **Environment variables** (`AMBER_SERVER_PORT=8080`) +2. **Environment YAML file** (`config/environments/production.yml`) +3. **Compiled defaults** (hardcoded in the config classes) + +## Full Example + +A complete `config/environments/production.yml`: + +```yaml +name: "my_app" + +server: + host: "0.0.0.0" + port: 3000 + port_reuse: true + process_count: 4 + secret_key_base: "use_a_strong_random_string_here_at_least_32_chars" + ssl: + key_file: "/etc/ssl/private/app.key" + cert_file: "/etc/ssl/certs/app.crt" + +database: + url: "postgres://user:pass@db-host/my_app_production" + +session: + key: "my_app.session" + store: "encrypted_cookie" + adapter: "memory" + expires: 86400 + +pubsub: + adapter: "memory" + +logging: + severity: "info" + colorize: false + +jobs: + adapter: "memory" + workers: 4 + auto_start: true + polling_interval_seconds: 0.5 + work_stealing: true + +mailer: + adapter: "smtp" + default_from: "noreply@myapp.com" + smtp: + host: "smtp.sendgrid.net" + port: 587 + username: "apikey" + password: "SG.xxxxx" + use_tls: true + helo_domain: "myapp.com" + +secrets: + api_key: "secret_api_key_here" +``` + +## Source Files + +- `src/amber/configuration.cr` -- Requires all configuration files +- `src/amber/configuration/app_config.cr` -- Top-level AppConfig with validation +- `src/amber/configuration/server_config.cr` -- ServerConfig and SSLConfig +- `src/amber/configuration/database_config.cr` -- DatabaseConfig +- `src/amber/configuration/session_config.cr` -- SessionConfig +- `src/amber/configuration/pubsub_config.cr` -- PubSubConfig +- `src/amber/configuration/logging_config.cr` -- LoggingConfig +- `src/amber/configuration/jobs_config.cr` -- JobsConfig +- `src/amber/configuration/mailer_config.cr` -- MailerConfig +- `src/amber/configuration/static_config.cr` -- StaticConfig +- `src/amber/configuration/env_override.cr` -- Environment variable override logic +- `src/amber/configuration/custom_registry.cr` -- Custom config registration diff --git a/docs/guides/lsp-setup.md b/docs/guides/lsp-setup.md new file mode 100644 index 000000000..079932a27 --- /dev/null +++ b/docs/guides/lsp-setup.md @@ -0,0 +1,230 @@ +# Amber LSP — Claude Code Integration Guide + +The Amber LSP is a diagnostics-only language server that checks your Crystal code against Amber framework conventions. It works with Claude Code to catch mistakes automatically as files are edited. + +## Quick Start + +### 1. Build the LSP binary + +```bash +cd path/to/amber_cli +crystal build src/amber_lsp.cr -o bin/amber-lsp --release +``` + +The `amber_cli` repository is at https://github.com/amberframework/amber_cli. + +### 2. Set up your project + +From your Amber application directory, run: + +```bash +amber setup:lsp +``` + +This creates three files: + +| File | Purpose | +|------|---------| +| `.lsp.json` | Tells Claude Code where the LSP binary is and what files it handles | +| `.claude-plugin/plugin.json` | Plugin manifest so Claude Code discovers the LSP | +| `.amber-lsp.yml` | Rule configuration — customize severity, disable rules, add custom rules | + +### 3. Start Claude Code + +That's it. Open Claude Code in your project directory and the LSP activates automatically. + +--- + +## Manual Setup (Without CLI) + +If you prefer to set things up by hand or the CLI command isn't available: + +### `.lsp.json` (project root) + +```json +{ + "amber": { + "command": "/full/path/to/bin/amber-lsp", + "args": [], + "extensionToLanguage": { + ".cr": "crystal" + }, + "transport": "stdio", + "restartOnCrash": true, + "maxRestarts": 3 + } +} +``` + +Replace `/full/path/to/bin/amber-lsp` with the actual absolute path to your compiled binary. + +### `.claude-plugin/plugin.json` + +```json +{ + "name": "amber-framework-lsp", + "version": "1.0.0", + "description": "Convention diagnostics for Amber V2 web framework projects.", + "author": { "name": "Amber Framework" }, + "lspServers": "./.lsp.json" +} +``` + +--- + +## What the LSP Checks + +The LSP ships with 15 built-in convention rules: + +| Rule | Severity | What It Checks | +|------|----------|----------------| +| `amber/controller-naming` | Error | Controller classes must end with `Controller` | +| `amber/controller-inheritance` | Error | Must inherit from `ApplicationController` or `Amber::Controller::Base` | +| `amber/action-return-type` | Warning | Actions should call `render`, `redirect_to`, or `respond_with` | +| `amber/filter-syntax` | Error | Detects Rails-style `before_action :symbol` and deprecated `before_filter` | +| `amber/job-perform` | Error | Job classes must define a `perform` method | +| `amber/job-serializable` | Warning | Jobs should include `JSON::Serializable` | +| `amber/channel-handle-message` | Error | Channels must define `handle_message` | +| `amber/pipe-call-next` | Error | Pipes overriding `call` must invoke `call_next` | +| `amber/spec-existence` | Info | Controllers should have a corresponding spec file | +| `amber/route-controller-exists` | Warning | Controllers referenced in routes should exist as files | +| `amber/schema-field-type` | Error | Schema field types must be valid Crystal types | +| `amber/mailer-methods` | Error | Mailers must define `html_body` and `text_body` | +| `amber/file-naming` | Warning | Crystal files must be snake_case | +| `amber/directory-structure` | Warning | Classes should be in their expected directories | +| `amber/socket-channel-macro` | Warning | ClientSockets should define at least one `channel` | + +--- + +## Configuration + +### `.amber-lsp.yml` + +Place this file in your project root to customize behavior. + +#### Disable a rule + +```yaml +rules: + amber/spec-existence: + enabled: false +``` + +#### Change severity + +```yaml +rules: + amber/spec-existence: + severity: hint + amber/file-naming: + severity: error +``` + +Valid severities: `error`, `warning`, `information`, `hint` + +#### Exclude directories + +```yaml +exclude: + - lib/ + - tmp/ + - db/migrations/ + - vendor/ +``` + +--- + +## Custom Rules + +You can define project-specific rules using regex patterns in `.amber-lsp.yml`. No recompilation needed. + +### Basic pattern rule + +Flag `puts` statements in production code: + +```yaml +custom_rules: + - id: "project/no-puts" + description: "Do not use puts in production code" + severity: warning + applies_to: ["src/**"] + pattern: "^\\s*puts\\b" + message: "Avoid 'puts' in production code. Use Log.info instead." +``` + +### Negated rule (require something exists) + +Require a copyright header in every file: + +```yaml +custom_rules: + - id: "project/require-copyright" + description: "Every source file must have a copyright header" + severity: info + applies_to: ["src/**"] + pattern: "^# Copyright" + negate: true + message: "Missing copyright header." +``` + +When `negate: true`, the rule reports a diagnostic when the pattern is NOT found in the file. + +### Pattern with capture groups + +Use `{0}`, `{1}`, etc. in the message to reference regex capture groups: + +```yaml +custom_rules: + - id: "project/no-hardcoded-urls" + description: "Flag hardcoded URLs" + severity: warning + applies_to: ["src/**"] + pattern: "https?://[^\\s\"']+" + message: "Hardcoded URL found: {0}. Consider using a configuration variable." +``` + +### Custom rule fields + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique rule identifier (e.g., `"project/no-puts"`) | +| `pattern` | Yes | Regex pattern to match against each line | +| `description` | No | Human-readable description | +| `severity` | No | `error`, `warning` (default), `info`, or `hint` | +| `applies_to` | No | Array of file patterns (default: `["**/*.cr"]`) | +| `message` | No | Diagnostic message (supports `{0}`, `{1}` substitution) | +| `negate` | No | `true` to report when pattern is NOT found (default: `false`) | + +Custom rules can be disabled or have their severity overridden using the `rules:` section, just like built-in rules: + +```yaml +rules: + project/no-puts: + severity: error + project/require-copyright: + enabled: false +``` + +--- + +## How It Works + +1. Claude Code reads `.lsp.json` and spawns the `amber-lsp` binary +2. They communicate via JSON-RPC over stdio (LSP protocol) +3. Every time a `.cr` file is saved, the LSP analyzes it against all enabled rules +4. Diagnostics (errors, warnings) are sent back to Claude Code +5. Claude sees the diagnostics and can self-correct + +The LSP only activates for Amber projects — it checks for `shard.yml` with an `amber` dependency. + +--- + +## Troubleshooting + +**LSP not activating**: Verify `amber-lsp` is at the path specified in `.lsp.json`. Run it manually: `echo '' | /path/to/amber-lsp` — it should wait for input without errors. + +**No diagnostics appearing**: Check that your project has a `shard.yml` with an `amber` dependency. The LSP skips non-Amber projects. + +**Custom rule not working**: Verify the regex is valid. Test it: `crystal eval 'puts /your_pattern/.matches?("test line")'`. Remember to double-escape backslashes in YAML (`\\s` not `\s`). + +**Binary not found**: Rebuild from the `amber_cli` repository: `crystal build src/amber_lsp.cr -o bin/amber-lsp --release` diff --git a/docs/guides/mailer.md b/docs/guides/mailer.md new file mode 100644 index 000000000..e7f991a86 --- /dev/null +++ b/docs/guides/mailer.md @@ -0,0 +1,362 @@ +# Mailer + +Amber::Mailer provides a complete email delivery system with an adapter pattern for pluggable delivery backends. It ships with a memory adapter for testing and an SMTP adapter for production use. Mailers use a fluent API for composing messages, support file attachments, and generate RFC 2045-compliant MIME messages. + +## Quick Start + +```crystal +class WelcomeMailer < Amber::Mailer::Base + def initialize(@user_name : String) + end + + def html_body : String? + "

    Welcome, #{HTML.escape(@user_name)}!

    " + end + + def text_body : String? + "Welcome, #{@user_name}!" + end +end + +# Send an email +WelcomeMailer.new("Alice") + .to("alice@example.com") + .from("hello@myapp.com") + .subject("Welcome to MyApp!") + .deliver +``` + +## Defining Mailers + +Every mailer must inherit from `Amber::Mailer::Base` and implement the abstract `html_body` and `text_body` methods. Return `nil` from either method to send only the other format. + +```crystal +class OrderConfirmation < Amber::Mailer::Base + def initialize(@order_id : Int64, @customer_name : String, @total : Float64) + end + + def html_body : String? + <<-HTML +

    Order Confirmation

    +

    Thank you, #{HTML.escape(@customer_name)}!

    +

    Order ##{@order_id} - Total: $#{sprintf("%.2f", @total)}

    + HTML + end + + def text_body : String? + <<-TEXT + Order Confirmation + Thank you, #{@customer_name}! + Order ##{@order_id} - Total: $#{sprintf("%.2f", @total)} + TEXT + end +end +``` + +### HTML-Only or Text-Only Emails + +Return `nil` from the method you do not want to include: + +```crystal +class HtmlOnlyMailer < Amber::Mailer::Base + def html_body : String? + "

    HTML content only

    " + end + + def text_body : String? + nil # No text alternative + end +end +``` + +## Fluent API + +All setter methods return `self` to enable method chaining: + +```crystal +OrderConfirmation.new(order_id: 123_i64, customer_name: "Alice", total: 49.99) + .to("alice@example.com", "billing@example.com") + .from("orders@myapp.com") + .subject("Order #123 Confirmed") + .cc("manager@myapp.com") + .bcc("archive@myapp.com") + .reply_to("support@myapp.com") + .header("X-Mailer", "MyApp/2.0") + .deliver +``` + +### Available Methods + +| Method | Description | +|--------|-------------| +| `to(*addresses)` | Set recipient addresses (replaces previous) | +| `from(address)` | Set sender address (overrides default_from) | +| `subject(text)` | Set subject line | +| `cc(*addresses)` | Set CC recipients (replaces previous) | +| `bcc(*addresses)` | Set BCC recipients (replaces previous) | +| `reply_to(address)` | Set Reply-To address | +| `header(name, value)` | Add a custom MIME header | +| `attach(filename, content, mime_type)` | Attach binary content | +| `attach_file(path, filename, mime_type)` | Attach a file from disk | +| `build` | Build the Email struct without sending | +| `deliver` | Build and deliver the email | + +## Attachments + +### Binary Content + +```crystal +pdf_bytes = File.read("invoice.pdf").to_slice + +InvoiceMailer.new(invoice_id: 456_i64) + .to("customer@example.com") + .subject("Your Invoice") + .attach("invoice-456.pdf", pdf_bytes, "application/pdf") + .deliver +``` + +### File from Disk + +```crystal +InvoiceMailer.new(invoice_id: 456_i64) + .to("customer@example.com") + .subject("Your Invoice") + .attach_file("tmp/invoices/456.pdf", mime_type: "application/pdf") + .deliver +``` + +If `filename` is not provided to `attach_file`, the basename of the path is used automatically. + +Attachments are base64-encoded in the generated MIME message and included as separate MIME parts with `Content-Disposition: attachment` headers. + +## Configuration + +Configure the mailer system using the singleton Configuration: + +```crystal +Amber::Mailer::Configuration.configure do |config| + config.adapter = :smtp + config.smtp_host = "smtp.example.com" + config.smtp_port = 587 + config.smtp_username = ENV["SMTP_USER"] + config.smtp_password = ENV["SMTP_PASS"] + config.use_tls = true + config.default_from = "noreply@myapp.com" + config.helo_domain = "myapp.com" +end +``` + +### Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `adapter` | `:memory` | Delivery backend (`:memory` or `:smtp`) | +| `smtp_host` | `"localhost"` | SMTP server hostname | +| `smtp_port` | `587` | SMTP server port | +| `smtp_username` | `nil` | SMTP AUTH LOGIN username | +| `smtp_password` | `nil` | SMTP AUTH LOGIN password | +| `use_tls` | `true` | Enable STARTTLS encryption | +| `default_from` | `"noreply@example.com"` | Default sender when `from` is not called | +| `helo_domain` | `"localhost"` | Domain sent in EHLO command | + +### YAML Configuration + +Mailer settings can be placed in your environment YAML files: + +```yaml +mailer: + adapter: "smtp" + default_from: "noreply@myapp.com" + smtp: + host: "smtp.example.com" + port: 587 + username: "user@example.com" + password: "secret" + use_tls: true + helo_domain: "myapp.com" +``` + +### Environment Variable Overrides + +```bash +AMBER_MAILER_ADAPTER=smtp +AMBER_MAILER_DEFAULT_FROM=noreply@myapp.com +AMBER_MAILER_SMTP_HOST=smtp.example.com +AMBER_MAILER_SMTP_PORT=587 +AMBER_MAILER_SMTP_USERNAME=user@example.com +AMBER_MAILER_SMTP_PASSWORD=secret +AMBER_MAILER_SMTP_USE_TLS=true +AMBER_MAILER_SMTP_HELO_DOMAIN=myapp.com +``` + +## Delivery Adapters + +### MemoryAdapter (Default) + +Stores all delivered emails in a thread-safe class-level array. Ideal for testing and development. + +```crystal +# Clear stored emails +Amber::Mailer::MemoryAdapter.clear + +# Check deliveries +Amber::Mailer::MemoryAdapter.count # => 0 +Amber::Mailer::MemoryAdapter.deliveries # => Array(Email) +Amber::Mailer::MemoryAdapter.last # => Email? +``` + +### SMTPAdapter + +Delivers emails via the SMTP protocol. Supports STARTTLS encryption and AUTH LOGIN authentication. + +The SMTP protocol flow is: + +1. Connect to SMTP server +2. Read server greeting (220) +3. Send EHLO +4. Optionally upgrade to TLS via STARTTLS +5. Re-send EHLO after TLS upgrade +6. Authenticate via AUTH LOGIN if credentials are provided +7. Send MAIL FROM, RCPT TO, DATA commands +8. Send the MIME message +9. Send QUIT + +### Writing a Custom Adapter + +Inherit from `Amber::Mailer::DeliveryAdapter` and implement the `deliver` method: + +```crystal +class SendGridAdapter < Amber::Mailer::DeliveryAdapter + def initialize(@api_key : String) + end + + def deliver(email : Email) : DeliveryResult + # Implement your delivery logic + response = HTTP::Client.post( + "https://api.sendgrid.com/v3/mail/send", + headers: HTTP::Headers{"Authorization" => "Bearer #{@api_key}"}, + body: build_sendgrid_payload(email).to_json + ) + + if response.status_code == 202 + DeliveryResult.new(is_successful: true) + else + DeliveryResult.new(is_successful: false, error: response.body) + end + end +end +``` + +## Building Without Delivering + +Use `build` to construct the `Email` struct without sending it. This is useful for inspecting the email or passing it to a background job: + +```crystal +mailer = WelcomeMailer.new("Alice") + .to("alice@example.com") + .subject("Welcome!") + +email = mailer.build +puts email.to # => ["alice@example.com"] +puts email.subject # => "Welcome!" +puts email.to_mime # => Full MIME message string +``` + +## Testing + +Use the MemoryAdapter to verify emails were sent correctly in your specs: + +```crystal +describe OrderConfirmation do + before_each do + Amber::Mailer::Configuration.reset + Amber::Mailer::MemoryAdapter.clear + end + + it "sends a confirmation email" do + OrderConfirmation.new( + order_id: 123_i64, + customer_name: "Alice", + total: 49.99 + ) + .to("alice@example.com") + .subject("Order #123 Confirmed") + .deliver + + Amber::Mailer::MemoryAdapter.count.should eq(1) + + email = Amber::Mailer::MemoryAdapter.last.not_nil! + email.to.should eq(["alice@example.com"]) + email.subject.should eq("Order #123 Confirmed") + email.html_body.not_nil!.should contain("Alice") + end + + it "includes attachments" do + content = "test content".to_slice + + WelcomeMailer.new("Alice") + .to("alice@example.com") + .subject("Welcome!") + .attach("test.txt", content, "text/plain") + .deliver + + email = Amber::Mailer::MemoryAdapter.last.not_nil! + email.attachments.size.should eq(1) + email.attachments[0].filename.should eq("test.txt") + end +end +``` + +## Common Patterns + +### Sending Email from a Controller + +```crystal +class UsersController < ApplicationController + def create + user = User.create!(params) + + WelcomeMailer.new(user.name) + .to(user.email) + .subject("Welcome to MyApp!") + .deliver + + redirect_to "/users/#{user.id}" + end +end +``` + +### Sending Email via a Background Job + +```crystal +class SendEmailJob < Amber::Jobs::Job + include JSON::Serializable + + property user_name : String + property user_email : String + + def initialize(@user_name : String, @user_email : String) + end + + def perform + WelcomeMailer.new(@user_name) + .to(@user_email) + .subject("Welcome!") + .deliver + end +end + +# In the controller +SendEmailJob.new(user_name: user.name, user_email: user.email).enqueue +``` + +## Source Files + +- `src/amber/mailer.cr` -- Module entry point +- `src/amber/mailer/base.cr` -- Abstract Base mailer with fluent API +- `src/amber/mailer/email.cr` -- Email struct, Attachment struct, DeliveryResult +- `src/amber/mailer/delivery_adapter.cr` -- Abstract DeliveryAdapter base class +- `src/amber/mailer/memory_adapter.cr` -- In-memory adapter for testing +- `src/amber/mailer/smtp_adapter.cr` -- SMTP delivery adapter +- `src/amber/mailer/mime.cr` -- MIME message builder +- `src/amber/mailer/configuration.cr` -- Mailer configuration singleton diff --git a/docs/guides/markdown.md b/docs/guides/markdown.md new file mode 100644 index 000000000..d030ac788 --- /dev/null +++ b/docs/guides/markdown.md @@ -0,0 +1,299 @@ +# Markdown + +Amber V2 includes an internalized Markdown renderer (`Amber::Markdown`) that converts Markdown source to HTML. It supports GitHub Flavored Markdown (GFM) extensions, table of contents generation, footnotes, and pluggable syntax highlighting for fenced code blocks. No external shards are required. + +## Quick Start + +```crystal +require "amber/markdown" + +html = Amber::Markdown.to_html("# Hello World") +# => "

    Hello World

    \n" +``` + +## API + +### to_html + +Converts a Markdown string to HTML: + +```crystal +Amber::Markdown.to_html(source : String, options = Options.new) : String +``` + +Returns an empty string when given an empty source. + +```crystal +Amber::Markdown.to_html("") +# => "" + +Amber::Markdown.to_html("**Bold** and *italic*") +# => "

    Bold and italic

    \n" +``` + +### to_html_with_toc + +Converts Markdown to HTML and generates a table of contents from headings: + +```crystal +Amber::Markdown.to_html_with_toc(source : String, options = Options.new) : {html: String, toc: String} +``` + +Returns a named tuple with `:html` and `:toc` keys. The `:toc` value is an HTML string containing a `