HTTP API Toolkit HTTP API Toolkit
HTTP API Toolkit HTTP API Toolkit
DocFX + Singulink = ♥

Search Results for

    Upgrading from 3.x

    This guide lists what changes for an app written for 3.x: the changes that need code or schema changes, and the changes in behavior that need none. Features that are new in 4.0, such as sign-in codes, device links, passkeys, chunked uploads, rate limiting and Cloudflare support, are covered in their own guides.

    Before Upgrading

    • The session cookie has a new format, which 3.x cookies are not read as. Users sign in once after upgrading.
    • The default session cookie name is __Host-session-token (SessionCookieName), rather than session-token, in the service and the client alike. 3.x clients and services only use session-token: a 3.x client of a 4.0 service with the default name, or a 4.0 client of a 3.x service, signs in, and then every request fails with a 401. While 3.x clients or services are in use, such as installed apps that have not been updated, the 4.0 service and its clients set the name to session-token. Browsers only accept a __Host- cookie without a domain and with the path /, so a service that sets CookieDomain or another CookiePath with that name fails to start. An app that sets either in any environment, such as a domain only in production, sets a name of its own for the service and its clients in every environment.
    • Services that read the same session cookie, such as the servers of one service or services that share it through SessionHandlingOptions.CookieDomain, are upgraded together: a 4.0 service does not read a 3.x cookie, and a 3.x service does not read a 4.0 one.
    • A 4.0 hub connection (ApiHubConnection) waits for the confirmation that a 4.0 service sends once the hub accepted the connection, so its start doesn't complete against a 3.x service. 3.x clients of a 4.0 service ignore the confirmation (see Confirming connections).
    • Session handling registers ASP.NET Core data protection (AddDataProtection()), which 3.x left to the app, unless an IDataProtectionProvider is registered before it (see the note in Session Handling on where its keys are kept). A service with session handling and without the allowed origins (AddAllowedOrigins) fails at startup, rather than at the first request that reads a session.
    • UseApiResponseHandling requires services.AddApiResponseHandling() (AddApiResponseHandling) and throws without it, and an app that registers those services without calling UseApiResponseHandling fails to start. An app that calls UseEndpoints after mapping endpoints on the app, or maps endpoints in it, fails to start (see Streaming Responses).
    • The shared and client packages also target .NET Standard 2.0, for .NET Framework 4.6.2 and later. Apps on other runtimes that load .NET Standard 2.0 libraries, such as .NET 8 and 9, get a build warning: they are not supported. The client package no longer depends on Microsoft.Extensions.Http: an app that used that package through the toolkit references it itself.
    • Several signatures change in ways that keep calls compiling but not compiled code, such as the path parameters of the client (see Clients), so assemblies built against 3.x are rebuilt.

    Session Tokens and Stores

    Version 4.0 moves the session's refresh info out of the token, into the cookie next to it, and replaces the store context with a scoped store:

    • ISessionToken only has UserId. Remove RefreshedUtc, ValidFor, Generation, IsPersistent and RefreshAfter from your token type: the library keeps the refresh info in the cookie, and the refresh interval is SessionHandlingOptions.RefreshAfter, 10 minutes by default. A token type that keeps its own RefreshAfter still compiles, but the property is ignored, so a token that set a shorter interval is refreshed after the option's interval.
    • A token type that implements ISessionToken<TSelf> with its own type, as in record SessionToken(...) : ISessionToken<SessionToken>, binds as an endpoint parameter without a BindAsync of its own (see The Session Token).
    • ISessionStoreContextFactory<T> and ISessionStoreContext<T> are replaced by ISessionStore<TSessionToken>, which AddHttpSessionHandling registers as a scoped service. Its second type argument is the store type. The store's methods take a cancellation token, which is the request's, except for signing out, ending the previous session at sign-in and ending a session after GetSessionAsync threw, which are not cancelled, and the rechecks of SignalR connections, which have a token of their own. Each call runs in a service scope of its own, not the request's.
    • CreateTokenAsync and SessionLookupResult.IsTokenStale are replaced by the replacement token of SessionLookupResult<TSessionToken>, which GetSessionAsync returns when the data in the token is out of date. The library applies the refresh info itself, so tokens are no longer copied on every refresh.
    • A store can end a session by throwing an UnauthorizedApiException from GetSessionAsync, such as for a disabled account: the session is invalidated, the cookie is cleared and the exception's message reaches the client (see Ending Sessions). Returning null ends it without a message.
    • The token that the function passed to SignInAsync returns has no refresh info. SignInInfo derives from SessionRecord: SessionExpiry, the time span the session is valid for, is replaced by ExpiresUtc, the time it expires, and it also carries the session's creation and refresh time and its generation, which the session record stores.
    • SessionRecord has no constructor: its values are required properties, set by name. It has the session's expiry time rather than a time span, ExpiresUtc, which a database can filter on, and the session's creation time, CreatedUtc, which 3.x did not have, so that the maximum session lifetimes always apply. The store saves the creation time when a session is created, as in Signing In and Out, and passes it back when GetSessionAsync loads the session. It never needs to be written on refresh.
    • Existing sessions need values for the new columns. Since users sign in again after upgrading anyway, the old sessions can also simply be deleted. To keep them, fill the expiry column from the refresh time and the old time span, and the creation column with the time of the migration, which gives them a fresh maximum-lifetime window, or with their last refresh time, which ends them sooner when a maximum lifetime is configured. In SQL Server, for example, where the 3.x SessionExpiry column holds the time span as a number of ticks (a bigint; a column of another type needs another conversion):
    ALTER TABLE Sessions ADD ExpiresUtc datetime2 NULL;
    UPDATE Sessions SET ExpiresUtc = DATEADD(second, SessionExpiry / 10000000, RefreshedUtc);
    ALTER TABLE Sessions ALTER COLUMN ExpiresUtc datetime2 NOT NULL;
    ALTER TABLE Sessions DROP COLUMN SessionExpiry;
    
    -- Existing sessions count from the migration:
    ALTER TABLE Sessions ADD CreatedUtc datetime2 NOT NULL DEFAULT SYSUTCDATETIME();
    
    -- Or from their last refresh:
    ALTER TABLE Sessions ADD CreatedUtc datetime2 NULL;
    UPDATE Sessions SET CreatedUtc = RefreshedUtc;
    ALTER TABLE Sessions ALTER COLUMN CreatedUtc datetime2 NOT NULL;
    
    • The device that a session records is shortened to SessionHandlingOptions.MaxDeviceLength characters, 512 by default, so a column of that size holds it.
    • SetToken replaces the token of the request's session and keeps its refresh info, so it throws InvalidOperationException for a request without a session.
    • The AddHttpSessionHandling overload without JSON type info serializes tokens with reflection and is marked with RequiresUnreferencedCode and RequiresDynamicCode. The overload that takes a JsonTypeInfo<T> for the token works in trimmed and Native AOT apps.

    Session Behavior

    • SessionHandlingOptions are validated at startup. An expiry (TempSessionExpiry or PersistentSessionExpiry) that is not longer than RefreshAfter fails startup, and the setters of the new options throw for values that are not positive.
    • A session cookie larger than 4096 bytes is logged as an error. It is still sent.
    • The cookie of a persistent session expires when the session does, rather than a full validity period after it was last issued.
    • SignInAsync rejects requests from origins that are not allowed, like retrieving the session does.
    • SignInAsync ends the request's previous session through InvalidateSessionAsync after it issues the new cookie, unless the sign-in function returned the previous session's token. In 3.x the previous session stayed in the store, so an app that signs in on a request that has a session, such as to switch accounts or to sign in again, now loses the previous session's record. A store's InvalidateSessionAsync can therefore be called for a session other than the one a request uses.
    • SignOutAsync checks the user ID precondition only when the request has one: a sign-out without it succeeds, and one for another user still fails with UserChangedApiException.
    • SessionAccessOptions.ForceValidate looks the session up on every call in a SignalR hub too, rather than once per connection, and a replacement token that the store returns is used for the rest of the connection.
    • In an app that registers SignalR, session handling rechecks the sessions of open hub connections and closes the connections whose sessions have ended, which 3.x left open, unless SessionHandlingOptions.RevalidateHubConnections is false (see Rechecking the sessions of open connections).
    • In an app that registers SignalR, AddApiResponseHandling and session handling add a hub filter that sends each connection a message once the hub's OnConnectedAsync succeeded (see Confirming connections). Clients without a handler for it, such as 3.x clients, log a warning and otherwise ignore it.

    Origins

    • An allowed origin given as a host, such as example.com or *.example.com, matches https origins only, and http origins too for the loopback hosts: localhost, the addresses in 127.0.0.0/8 and [::1]. List http origins of other hosts with their scheme, such as http://intranet.example.com (see OriginValidator). Origins with other schemes, such as chrome-extension:, no longer match, and *.localhost is not a loopback host.
    • Allowed origins with a scheme or a port, which 3.x accepted but never matched, match: a scheme matches only that scheme, on its default port unless a port is given, and a port matches only that port.
    • An allowed origin that is not a valid host pattern, such as an empty one, one with spaces, a path (including a trailing slash), a wildcard other than a leading *., or an invalid port, throws an ArgumentException when the OriginValidator is created, which AddAllowedOrigins does when it is called.
    • UseAllowedOrigins exposes the Retry-After, Reference-Id and Location headers to browsers. An app with its own CORS policy exposes the ones that its clients read. Outside the Development environment, it logs a warning for allowed origins that match a loopback host, such as localhost.
    • UseAllowedOrigins lets browsers cache the answers to preflight requests for 2 hours (Access-Control-Max-Age: 7200), the longest that Chrome caches them, where 3.x sent no max age, which browsers take as 5 seconds.

    Errors

    • Error responses in the toolkit's format have the content type text/plain; charset=utf-8, with format=error-code for an error with a code, where 3.x sent no charset, which left browsers to guess the encoding of the UTF-8 body. 3.x clients read these responses as before, since they only look at the media type and the format parameter.
    • AddApiExceptionHandler makes minimal API endpoints report requests they cannot read as API errors with a message in every environment, instead of an empty 400 in production and the developer exception page in development (see Requests That Cannot Be Read). A configuration of RouteHandlerOptions.ThrowOnBadRequest that the app registers before the call is overridden; one registered after it applies.
    • ApiExceptionMiddleware reports a BadHttpRequestException with its status, even without a handler or when a handler returns null for it, rather than letting it propagate. ApiExceptionHandler maps it after MapAsync, so it no longer reaches HandleUnexpectedAsync and is not logged as unexpected.
    • An empty 413 or 415 response, which ASP.NET Core sends for a request body over the size limit or a content type that the endpoint does not accept, is filled in with an error in the toolkit's format, also when an endpoint of the app sends it.
    • The error of an unexpected exception that ApiExceptionHandler reports carries its reference ID as ReferenceId, in the Reference-Id header of the response (see Error Response Format), and the message that has it ends with a period.
    • HubOptionsExtensions is renamed to ApiHubOptionsExtensions, so that it does not clash with SignalR's class of the same name. Calls such as options.AddApiExceptionFilter() compile as before.
    • The errors that the toolkit reports itself carry an error code, such as sign-in-required for a request without a session and origin-blocked for a blocked cross-origin request (see Error Codes). Clients that read the response body themselves see the code in brackets before the message.
    • The toolkit's JSON null handling, which AddApiJson and the client constructors that take a type info resolver apply, also rejects a null in a property that does not allow one when writing, where 3.x wrote the null: a service's response fails like an unexpected exception (a 500), a streaming response ends with an error record, SignalR closes the connection of a hub method whose result has one, and a client throws a JsonException for a command with one when the request is sent (see JSON Serialization).

    Streaming Responses

    • UseApiResponseHandling converts the streaming endpoints when the host starts, so it can be called before or after UseRouting and the endpoints. In 3.x, endpoints mapped after it did not get streaming responses.
    • Route templates without a leading slash, such as the ones of a route group mapped as MapGroup("api/v1"), gain one in endpoints mapped after UseApiResponseHandling: api/v1/items/{id} becomes /api/v1/items/{id}. 3.x, which required the call after the mappings, already gave one to the endpoints mapped before it. This changes the http.route tag of metrics and traces and the display names of those endpoints; API descriptions and OpenAPI documents are unchanged.

    Clients

    • Request paths are an InterpolatedApiPathHandler instead of a string, in CreateRequest, GetApiUrl and CreateHubConnection. A string converts to one, so calls compile as before, and overrides of CreateRequest and CreateHubConnection change their parameter type.
    • Values in interpolated paths are formatted with the invariant culture and escaped, so a / in a value no longer starts a new segment. Paths that build a query string or several segments from one value use the raw format, as in $"items?{queryString:raw}", or pass the values as query string parameters. A value that is . or .., other than a raw one, and a path that leads outside the base address, such as ../x, or /x when the base address has a path, throw an ArgumentException (see Paths). A path that contains ://, such as in its query, is no longer rejected for it, and an absolute URI under the base address is accepted.
    • Hub paths are under the base address too: a path such as ../../hubs/chat, which a 3.x client could use for a hub outside its base address, throws an ArgumentException. Such a hub is connected through a child client whose GetBaseAddress returns the hubs' address. A child client shares the session and the user ID precondition of the client it is created from:
    // Created with new HubsApiClient(this) by the client whose base address is https://api.example.com/business/v1/.
    public sealed class HubsApiClient(ApiClientBase parent) : SignalRApiClientBase(parent)
    {
        protected override Uri GetBaseAddress() => new("https://api.example.com/hubs/");
    
        public ApiHubConnection CreateChatConnection() => CreateHubConnection("chat");
    }
    
    • Interpolated paths do not support alignment, as in {value,8}, which fails to compile.
    • The query string parameters of CreateRequest, GetApiUrl and CreateHubConnection are one parameter named query, as in the request helpers, rather than a params parameter named queryStringParams. Calls pass them as a collection expression: CreateRequest(HttpMethod.Get, "items", [("page", 2), ("filter", filter)]).
    • GetDefaultQueryParams and the DefaultUserIdPreconditionQueryName and DefaultSessionCookieName constants are gone. A child client takes the user ID precondition as a constructor argument, and UserIdPreconditionQueryName and SessionCookieName hold the names. Other default query string parameters are passed with each request. Child clients inherit both names from their parent.
    • GetBaseAddress is virtual rather than abstract, and child clients inherit the parent's base address, so they no longer override it to return the parent's.
    • A base address whose path has no trailing slash is used as if it had one: with https://example.com/api, the path x goes to https://example.com/api/x, where 3.x sent it to https://example.com/x.
    • IsPersistentSession is read-only: the session cookie decides it. The app passes its "remember me" choice to its sign-in endpoint, where the service signs the user in with it. The session token changed callback is called with null when a session that isn't persistent replaces a persistent one.
    • The constructors that take a type info resolver apply the toolkit's JSON null handling (see JSON Serialization): a response with a missing or null value where the contract does not allow one throws a JsonException. A response whose body is null throws one too, with every constructor, rather than a FormatException. Their configuration callback runs after the resolver is set and the null handling is applied, instead of before the resolver is set, so a resolver that it sets or adds is kept, and resolvers that it adds are not covered by the null handling.
    • The parameter of the constructors that take a type info resolver is named typeInfoResolver rather than serializerContext, which matters to calls that name it.
    • The session cookie is only taken from responses from the host of the base address, and only from a response whose request sent the current session token, or a sign-in's or sign-out's response, which the service marks with a Session-Change header. 3.x took every response's. When a response sets it more than once, the last one wins, and an expired cookie ends the session like an empty one. A sign-in's response counts as the start of a session (see NotifySessionStarted), also when it replaces a session.
    • Redirects are not followed, except on browser platforms: a 3xx response throws an ApiException with its status, whose Location has the address that a redirect leads to. 3.x followed them and sent the session cookie to the host they led to. A Set-Cookie header on a redirect from the host of the base address is now picked up.
    • Hub connections connect over WebSockets without a negotiate request (HttpConnectionOptions.SkipNegotiation), and their WebSocket handshake does not follow redirects, except on browser platforms. Without the negotiate request, ConnectionId and the argument of Reconnected are null, where 3.x had the connection ID, and stateful reconnect is off. Other transports, and services that send connections elsewhere in their negotiate response, such as Azure SignalR Service, need negotiation, which the configureOptions callback of CreateHubConnection can turn back on. The session cookie then only goes to the host of the base address.
    • A 401 with the sign-in-required error code ends the session on the device, from requests, streaming responses and hub connections alike, also when the response does not clear the cookie (see Session Persistence).
    • ApiHubConnection.StartAsync completes once the service confirms that the hub accepted the connection, after the hub's OnConnectedAsync, and an error that OnConnectedAsync throws fails the start, where 3.x completed the start at the handshake and reported the error through Closed. Reconnected likewise occurs once the hub accepted the reconnected connection (see Using the Contract on the Client).
    • A UserChangedApiException has the user ID precondition of the request or hub connection that the service refused as its ExpectedUserId. The client sets it from its own request, so errors are sent as before.
    • The client throws dedicated exception types for 409, 413, 415, 429 and 503 responses, which derive from ApiException as before. An error response that is not in the toolkit's format keeps its status instead of being reported as a 500, and its message names the status, such as "The service returned an error (502 Bad Gateway).". RetryAfter is set from the Retry-After header, which browsers only show to the app if the service exposes it, as UseAllowedOrigins does.
    • RequestTimeout sets how long a request can take, 100 seconds by default as in 3.x. The 3.x timeout ended when the response headers arrived; this one also covers reading the body of a response that is returned as a type or a string, so a large body that arrives slowly can time out.
    • The default UserAgent is the name and version of the app's entry assembly followed by the operating system, such as MyApp/1.2.0.0 (Microsoft Windows 10.0.26100), rather than the name and version of the client library. CreateUserAgent creates one of the same form with another product name and version.
    • ApiClientBase has the protected upload methods UploadAsync and UploadStreamingAsync (see Chunked Uploads in Client Apps). A method of a derived client with either name and the same parameters hides them, with a compiler warning, and one with other parameters is an overload of them.
    • VoidApiResponse is internal. It was the response type of the requests that don't read their response, which no public member uses.
    • An IOException while reading the body of a JSON response, such as a connection that closes before the body ends, is thrown as an HttpRequestException with the IOException as its inner exception, as it already was for string responses. Streaming responses still throw the IOException.

    Namespaces

    No type from 3.x moved to another namespace. The features new in 4.0 have namespaces of their own: sign-in codes and device links are in Singulink.Net.Http.Api.Service.SignIn, passkeys in Singulink.Net.Http.Api.Service.Passkeys, chunked uploads in Singulink.Net.Http.Api.Service.Uploads, the client's passkey support in Singulink.Net.Http.Api.Client.Passkeys, and the data contracts that apps return to their clients, such as a passkey ceremony, in Singulink.Net.Http.Api.DataContracts. Their registration methods, the short-lived store and rate limiting are in Singulink.Net.Http.Api.Service, like the others. The error codes of the toolkit's own errors are in the shared Singulink.Net.Http.Api package, so that clients can use them.

    The new types in the Singulink.Net.Http.Api namespace, such as ConflictApiException and TooManyRequestsApiException, and in the Singulink.Net.Http.Api.Client namespace, such as UploadSource, UploadOptions and UploadProgress, can clash with an app's own types of the same name.

    Next Steps

    Continue with these related articles:

    • Session Handling - The session token, the session store and signing in.
    • Building API Clients - Paths, session persistence and child clients.
    • JSON Serialization - The null handling on the service and in clients.
    © Singulink. All rights reserved.