Skip to content

Writing functions

A function is a plain C# class. The one rule is that it only uses IFunctionContext: no HttpClient, DbContext or IConfiguration. The analyzers enforce this at compile time.

using Microsoft.Kiota.Abstractions;
using StayFn.Abstractions;
using StayFn.Functions.Client; // the typed function client, generated in every function project
using StayFn.Ohip.Generated.Rsv.Models;
namespace MyCompany.Functions.Rsv; // last segment names a known area: rsv (or set Area = "rsv")
public sealed record ArrivalsOutput(int Count);
public sealed class ArrivalFunctions
{
/// <summary>Count arrivals for a day.</summary>
/// <param name="date">Arrival date.</param> // becomes the schema description of "date"
/// <param name="roomType">Room type code; all room types when omitted.</param>
[OhipFunction("count-arrivals", Visibility = FunctionVisibility.TenantApi, Idempotency = IdempotencyMode.None)]
[OhipBudget(3)] // OHIP calls it may make; reserved before it runs
public async Task<ArrivalsOutput> CountArrivals(DateOnly date, string? roomType, IFunctionContext ctx)
{
var enabled = await ctx.Vars.TryGetAsync<bool>("arrivals.enabled"); // tenant/environment/hotel variable
var day = new Date(date.Year, date.Month, date.Day);
// One method per OHIP operationId; hotelId defaults to the request's hotel. Budgeted, retried and recorded.
var found = await ctx.Ohip.Api.Rsv.SearchHotelReservationsAsync(
new SearchHotelReservationsRequest { ArrivalStartDate = day, ArrivalEndDate = day, Limit = 200 }, ct: ctx.Cancellation);
…
}
}

The signature is the contract. Parameters of any serialisable type form the body, a JSON object of camelCase properties ({"date":"2026-09-26","roomType":"KING"}); non-nullable parameters without a default are required, and XML <param> docs become the schema descriptions. IFunctionContext, CancellationToken, IOhipClient, IVariables, IFunctionInvoker, IEventPublisher and ILogger parameters are filled from the invocation context, in any position and each at most once. Methods may be static or synchronous and may return Task, Task<T>, ValueTask, ValueTask<T> or a value. The classic shape Task<TOut> Run(TIn input, IFunctionContext ctx), where one record is the whole body, still works unchanged; Binding = FunctionBinding.Arguments makes a single record a named argument instead ({"address":{…}}).

Call other functions through the generated client: await ctx.Functions.Rsv().CountArrivalsAsync(date, "KING"), or for a record function ctx.Functions.Rsv().GetReservationAsync("12345678") (small records are flattened) or GetReservationAsync(new GetReservationInput(…)). A renamed function or a changed input breaks the build. ctx.Functions.InvokeAsync<TIn, TOut>("rsv/…", …) remains for dynamic names.

There are three ways to call OHIP from a function, and all of them go through the same authenticated, rate-limited and recorded pipeline:

  • The flat operation layer, ctx.Ohip.Api.<Module>.<Operation>Async(…). Use this by default. It is generated from the specs with one method per operationId in all 22 modules (Api.Rsv, Api.Crm, Api.Rsvasync, …).
    • Path parameters are required arguments, and the body is the generated model.
    • hotelId defaults to the request’s hotel.
    • Up to 8 query parameters are named optional arguments. More come as query: q => … over the generated query object.
    • Deprecated operations are [Obsolete] and name their replacement.
    • The *async modules poll for you. Start…Async returns the Location instead.
    • Hover text shows the real METHOD /path.
  • Typed helpers such as ctx.Ohip.Reservations.GetByConfirmationAsync and ctx.Ohip.Profiles.SearchByNameAsync. They are the portable surface: the OPERA 5 adapters implement them, while the flat layer and the Kiota clients throw there.
  • The Kiota clients, ctx.Ohip.<Api>.Client.… request-builder chains. They send the same requests as the flat layer. MCP ohip.operation shows both forms for every operation.

Other capabilities on ctx: ctx.Events (publish an internal event), ctx.Vars.GetSecretAsync, ctx.Logger (PII-masked), ctx.Cancellation, ctx.Tenant, ctx.CorrelationId. [OhipFunction] properties control Idempotency (when not set: KeyRequired for record functions, which makes callers send Idempotency-Key, and Derived for plain-signature functions, where identical calls replay without a header; or set None, KeyRequired, Derived explicitly), Retry (Default, None, Aggressive), TimeoutSeconds (60), Visibility (Internal by default; TenantApi or Public to expose it over HTTP) and Schedule (6-field cron). src/StayFn.Functions.Reference has complete, tested examples.

You get a function, its WireMock-backed tests and a catalog doc stub from the same templates in three ways:

  1. CLI: dotnet run --project src/StayFn.Cli -- new function count-arrivals --area rsv --input 'date:DateOnly' --output 'count:int' (one to three simple inputs give a plain signature; more, lists or --operation give an input record). new handler <name> --event reservation.changed and new scheduled <name> --schedule "0 0 6 * * *" work the same way.
  2. Claude Code: the repository’s .mcp.json registers the stayfn MCP server. Ask Claude Code for a function and it uses catalog.search → ohip.operation → function.scaffold → function.validate. See docs/authoring-with-claude-code.md.
  3. VS Code: the StayFn extension offers StayFn: New Function, validation, CodeLens ▶ Invoke and the live Invocations view. See docs/vscode-extension.md.

Test functions with StayFn.Testing.FunctionTestHost, which runs the real OHIP pipeline against WireMock. The generated test files show the pattern: one happy path and one OHIP-error case.

To load your own function assembly into the host, reference it from StayFn.Host, or point stayfn dev --project <your.csproj> at it during development.