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 thansession-token, in the service and the client alike. 3.x clients and services only usesession-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 a401. 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 tosession-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 anIDataProtectionProvideris 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 callsUseEndpointsafter 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,IsPersistentandRefreshAfterfrom 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 ownRefreshAfterstill 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 aBindAsyncof its own (see The Session Token). ISessionStoreContextFactory<T>andISessionStoreContext<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.CreateTokenAsyncandSessionLookupResult.IsTokenStaleare 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
nullends 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
SessionExpirycolumn holds the time span as a number of ticks (abigint; 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
RequiresUnreferencedCodeandRequiresDynamicCode. 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
InvalidateSessionAsynccan 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
OnConnectedAsyncsucceeded (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.comor*.example.com, matcheshttpsorigins only, andhttporigins too for the loopback hosts:localhost, the addresses in127.0.0.0/8and[::1]. Listhttporigins of other hosts with their scheme, such ashttp://intranet.example.com(see OriginValidator). Origins with other schemes, such aschrome-extension:, no longer match, and*.localhostis 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-IdandLocationheaders 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 aslocalhost. - 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, withformat=error-codefor 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 theformatparameter. - AddApiExceptionHandler makes minimal API endpoints report requests they cannot read as API errors with a message in every environment, instead of an empty
400in production and the developer exception page in development (see Requests That Cannot Be Read). A configuration ofRouteHandlerOptions.ThrowOnBadRequestthat the app registers before the call is overridden; one registered after it applies. - ApiExceptionMiddleware reports a
BadHttpRequestExceptionwith its status, even without a handler or when a handler returnsnullfor 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
413or415response, 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-Idheader of the response (see Error Response Format), and the message that has it ends with a period. HubOptionsExtensionsis renamed to ApiHubOptionsExtensions, so that it does not clash with SignalR's class of the same name. Calls such asoptions.AddApiExceptionFilter()compile as before.- The errors that the toolkit reports itself carry an error code, such as
sign-in-requiredfor a request without a session andorigin-blockedfor 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
nullin a property that does not allow one when writing, where 3.x wrote thenull: a service's response fails like an unexpected exception (a500), 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
UseRoutingand 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 thehttp.routetag 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
CreateRequestandCreateHubConnectionchange 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 therawformat, as in$"items?{queryString:raw}", or pass the values as query string parameters. A value that is.or.., other than arawone, and a path that leads outside the base address, such as../x, or/xwhen 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 aparamsparameter namedqueryStringParams. Calls pass them as a collection expression:CreateRequest(HttpMethod.Get, "items", [("page", 2), ("filter", filter)]). GetDefaultQueryParamsand theDefaultUserIdPreconditionQueryNameandDefaultSessionCookieNameconstants 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 pathxgoes tohttps://example.com/api/x, where 3.x sent it tohttps://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
nullwhen 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
nullvalue where the contract does not allow one throws a JsonException. A response whose body isnullthrows 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
typeInfoResolverrather thanserializerContext, 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-Changeheader. 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
3xxresponse 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. ASet-Cookieheader 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 theconfigureOptionscallback of CreateHubConnection can turn back on. The session cookie then only goes to the host of the base address. - A
401with thesign-in-requirederror 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 thatOnConnectedAsyncthrows 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,429and503responses, 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 a500, and its message names the status, such as "The service returned an error (502 Bad Gateway).". RetryAfter is set from theRetry-Afterheader, 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.
VoidApiResponseis 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.