Frank.Alps
7.3.3
dotnet add package Frank.Alps --version 7.3.3
NuGet\Install-Package Frank.Alps -Version 7.3.3
<PackageReference Include="Frank.Alps" Version="7.3.3" />
<PackageVersion Include="Frank.Alps" Version="7.3.3" />
<PackageReference Include="Frank.Alps" />
paket add Frank.Alps --version 7.3.3
#r "nuget: Frank.Alps, 7.3.3"
#:package Frank.Alps@7.3.3
#addin nuget:?package=Frank.Alps&version=7.3.3
#tool nuget:?package=Frank.Alps&version=7.3.3
Frank.Alps
Frank.Alps serves ALPS (Application-Level Profile Semantics, draft-07) profile documents describing what your resources mean and which transitions are available. Profiles are hand-authored F# values — never derived from CLR types or view templates — and each transition is bound to the endpoint that implements it, so the served document is filtered per principal and, optionally, per resource state.
Features
- Hand-authored profiles: plain
|>combinators, or adescriptor { }computation expression — both produce identicalDescriptorvalues - Compile-checked references:
rt/href/fromtake aDescriptorvalue, not a string id, so a dangling reference is a compile error, not a wrong document - Composite-state authoring:
contains/initial/regionsexpress substate (OR) and orthogonal (AND) decomposition, ridingextso documents stay spec-valid for readers that don't know them - Zero-friction binding:
binds descriptorinsidehandler { }attaches a transition to the endpoint that implements it — no separate registry to keep in sync - Startup validation: a transition's authored
typeis checked against its bound HTTP method; a mismatch fails host startup, and an unbound transition is logged as a warning, rather than either shipping silently - Authorization- and state-filtered documents: reads stock
IAuthorizeData/AuthorizationPolicymetadata (noFrank.Authdependency) at every depth of the profile, and optionally filters by aCurrentStateResolveryou supply - Two HTTP exposures: an app-wide document at
/.well-known/alps.json, and a per-resource excerpt wired intonegotiate { } - Zero NuGet dependencies: only
FrameworkReference Microsoft.AspNetCore.Appand a project reference toFrank; no dependency onFrank.Auth,Frank.Rdf, orFrank.OpenApi
Installation
dotnet add package Frank.Alps
Quick Start
open Frank.Builder
open Frank.Alps
module Catalog =
let openState = semantic "open" |> def "https://tictactoe.example/states/open"
let closedState = semantic "closed" |> def "https://tictactoe.example/states/closed"
let viewGame = safe "viewGame"
let makeMove = unsafe "makeMove" |> from [ openState ] |> rt closedState
// Transitions may be authored at the top level or nested under the state they act on.
let game = semantic "game" |> doc "A tic-tac-toe game" |> contains [ viewGame; makeMove ]
webHost args {
useDefaults
resource "/games/{id}" {
// Advertises the per-resource excerpt available at this same url.
link (fun ctx ->
Seq.singleton
{ Target = string ctx.Request.Path
Rel = "profile"
Params = [ "type", "application/alps+json" ] })
get (
negotiate {
accepts "application/json" (handler {
handle getGameJson
binds Catalog.viewGame
})
accepts "application/alps+json" (Alps.excerpt None)
}
)
post (handler {
handle makeMoveHandler
binds Catalog.makeMove
})
}
useAlps [ Catalog.openState; Catalog.closedState; Catalog.game ]
}
A descriptor { } computation expression offers the same vocabulary as the |> combinators above, producing an identical Descriptor:
descriptor "makeMove" { unsafe; from [ Catalog.openState ]; rt Catalog.closedState }
API Reference
Authoring Operations
semantic/safe/unsafe/idempotent"id"— Constructs a descriptor of thattype(semanticis the spec's default). Also available as zero-argument custom operations insidedescriptor { }doc "text"/docWith { Value; Href; Format; ContentType; Tag }— Human-readable documentationdef "iri"— The descriptor's source-definition IRInamed/rel/tag—name,rel, andtagfrom draft-07 §2.2ext "id" "value"/extWith { Id; Href; Value; Tag }— Author-specific extension datalink "href" "rel"/linkWith { Href; Rel; Title; Tag }— An RFC 8288 web link, distinct from descriptor inheritancecontains [ children ]— Nests descriptors (draft-07 §2.2.4), deliberately unrestricted by child typert target/href target/from [ sources ]— Descriptor-typed references: a dangling one is a compile error, not a wrong document.hrefExternal "uri"is the escape hatch for a document this codebase doesn't owninitial/regions [ children ]— Composite-state structure: the default child of acontainslist, and orthogonal (AND) rather than substate (OR) decomposition. Both rideextunderhttps://frank-fs.github.io/alps-ext/, so documents stay spec-valid for readers that don't know them
Binding Transitions to Resources
binds descriptor, inside a handler { } block, records which transition an endpoint implements. That binding is what makes the rest work:
- Startup validation: a transition's authored
typeis checked against its bound HTTP method (safe→ GET/HEAD,idempotent→ PUT/DELETE,unsafe→ POST). A mismatch fails host startup rather than serving a wrong document. A transition in the profile that nothing binds is logged as a startup warning and omitted from the document. - Authorization filtering: reads stock
IAuthorizeData/AuthorizationPolicyendpoint metadata, so it works withFrank.Authwithout referencing it and equally with a plainAuthorizeAttribute. Evaluation failures deny. Filtering applies at every depth of the profile, so a guarded transition nested under a semantic state is hidden too. Semantic descriptors are never filtered — vocabulary, not capability.
Two HTTP Exposures
- App-wide document —
useAlps [ ... ]serves the whole profile at/.well-known/alps.json(configurable) and advertises it with aLink: rel="profile"response header. Filtered by authorization only; there is no resource instance in scope to have a state. - Per-resource excerpt —
Alps.excerpt resolver, wired into anegotiate { }block'saccepts "application/alps+json"case, serves just the transitions bound to this resource's route (every HTTP method's, not only the one it runs under). Filtered by authorization and, whenresolverisSome, by state. When a served (filtered) document references a descriptor absent from that filtered view, the reference resolves to the full document's URI (rootUri#id) rather than a dangling same-document fragment.
Both emit Cache-Control: private, no-cache and Vary: Authorization whenever any bound endpoint is guarded.
State-Based Filtering
from [ states ] marks a transition valid only from the given source state(s); a transition with no from is never state-filtered. CurrentStateResolver — a plain string -> Uri list, wired at composition time — answers "what states is this specific resource concurrently in" (one element per active orthogonal region):
let resolver: CurrentStateResolver =
fun resourceIri -> if isFinished resourceIri then [ closedIri ] else [ openIri ]
accepts "application/alps+json" (Alps.excerpt (Some resolver))
No dependency on any store: the natural implementation queries a provenance or event store, and an absent resolver (or one returning []) simply means state filtering does not apply. Matching walks contains ancestry rather than requiring exact equality, so being in a substate satisfies a transition declared from any of its ancestors. A transition's from candidate is satisfied if any element of the resolved list satisfies it (existential/OR match across concurrently-active regions) — conjunctive AND-guards and multi-region fan-out targets are out of scope here (frank-fs/frank#489).
ProtocolGraph.ofProfile derives the read-only { FromGuard: StateGuard option; Transition: Descriptor; ToTargets: TransitionTarget list } edge set from the authored profile. FromGuard comes from Guard (set via guardedBy) if present, else from From: empty yields None (unconditional — the edge fires regardless of prior state), one state yields Some (State s), and multiple states now collapse into a single Some (Any [State s1; State s2; ...]) edge rather than one edge per source state as before. ToTargets comes from Targets (set via entersRegions) if non-empty, else from Rt: Some yields one [EnterState t], None yields []. An edge is emitted iff the resulting ToTargets is non-empty — FromGuard is independently optional, so a transition declaring only rt (no from/guardedBy) now yields an unconditional edge, where previously it was excluded. Nothing in this package executes a transition or owns what state a resource is actually in. See docs/superpowers/specs/2026-08-08-frank-alps-compound-transitions-design.md for the full design rationale behind this shape and both behavior changes.
Architecture
Frank.Alps is built on:
- Frank: Provides the computation expression framework for defining HTTP resources and the
HandlerDefinition/endpoint-metadata mechanismbindswrites into - ASP.NET Core:
Endpoint/EndpointDataSourceare read directly (EndpointSurface) rather than through an ApiExplorer dependency
There is no dependency on Frank.Rdf, Frank.JsonHome, Frank.Provenance, or Frank.Auth — AuthorizationFilter reads stock ASP.NET Core authorization metadata, so it composes with Frank.Auth without referencing it.
See sample/Frank.Alps.Sample for a runnable demonstration of both HTTP exposures and both Link headers.
Related Projects
- Frank - F# web framework
- ALPS - Application-Level Profile Semantics (draft-07)
- RFC 6906 - The 'profile' Link Relation Type
License
MIT License - see LICENSE file for details
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Frank (>= 7.3.3)
- FSharp.Core (>= 10.1.302)
-
net8.0
- Frank (>= 7.3.3)
- FSharp.Core (>= 10.1.302)
-
net9.0
- Frank (>= 7.3.3)
- FSharp.Core (>= 10.1.302)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
### New in 7.3.3 (Released 2026-08-10)
**Frank.Rdf - Async IBufferWriter Streaming**
- **New: `Doc.writeJsonLdAsync doc bufferWriter`** — async overload that writes JSON-LD expanded-form directly to an `IBufferWriter<byte>` (e.g. `HttpResponse.BodyWriter`/`PipeWriter`), without intermediate string allocation or copying. Encodes UTF8 directly to the buffer for maximum efficiency in response streaming. Completes after serialization and flushing to the buffer.
- **Recommended for response streaming:** `writeJsonLdAsync` is the preferred method when serving JSON-LD from a Frank handler over HTTP. Streaming directly to `PipeWriter` avoids `AllowSynchronousIO` requirements and eliminates intermediate buffering layers that `StreamWriter` would introduce.
- **Three serialization options now available:** Use `writeJsonLdAsync` for HTTP responses (most efficient), `writeJsonLd` for flexibility with any `TextWriter`, and `toJsonLd` for testing/debugging.
- **Sample updated:** `sample/Frank.Rdf.Sample` demonstrates `Doc.writeJsonLdAsync` streaming the `application/ld+json` representation directly to the response body via `negotiate { }` content negotiation.
- **Test coverage:** comprehensive test cases for `writeJsonLdAsync` including single and multi-subject documents, language-tagged strings, and round-trip parsing.