microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/docs/ApiClient-Design.md
249lines · modecode
| 1 | # ApiClient Design Document |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | The `ApiClient` class (`Microsoft.Teams.Apps.Api.Clients`) provides a hierarchical, Libraries-compatible API surface for Teams Bot operations. It organizes Bot Framework v3 REST API calls into sub-clients that delegate to the core SDK infrastructure rather than making raw HTTP calls. |
| 6 | |
| 7 | ## Architecture |
| 8 | |
| 9 | ``` |
| 10 | ApiClient (top-level facade) |
| 11 | ├── Bots → BotClient |
| 12 | │ └── SignIn → BotSignInClient [delegates to core UserTokenClient] |
| 13 | ├── Conversations → ConversationApiClient [delegates to core ConversationClient] |
| 14 | │ ├── Activities → ActivityClient |
| 15 | │ ├── Members → MemberClient |
| 16 | │ └── Reactions → ReactionClient [Experimental] |
| 17 | ├── Users → UserClient |
| 18 | │ └── Token → UserTokenApiClient [delegates to core UserTokenClient] |
| 19 | ├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/] |
| 20 | └── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/] |
| 21 | ``` |
| 22 | |
| 23 | ### HTTP strategies |
| 24 | |
| 25 | | Sub-client | Strategy | Why | |
| 26 | |---|---|---| |
| 27 | | Conversations (Activities, Members, Reactions) | Delegates to core `ConversationClient` | Reuses auth, logging, agents-channel handling, agentic identity support | |
| 28 | | Bots.SignIn, Users.Token | Delegates to core `UserTokenClient` | Reuses auth, logging, agentic identity; single source of truth for token API calls | |
| 29 | | Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints | |
| 30 | |
| 31 | ### Experimental APIs |
| 32 | |
| 33 | | Feature | Diagnostic ID | Notes | |
| 34 | |---|---|---| |
| 35 | | `ReactionClient` | `ExperimentalTeamsReactions` | Reactions endpoint assumed but not confirmed in Teams Bot Framework API | |
| 36 | | `ActivityClient.CreateTargetedAsync` / `UpdateTargetedAsync` / `DeleteTargetedAsync` | `ExperimentalTeamsTargeted` | Targeted (recipient-only visible) activities; not supported in team channel conversations | |
| 37 | |
| 38 | ## Construction & Scoping |
| 39 | |
| 40 | ### The serviceUrl problem |
| 41 | |
| 42 | The Bot Framework service URL is per-request (comes from `activity.ServiceUrl`), but `ApiClient` is per-application (DI singleton). The `ApiClient` solves this with a two-step pattern: |
| 43 | |
| 44 | 1. **DI registration** creates a base `ApiClient` without a serviceUrl |
| 45 | 2. **Per-request**, `ForServiceUrl(uri)` creates a lightweight scoped copy with all sub-clients bound |
| 46 | |
| 47 | ### DI-friendly constructor (no serviceUrl) |
| 48 | |
| 49 | ```csharp |
| 50 | // Registered automatically by AddTeamsBotApplication() |
| 51 | // The [ActivatorUtilitiesConstructor] attribute tells DI to prefer this constructor |
| 52 | [ActivatorUtilitiesConstructor] |
| 53 | public ApiClient(HttpClient httpClient, ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger? logger = null) |
| 54 | ``` |
| 55 | |
| 56 | `AddTeamsBotApplication()` calls `AddBotClient<ApiClient>(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` and `UserTokenClient` dependencies are resolved from DI automatically. |
| 57 | |
| 58 | **Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null!`. Only `Bots` and `Users` are available on the unscoped instance. Accessing `Conversations`, `Teams`, or `Meetings` directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance. |
| 59 | |
| 60 | ### Per-request scoping via Context.Api |
| 61 | |
| 62 | In activity handlers, use the `Context.Api` property which auto-scopes to the current activity's service URL: |
| 63 | |
| 64 | ```csharp |
| 65 | // In a handler — Context.Api is lazy-initialized via ForServiceUrl(Activity.ServiceUrl) |
| 66 | botApp.OnMessage(async (ctx, ct) => |
| 67 | { |
| 68 | var members = await ctx.Api.Conversations.Members.GetAsync(conversationId, ct); |
| 69 | var team = await ctx.Api.Teams.GetByIdAsync(teamId, ct); |
| 70 | }); |
| 71 | ``` |
| 72 | |
| 73 | **Do NOT use `ctx.TeamsBotApplication.Api.Conversations`** — that is the unscoped base client and will throw `NullReferenceException`. |
| 74 | |
| 75 | ### ForServiceUrl (explicit scoping) |
| 76 | |
| 77 | For code outside handlers (e.g., proactive messaging, compat layer): |
| 78 | |
| 79 | ```csharp |
| 80 | ApiClient scoped = baseApiClient.ForServiceUrl(activity.ServiceUrl); |
| 81 | await scoped.Conversations.Activities.CreateAsync(conversationId, activity); |
| 82 | ``` |
| 83 | |
| 84 | `ForServiceUrl` shares the underlying `BotHttpClient`, `ConversationClient`, and `UserTokenClient` — only the sub-client wrappers are new allocations. |
| 85 | |
| 86 | ### Constructors |
| 87 | |
| 88 | | Constructor | Use case | |
| 89 | |---|---| |
| 90 | | `ApiClient(HttpClient, ConversationClient, UserTokenClient, ILogger?)` | DI registration (marked `[ActivatorUtilitiesConstructor]`) | |
| 91 | | `ApiClient(Uri, HttpClient, ConversationClient, UserTokenClient, ILogger?)` | Fully initialized with known serviceUrl | |
| 92 | | `ApiClient(ApiClient)` | Copy constructor | |
| 93 | | Private: `ApiClient(BotHttpClient, ConversationClient, UserTokenClient, Uri)` | Used by `ForServiceUrl` — shares clients | |
| 94 | |
| 95 | ## Delegation Pattern |
| 96 | |
| 97 | The Apps-layer sub-clients delegate to core clients rather than duplicating HTTP logic: |
| 98 | |
| 99 | - **Conversation sub-clients** (`ActivityClient`, `MemberClient`, `ReactionClient`) → core `ConversationClient` |
| 100 | - **Token/SignIn sub-clients** (`UserTokenApiClient`, `BotSignInClient`) → core `UserTokenClient` |
| 101 | |
| 102 | This ensures: |
| 103 | |
| 104 | - Single source of truth for URL construction, auth, and error handling |
| 105 | - Agents-channel ID truncation logic is preserved |
| 106 | - Agentic identity support works transparently for all operations |
| 107 | - Custom headers and logging from core clients apply |
| 108 | |
| 109 | ### Parameter bridging |
| 110 | |
| 111 | The Libraries-style API takes `(conversationId, activity)` as separate parameters, while the core `ConversationClient` expects context embedded in the activity or passed as method parameters. The sub-clients bridge this: |
| 112 | |
| 113 | ``` |
| 114 | ActivityClient.CreateAsync(conversationId, activity) |
| 115 | → sets activity.ServiceUrl, activity.Conversation |
| 116 | → calls ConversationClient.SendActivityAsync(activity) |
| 117 | |
| 118 | MemberClient.GetAsync(conversationId) |
| 119 | → calls ConversationClient.GetConversationMembersAsync(conversationId, serviceUrl) |
| 120 | |
| 121 | ReactionClient.AddAsync(conversationId, activityId, reactionType) |
| 122 | → calls ConversationClient.AddReactionAsync(conversationId, activityId, reactionType, serviceUrl) |
| 123 | ``` |
| 124 | |
| 125 | ### Method mapping |
| 126 | |
| 127 | #### ActivityClient → ConversationClient |
| 128 | |
| 129 | | ActivityClient | ConversationClient | Notes | |
| 130 | |---|---|---| |
| 131 | | `CreateAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `ServiceUrl` and `Conversation` on activity | |
| 132 | | `UpdateAsync(conversationId, id, activity)` | `UpdateActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity | |
| 133 | | `ReplyAsync(conversationId, id, activity)` | `SendActivityAsync(activity)` | Sets `ReplyToId`, `ServiceUrl`, `Conversation` | |
| 134 | | `DeleteAsync(conversationId, id)` | `DeleteActivityAsync(conversationId, id, serviceUrl)` | | |
| 135 | | `CreateTargetedAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `Recipient.IsTargeted = true` [Experimental] | |
| 136 | | `UpdateTargetedAsync(conversationId, id, activity)` | `UpdateTargetedActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` [Experimental] | |
| 137 | | `DeleteTargetedAsync(conversationId, id)` | `DeleteTargetedActivityAsync(conversationId, id, serviceUrl)` | [Experimental] | |
| 138 | |
| 139 | #### MemberClient → ConversationClient |
| 140 | |
| 141 | | MemberClient | ConversationClient | |
| 142 | |---|---| |
| 143 | | `GetAsync(conversationId)` | `GetConversationMembersAsync(conversationId, serviceUrl)` | |
| 144 | | `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync<ConversationAccount>(conversationId, memberId, serviceUrl)` | |
| 145 | | `GetByIdAsync<T>(conversationId, memberId)` | `GetConversationMemberAsync<T>(conversationId, memberId, serviceUrl)` | |
| 146 | | `DeleteAsync(conversationId, memberId)` | `DeleteConversationMemberAsync(conversationId, memberId, serviceUrl)` | |
| 147 | |
| 148 | #### ReactionClient → ConversationClient [Experimental] |
| 149 | |
| 150 | | ReactionClient | ConversationClient | |
| 151 | |---|---| |
| 152 | | `AddAsync(conversationId, activityId, reactionType)` | `AddReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | |
| 153 | | `DeleteAsync(conversationId, activityId, reactionType)` | `DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | |
| 154 | |
| 155 | #### ConversationApiClient → ConversationClient |
| 156 | |
| 157 | | ConversationApiClient | ConversationClient | |
| 158 | |---|---| |
| 159 | | `CreateAsync(parameters)` | `CreateConversationAsync(parameters, serviceUrl)` | |
| 160 | |
| 161 | #### TeamClient (direct HTTP) |
| 162 | |
| 163 | | TeamClient | Endpoint | |
| 164 | |---|---| |
| 165 | | `GetByIdAsync(id)` | `GET {serviceUrl}/v3/teams/{id}` | |
| 166 | | `GetConversationsAsync(id)` | `GET {serviceUrl}/v3/teams/{id}/conversations` | |
| 167 | |
| 168 | #### MeetingClient (direct HTTP) |
| 169 | |
| 170 | | MeetingClient | Endpoint | |
| 171 | |---|---| |
| 172 | | `GetByIdAsync(id)` | `GET {serviceUrl}/v1/meetings/{id}` | |
| 173 | | `GetParticipantAsync(meetingId, id, tenantId)` | `GET {serviceUrl}/v1/meetings/{meetingId}/participants/{id}?tenantId={tenantId}` | |
| 174 | |
| 175 | #### BotSignInClient → UserTokenClient |
| 176 | |
| 177 | | BotSignInClient | UserTokenClient | |
| 178 | |---|---| |
| 179 | | `GetUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | |
| 180 | | `GetResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | |
| 181 | |
| 182 | #### UserTokenApiClient → UserTokenClient |
| 183 | |
| 184 | | UserTokenApiClient | UserTokenClient | Notes | |
| 185 | |---|---|---| |
| 186 | | `GetAsync(userId, connectionName, channelId, code?)` | `GetTokenAsync(userId, connectionName, channelId, code?)` | | |
| 187 | | `GetAadAsync(userId, connectionName, channelId, resourceUrls?)` | `GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?)` | `IList<string>?` → `string[]?` | |
| 188 | | `GetStatusAsync(userId, channelId, includeFilter?)` | `GetTokenStatusAsync(userId, channelId, include?)` | Returns `GetTokenStatusResult[]` as `IList<>?` | |
| 189 | | `SignOutAsync(userId, connectionName, channelId)` | `SignOutUserAsync(userId, connectionName?, channelId?)` | | |
| 190 | | `ExchangeAsync(userId, connectionName, channelId, token)` | `ExchangeTokenAsync(userId, connectionName, channelId, token?)` | | |
| 191 | |
| 192 | ## File Layout |
| 193 | |
| 194 | ``` |
| 195 | core/src/Microsoft.Teams.Apps/Api/Clients/ |
| 196 | ├── ApiClient.cs Top-level facade, DI entry point, ForServiceUrl factory |
| 197 | ├── ConversationApiClient.cs Conversation facade → delegates to core ConversationClient |
| 198 | ├── ActivityClient.cs Activity CRUD + targeted → delegates to core ConversationClient |
| 199 | ├── MemberClient.cs Member operations → delegates to core ConversationClient |
| 200 | ├── ReactionClient.cs Reaction operations → delegates to core ConversationClient [Experimental] |
| 201 | ├── TeamClient.cs Team info → BotHttpClient (v3/teams/) |
| 202 | ├── MeetingClient.cs Meeting info → BotHttpClient (v1/meetings/) + models |
| 203 | ├── BotClient.cs Bot facade (groups SignIn) |
| 204 | ├── BotSignInClient.cs Sign-in URLs → delegates to core UserTokenClient |
| 205 | ├── BotTokenClient.cs Static scope constants |
| 206 | ├── UserClient.cs User facade (groups Token) |
| 207 | └── UserTokenApiClient.cs User token ops → delegates to core UserTokenClient |
| 208 | ``` |
| 209 | |
| 210 | ## Integration with Context and Handlers |
| 211 | |
| 212 | The `Context<TActivity>` class exposes a lazy `Api` property: |
| 213 | |
| 214 | ```csharp |
| 215 | public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl(Activity.ServiceUrl); |
| 216 | ``` |
| 217 | |
| 218 | This is the primary way handlers should access the API clients. It ensures the scoped `ApiClient` is created once per request and reused across multiple calls within the same handler. |
| 219 | |
| 220 | ## Integration with TeamsApiClient |
| 221 | |
| 222 | `TeamsApiClient` retrieves clients from `TurnState`: |
| 223 | |
| 224 | - **`ApiClient`** (from `TurnState.Get<ApiClient>()`) for Teams/Meetings operations: |
| 225 | - `client.Teams.GetByIdAsync(teamId)` — team details |
| 226 | - `client.Teams.GetConversationsAsync(teamId)` — channel list |
| 227 | - `client.Meetings.GetParticipantAsync(meetingId, participantId, tenantId)` — meeting participant |
| 228 | |
| 229 | - **`ConversationClient`** (from `CompatConnectorClient` in `TurnState.Get<IConnectorClient>()`) for member operations: |
| 230 | - `GetConversationMemberAsync<TeamsConversationAccount>(...)` — single member |
| 231 | - `GetConversationMembersAsync(...)` — all members |
| 232 | - `GetConversationPagedMembersAsync(...)` — paged members |
| 233 | |
| 234 | **Note on TeamsBotFrameworkHttpAdapter scoping:** The `TeamsBotFrameworkHttpAdapter` currently stores the unscoped `TeamsApiClient` in `TurnState` (line 59). This works because `TeamsApiClient` uses the `ApiClient` sub-clients which are scoped. However, `TeamsBotFrameworkHttpAdapter` should ideally scope the `ApiClient` before storing: |
| 235 | |
| 236 | ```csharp |
| 237 | // Current (unscoped — Teams/Meetings sub-clients are null): |
| 238 | turnContext.TurnState.Add<ApiClient>(_teamsBotApplication.TeamsApiClient); |
| 239 | |
| 240 | // Should be (scoped): |
| 241 | ApiClient scopedClient = _teamsBotApplication.TeamsApiClient.ForServiceUrl(new Uri(activity.ServiceUrl)); |
| 242 | turnContext.TurnState.Add<ApiClient>(scopedClient); |
| 243 | ``` |
| 244 | |
| 245 | ## Future Work |
| 246 | |
| 247 | - **BatchClient**: Batch messaging operations (`SendMessageToListOfUsersAsync`, etc.) need a new sub-client on `ApiClient` using `BotHttpClient` for the `v3/batch/conversation/` endpoints. |
| 248 | - **MeetingClient.SendMeetingNotificationAsync**: Meeting notification support needs to be added along with notification model types. |
| 249 | - **TeamsBotFrameworkHttpAdapter scoping**: Fix `TeamsBotFrameworkHttpAdapter` to call `ForServiceUrl` before storing `ApiClient` in `TurnState`. |
| 250 | |