microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Apps/Routing/Router.cs
241lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Diagnostics; |
| 5 | using Microsoft.Extensions.Logging; |
| 6 | using Microsoft.Teams.Apps.Diagnostics; |
| 7 | using Microsoft.Teams.Apps.Handlers; |
| 8 | using Microsoft.Teams.Apps.Schema; |
| 9 | using Microsoft.Teams.Core.Diagnostics; |
| 10 | |
| 11 | namespace Microsoft.Teams.Apps.Routing; |
| 12 | |
| 13 | /// <summary> |
| 14 | /// Router for dispatching Teams activities to registered routes |
| 15 | /// </summary> |
| 16 | internal sealed class Router |
| 17 | { |
| 18 | private readonly List<RouteBase> _routes = []; |
| 19 | private readonly ILogger _logger; |
| 20 | |
| 21 | internal Router(ILogger logger) |
| 22 | { |
| 23 | _logger = logger; |
| 24 | } |
| 25 | |
| 26 | /// <summary> |
| 27 | /// Routes registered in the router. |
| 28 | /// </summary> |
| 29 | public IReadOnlyList<RouteBase> GetRoutes() => _routes.AsReadOnly(); |
| 30 | |
| 31 | /// <summary> |
| 32 | /// Determines whether any registered route matches the given activity. |
| 33 | /// </summary> |
| 34 | /// <param name="activity">The activity to check against registered routes.</param> |
| 35 | /// <returns><c>true</c> if at least one route matches; otherwise, <c>false</c>.</returns> |
| 36 | public bool IsMatch(TeamsActivity activity) |
| 37 | { |
| 38 | ArgumentNullException.ThrowIfNull(activity); |
| 39 | return _routes.Any(r => r.Matches(activity)); |
| 40 | } |
| 41 | |
| 42 | /// <summary> |
| 43 | /// Registers a route. Routes are checked and invoked in registration order. |
| 44 | /// For non-invoke activities all matching routes run sequentially. |
| 45 | /// For invoke activities — routes must be non-overlapping. |
| 46 | /// </summary> |
| 47 | /// <exception cref="InvalidOperationException"> |
| 48 | /// Thrown if a route with the same name is already registered, or if an invoke catch-all |
| 49 | /// is mixed with specific invoke handlers. |
| 50 | /// </exception> |
| 51 | public Router Register<TActivity>(Route<TActivity> route) where TActivity : TeamsActivity |
| 52 | { |
| 53 | if (_routes.Any(r => r.Name == route.Name)) |
| 54 | { |
| 55 | throw new InvalidOperationException($"A route with name '{route.Name}' is already registered."); |
| 56 | } |
| 57 | |
| 58 | string invokePrefix = TeamsActivityType.Invoke + "/"; |
| 59 | |
| 60 | if (route.Name == TeamsActivityType.Invoke && _routes.Any(r => r.Name.StartsWith(invokePrefix, StringComparison.Ordinal))) |
| 61 | { |
| 62 | throw new InvalidOperationException("Cannot register a catch-all invoke handler when specific invoke handlers are already registered. Use specific handlers or handle all invoke types inside OnInvoke."); |
| 63 | } |
| 64 | |
| 65 | if (route.Name.StartsWith(invokePrefix, StringComparison.Ordinal) && _routes.Any(r => r.Name == TeamsActivityType.Invoke)) |
| 66 | { |
| 67 | throw new InvalidOperationException($"Cannot register '{route.Name}' when a catch-all invoke handler is already registered. Remove OnInvoke or use specific handlers exclusively."); |
| 68 | } |
| 69 | _routes.Add(route); |
| 70 | _logger.LogDebug("Registered route '{Name}' for activity type '{ActivityType}'.", route.Name, typeof(TActivity).Name); |
| 71 | return this; |
| 72 | } |
| 73 | |
| 74 | /// <summary> |
| 75 | /// Dispatches the activity to all matching routes in registration order. |
| 76 | /// </summary> |
| 77 | public async Task DispatchAsync(Context<TeamsActivity> ctx, CancellationToken cancellationToken = default) |
| 78 | { |
| 79 | ArgumentNullException.ThrowIfNull(ctx); |
| 80 | |
| 81 | _logger.LogDebug("Routing activity of type '{Type}' against {RouteCount} registered routes.", ctx.Activity.Type, _routes.Count); |
| 82 | |
| 83 | List<RouteBase> matchingRoutes = []; |
| 84 | foreach (RouteBase route in _routes) |
| 85 | { |
| 86 | bool matched = route.Matches(ctx.Activity); |
| 87 | _logger.LogTrace("Route '{Name}' selector returned {Result} for activity of type '{Type}'.", route.Name, matched, ctx.Activity.Type); |
| 88 | if (matched) |
| 89 | { |
| 90 | matchingRoutes.Add(route); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | if (matchingRoutes.Count == 0 && _routes.Count > 0) |
| 95 | { |
| 96 | AppsTelemetry.HandlerUnmatched.Add(1, new KeyValuePair<string, object?>(AppsTelemetry.Tags.ActivityType, ctx.Activity.Type)); |
| 97 | _logger.LogWarning( |
| 98 | "No routes matched activity of type '{Type}'.", |
| 99 | ctx.Activity.Type |
| 100 | ); |
| 101 | return; |
| 102 | } |
| 103 | |
| 104 | _logger.LogDebug("Matched {MatchCount} route(s) for activity of type '{Type}'.", matchingRoutes.Count, ctx.Activity.Type); |
| 105 | |
| 106 | foreach (RouteBase route in matchingRoutes) |
| 107 | { |
| 108 | _logger.LogInformation("Dispatching '{Type}' activity to route '{Name}'.", ctx.Activity.Type, route.Name); |
| 109 | _logger.LogTrace("Dispatching activity to route '{Name}': {Activity}", route.Name, ctx.Activity.ToJson()); |
| 110 | |
| 111 | (string handlerType, string dispatch) = GetHandlerTags(route.Name); |
| 112 | TagList handlerTags = new() |
| 113 | { |
| 114 | { AppsTelemetry.Tags.HandlerType, handlerType }, |
| 115 | { AppsTelemetry.Tags.HandlerDispatch, dispatch }, |
| 116 | }; |
| 117 | |
| 118 | AppsTelemetry.HandlerDispatched.Add(1, handlerTags); |
| 119 | |
| 120 | using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal); |
| 121 | if (span is not null) |
| 122 | { |
| 123 | span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType); |
| 124 | span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch); |
| 125 | } |
| 126 | |
| 127 | long startTimestamp = Stopwatch.GetTimestamp(); |
| 128 | try |
| 129 | { |
| 130 | await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false); |
| 131 | } |
| 132 | catch (Exception ex) |
| 133 | { |
| 134 | AppsTelemetry.HandlerFailures.Add(1, handlerTags); |
| 135 | span.RecordException(ex); |
| 136 | throw; |
| 137 | } |
| 138 | finally |
| 139 | { |
| 140 | double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; |
| 141 | AppsTelemetry.HandlerDuration.Record(elapsedMs, handlerTags); |
| 142 | } |
| 143 | |
| 144 | _logger.LogDebug("Completed route '{Name}' for '{Type}' activity.", route.Name, ctx.Activity.Type); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// <summary> |
| 149 | /// Dispatches the specified activity context to the first matching route and returns the result of the invocation. |
| 150 | /// </summary> |
| 151 | /// <param name="ctx">The activity context to dispatch. Cannot be null.</param> |
| 152 | /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> |
| 153 | /// <returns>A task that represents the asynchronous operation. The task result contains a response object with the outcome |
| 154 | /// of the invocation.</returns> |
| 155 | public async Task<InvokeResponse> DispatchWithReturnAsync(Context<TeamsActivity> ctx, CancellationToken cancellationToken = default) |
| 156 | { |
| 157 | ArgumentNullException.ThrowIfNull(ctx); |
| 158 | |
| 159 | string? name = ctx.Activity is InvokeActivity inv ? inv.Name : null; |
| 160 | |
| 161 | _logger.LogDebug("Routing invoke activity with name '{Name}' against {RouteCount} registered routes.", name, _routes.Count); |
| 162 | |
| 163 | List<RouteBase> matchingRoutes = []; |
| 164 | foreach (RouteBase route in _routes) |
| 165 | { |
| 166 | bool matched = route.Matches(ctx.Activity); |
| 167 | _logger.LogTrace("Route '{RouteName}' selector returned {Result} for invoke '{Name}'.", route.Name, matched, name); |
| 168 | if (matched) |
| 169 | { |
| 170 | matchingRoutes.Add(route); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | if (matchingRoutes.Count == 0 && _routes.Count > 0) |
| 175 | { |
| 176 | TagList unmatchedTags = new() |
| 177 | { |
| 178 | { AppsTelemetry.Tags.ActivityType, ctx.Activity.Type }, |
| 179 | { AppsTelemetry.Tags.InvokeName, name ?? string.Empty }, |
| 180 | }; |
| 181 | AppsTelemetry.HandlerUnmatched.Add(1, unmatchedTags); |
| 182 | _logger.LogWarning("No routes matched invoke activity with name '{Name}'; returning 501.", name); |
| 183 | return new InvokeResponse(501); |
| 184 | } |
| 185 | |
| 186 | _logger.LogInformation("Dispatching invoke activity with name '{Name}' to route '{Route}'.", name, matchingRoutes[0].Name); |
| 187 | _logger.LogTrace("Dispatching invoke activity to route '{Route}': {Activity}", matchingRoutes[0].Name, ctx.Activity.ToJson()); |
| 188 | |
| 189 | (string handlerType, string dispatch) = GetHandlerTags(matchingRoutes[0].Name); |
| 190 | TagList handlerTags = new() |
| 191 | { |
| 192 | { AppsTelemetry.Tags.HandlerType, handlerType }, |
| 193 | { AppsTelemetry.Tags.HandlerDispatch, dispatch }, |
| 194 | }; |
| 195 | |
| 196 | AppsTelemetry.HandlerDispatched.Add(1, handlerTags); |
| 197 | |
| 198 | using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal); |
| 199 | if (span is not null) |
| 200 | { |
| 201 | span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType); |
| 202 | span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch); |
| 203 | } |
| 204 | |
| 205 | long startTimestamp = Stopwatch.GetTimestamp(); |
| 206 | InvokeResponse response; |
| 207 | try |
| 208 | { |
| 209 | response = await matchingRoutes[0].InvokeRouteWithReturn(ctx, cancellationToken).ConfigureAwait(false); |
| 210 | } |
| 211 | catch (Exception ex) |
| 212 | { |
| 213 | AppsTelemetry.HandlerFailures.Add(1, handlerTags); |
| 214 | span.RecordException(ex); |
| 215 | throw; |
| 216 | } |
| 217 | finally |
| 218 | { |
| 219 | double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; |
| 220 | AppsTelemetry.HandlerDuration.Record(elapsedMs, handlerTags); |
| 221 | } |
| 222 | |
| 223 | _logger.LogDebug("Completed invoke route '{Route}' for '{Name}' with status {Status}.", matchingRoutes[0].Name, name, response.Status); |
| 224 | |
| 225 | return response; |
| 226 | } |
| 227 | |
| 228 | private static (string handlerType, string dispatch) GetHandlerTags(string routeName) |
| 229 | { |
| 230 | const string invokePrefix = TeamsActivityType.Invoke + "/"; |
| 231 | if (string.Equals(routeName, TeamsActivityType.Invoke, StringComparison.Ordinal)) |
| 232 | { |
| 233 | return (routeName, "catchall"); |
| 234 | } |
| 235 | if (routeName.StartsWith(invokePrefix, StringComparison.Ordinal)) |
| 236 | { |
| 237 | return (routeName[invokePrefix.Length..], "invoke"); |
| 238 | } |
| 239 | return (routeName, "type"); |
| 240 | } |
| 241 | } |
| 242 | |