From 598d50d7db40d74ec95769a918eb2731e8b41e58 Mon Sep 17 00:00:00 2001 From: DX-Bandwidth Date: Tue, 23 Jun 2026 15:05:22 +0000 Subject: [PATCH 1/5] Generate SDK with OpenAPI Generator Version --- .openapi-generator/FILES | 2 + Bandwidth.Standard.sln | 10 +- README.md | 1 + api/openapi.yaml | 73 ++++++++- bandwidth.yml | 70 +++++++++ docs/RecordingTranscriptionClip.md | 14 ++ docs/RecordingTranscriptions.md | 1 + docs/Transcription.md | 1 + src/Bandwidth.Standard/Model/RbmActionBase.cs | 2 +- .../Model/RbmSuggestionResponse.cs | 2 +- .../Model/RecordingTranscriptionClip.cs | 141 ++++++++++++++++++ .../Model/RecordingTranscriptions.cs | 12 +- src/Bandwidth.Standard/Model/Transcription.cs | 13 +- 13 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 docs/RecordingTranscriptionClip.md create mode 100644 src/Bandwidth.Standard/Model/RecordingTranscriptionClip.cs diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 6043157a..2b798a2e 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -163,6 +163,7 @@ docs/RbmWebViewEnum.md docs/RecordingAvailableCallback.md docs/RecordingCompleteCallback.md docs/RecordingStateEnum.md +docs/RecordingTranscriptionClip.md docs/RecordingTranscriptionMetadata.md docs/RecordingTranscriptions.md docs/RecordingsApi.md @@ -399,6 +400,7 @@ src/Bandwidth.Standard/Model/RbmWebViewEnum.cs src/Bandwidth.Standard/Model/RecordingAvailableCallback.cs src/Bandwidth.Standard/Model/RecordingCompleteCallback.cs src/Bandwidth.Standard/Model/RecordingStateEnum.cs +src/Bandwidth.Standard/Model/RecordingTranscriptionClip.cs src/Bandwidth.Standard/Model/RecordingTranscriptionMetadata.cs src/Bandwidth.Standard/Model/RecordingTranscriptions.cs src/Bandwidth.Standard/Model/RedirectCallback.cs diff --git a/Bandwidth.Standard.sln b/Bandwidth.Standard.sln index 49ff43c0..28da61d0 100644 --- a/Bandwidth.Standard.sln +++ b/Bandwidth.Standard.sln @@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25420.1 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bandwidth.Standard", "src\Bandwidth.Standard\Bandwidth.Standard.csproj", "{612BC740-F2E6-4466-87AE-403F47F2323F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bandwidth.Standard", "src\Bandwidth.Standard\Bandwidth.Standard.csproj", "{B644B02A-2DAC-42EA-A361-EB14EAD13209}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bandwidth.Standard.Test", "src\Bandwidth.Standard.Test\Bandwidth.Standard.Test.csproj", "{19F1DEBC-DE5E-4517-8062-F000CD499087}" EndProject @@ -12,10 +12,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {612BC740-F2E6-4466-87AE-403F47F2323F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {612BC740-F2E6-4466-87AE-403F47F2323F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {612BC740-F2E6-4466-87AE-403F47F2323F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {612BC740-F2E6-4466-87AE-403F47F2323F}.Release|Any CPU.Build.0 = Release|Any CPU + {B644B02A-2DAC-42EA-A361-EB14EAD13209}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B644B02A-2DAC-42EA-A361-EB14EAD13209}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B644B02A-2DAC-42EA-A361-EB14EAD13209}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B644B02A-2DAC-42EA-A361-EB14EAD13209}.Release|Any CPU.Build.0 = Release|Any CPU {19F1DEBC-DE5E-4517-8062-F000CD499087}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {19F1DEBC-DE5E-4517-8062-F000CD499087}.Debug|Any CPU.Build.0 = Debug|Any CPU {19F1DEBC-DE5E-4517-8062-F000CD499087}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/README.md b/README.md index 70cbdf89..63f5bbd9 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,7 @@ Class | Method | HTTP request | Description - [Model.RecordingAvailableCallback](docs/RecordingAvailableCallback.md) - [Model.RecordingCompleteCallback](docs/RecordingCompleteCallback.md) - [Model.RecordingStateEnum](docs/RecordingStateEnum.md) + - [Model.RecordingTranscriptionClip](docs/RecordingTranscriptionClip.md) - [Model.RecordingTranscriptionMetadata](docs/RecordingTranscriptionMetadata.md) - [Model.RecordingTranscriptions](docs/RecordingTranscriptions.md) - [Model.RedirectCallback](docs/RedirectCallback.md) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8d7e9ff8..30acc9ae 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11949,15 +11949,73 @@ components: recordingTranscriptions: example: transcripts: - - confidence: 0.9 - text: "Nice talking to you, friend!" - - confidence: 0.9 - text: "Nice talking to you, friend!" + - speaker: 0 + text: "Hi, is Thursday at two still good for you? Perfect, talk soon." + confidence: 0.96 + - speaker: 1 + text: "Yes, that works great. See you then! Sounds good, bye!" + confidence: 0.97 + clips: + - speaker: 0 + text: "Hi, is Thursday at two still good for you?" + confidence: 0.97 + startTimeSeconds: 0.4 + endTimeSeconds: 3.1 + - speaker: 1 + text: "Yes, that works great. See you then!" + confidence: 0.95 + startTimeSeconds: 3.8 + endTimeSeconds: 6.2 + - speaker: 0 + text: "Perfect, talk soon." + confidence: 0.94 + startTimeSeconds: 6.9 + endTimeSeconds: 8.1 + - speaker: 1 + text: "Sounds good, bye!" + confidence: 0.98 + startTimeSeconds: 8.5 + endTimeSeconds: 9.7 properties: transcripts: items: $ref: '#/components/schemas/transcription' type: array + clips: + description: "A list of individual speech clips with speaker, timing, and\ + \ confidence information." + items: + $ref: '#/components/schemas/recordingTranscriptionClip' + type: array + type: object + recordingTranscriptionClip: + properties: + speaker: + description: Zero-based index identifying the speaker. + example: 0 + type: integer + text: + description: The transcribed text of this clip. + example: "Hi there, thanks for calling!" + type: string + confidence: + description: How confident the transcription engine was in transcribing + this clip (from `0.0` to `1.0`). + example: 0.85 + format: double + maximum: 1 + minimum: 0 + type: number + startTimeSeconds: + description: "The start time of this clip within the recording, in seconds." + example: 2.3 + format: double + type: number + endTimeSeconds: + description: "The end time of this clip within the recording, in seconds." + example: 3.1 + format: double + type: number type: object callTranscriptionMetadataList: example: @@ -14048,10 +14106,11 @@ components: type: string type: object transcription: - example: - confidence: 0.9 - text: "Nice talking to you, friend!" properties: + speaker: + description: Zero-based index identifying the speaker. + example: 0 + type: integer text: description: The transcribed text example: "Nice talking to you, friend!" diff --git a/bandwidth.yml b/bandwidth.yml index a380e26d..fd39c5cb 100644 --- a/bandwidth.yml +++ b/bandwidth.yml @@ -4354,6 +4354,72 @@ components: type: array items: $ref: '#/components/schemas/transcription' + clips: + type: array + description: >- + A list of individual speech clips with speaker, timing, and + confidence information. + items: + $ref: '#/components/schemas/recordingTranscriptionClip' + example: + transcripts: + - speaker: 0 + text: Hi, is Thursday at two still good for you? Perfect, talk soon. + confidence: 0.96 + - speaker: 1 + text: Yes, that works great. See you then! Sounds good, bye! + confidence: 0.97 + clips: + - speaker: 0 + text: Hi, is Thursday at two still good for you? + confidence: 0.97 + startTimeSeconds: 0.4 + endTimeSeconds: 3.1 + - speaker: 1 + text: Yes, that works great. See you then! + confidence: 0.95 + startTimeSeconds: 3.8 + endTimeSeconds: 6.2 + - speaker: 0 + text: Perfect, talk soon. + confidence: 0.94 + startTimeSeconds: 6.9 + endTimeSeconds: 8.1 + - speaker: 1 + text: Sounds good, bye! + confidence: 0.98 + startTimeSeconds: 8.5 + endTimeSeconds: 9.7 + recordingTranscriptionClip: + type: object + properties: + speaker: + type: integer + description: Zero-based index identifying the speaker. + example: 0 + text: + type: string + description: The transcribed text of this clip. + example: Hi there, thanks for calling! + confidence: + type: number + format: double + minimum: 0 + maximum: 1 + description: >- + How confident the transcription engine was in transcribing this clip + (from `0.0` to `1.0`). + example: 0.85 + startTimeSeconds: + type: number + format: double + description: The start time of this clip within the recording, in seconds. + example: 2.3 + endTimeSeconds: + type: number + format: double + description: The end time of this clip within the recording, in seconds. + example: 3.1 callTranscriptionMetadataList: type: array items: @@ -5480,6 +5546,10 @@ components: transcription: type: object properties: + speaker: + type: integer + description: Zero-based index identifying the speaker. + example: 0 text: type: string description: The transcribed text diff --git a/docs/RecordingTranscriptionClip.md b/docs/RecordingTranscriptionClip.md new file mode 100644 index 00000000..70dc7cae --- /dev/null +++ b/docs/RecordingTranscriptionClip.md @@ -0,0 +1,14 @@ +# Bandwidth.Standard.Model.RecordingTranscriptionClip + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Speaker** | **int** | Zero-based index identifying the speaker. | [optional] +**Text** | **string** | The transcribed text of this clip. | [optional] +**Confidence** | **double** | How confident the transcription engine was in transcribing this clip (from `0.0` to `1.0`). | [optional] +**StartTimeSeconds** | **double** | The start time of this clip within the recording, in seconds. | [optional] +**EndTimeSeconds** | **double** | The end time of this clip within the recording, in seconds. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/docs/RecordingTranscriptions.md b/docs/RecordingTranscriptions.md index 23a4c666..a0bf0c0f 100644 --- a/docs/RecordingTranscriptions.md +++ b/docs/RecordingTranscriptions.md @@ -5,6 +5,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **Transcripts** | [**List<Transcription>**](Transcription.md) | | [optional] +**Clips** | [**List<RecordingTranscriptionClip>**](RecordingTranscriptionClip.md) | A list of individual speech clips with speaker, timing, and confidence information. | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/docs/Transcription.md b/docs/Transcription.md index 47c3445f..c277e196 100644 --- a/docs/Transcription.md +++ b/docs/Transcription.md @@ -4,6 +4,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**Speaker** | **int** | Zero-based index identifying the speaker. | [optional] **Text** | **string** | The transcribed text | [optional] **Confidence** | **double** | The confidence on the recognized content, ranging from `0.0` to `1.0` with `1.0` being the highest confidence. | [optional] diff --git a/src/Bandwidth.Standard/Model/RbmActionBase.cs b/src/Bandwidth.Standard/Model/RbmActionBase.cs index 9f3005e4..c6222673 100644 --- a/src/Bandwidth.Standard/Model/RbmActionBase.cs +++ b/src/Bandwidth.Standard/Model/RbmActionBase.cs @@ -78,7 +78,7 @@ protected RbmActionBase() { } /// Base64 payload the customer receives when the reply is clicked. /// /// Base64 payload the customer receives when the reply is clicked. - /// [B@2b76ecd5 + /// [B@2041c9a3 [DataMember(Name = "postbackData", IsRequired = true, EmitDefaultValue = true)] public byte[] PostbackData { get; set; } diff --git a/src/Bandwidth.Standard/Model/RbmSuggestionResponse.cs b/src/Bandwidth.Standard/Model/RbmSuggestionResponse.cs index 5ea253c5..b443fb9a 100644 --- a/src/Bandwidth.Standard/Model/RbmSuggestionResponse.cs +++ b/src/Bandwidth.Standard/Model/RbmSuggestionResponse.cs @@ -57,7 +57,7 @@ public partial class RbmSuggestionResponse : IValidatableObject /// Base64 payload the customer receives when the reply is clicked. /// /// Base64 payload the customer receives when the reply is clicked. - /// [B@2b76ecd5 + /// [B@2041c9a3 [DataMember(Name = "postbackData", EmitDefaultValue = false)] public byte[] PostbackData { get; set; } diff --git a/src/Bandwidth.Standard/Model/RecordingTranscriptionClip.cs b/src/Bandwidth.Standard/Model/RecordingTranscriptionClip.cs new file mode 100644 index 00000000..21b49b7b --- /dev/null +++ b/src/Bandwidth.Standard/Model/RecordingTranscriptionClip.cs @@ -0,0 +1,141 @@ +/* + * Bandwidth + * + * Bandwidth's Communication APIs + * + * The version of the OpenAPI document: 1.0.0 + * Contact: letstalk@bandwidth.com + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using OpenAPIDateConverter = Bandwidth.Standard.Client.OpenAPIDateConverter; + +namespace Bandwidth.Standard.Model +{ + /// + /// RecordingTranscriptionClip + /// + [DataContract(Name = "recordingTranscriptionClip")] + public partial class RecordingTranscriptionClip : IValidatableObject + { + /// + /// Initializes a new instance of the class. + /// + /// Zero-based index identifying the speaker.. + /// The transcribed text of this clip.. + /// How confident the transcription engine was in transcribing this clip (from `0.0` to `1.0`).. + /// The start time of this clip within the recording, in seconds.. + /// The end time of this clip within the recording, in seconds.. + public RecordingTranscriptionClip(int speaker = default(int), string text = default(string), double confidence = default(double), double startTimeSeconds = default(double), double endTimeSeconds = default(double)) + { + this.Speaker = speaker; + this.Text = text; + this.Confidence = confidence; + this.StartTimeSeconds = startTimeSeconds; + this.EndTimeSeconds = endTimeSeconds; + } + + /// + /// Zero-based index identifying the speaker. + /// + /// Zero-based index identifying the speaker. + /// 0 + [DataMember(Name = "speaker", EmitDefaultValue = false)] + public int Speaker { get; set; } + + /// + /// The transcribed text of this clip. + /// + /// The transcribed text of this clip. + /// Hi there, thanks for calling! + [DataMember(Name = "text", EmitDefaultValue = false)] + public string Text { get; set; } + + /// + /// How confident the transcription engine was in transcribing this clip (from `0.0` to `1.0`). + /// + /// How confident the transcription engine was in transcribing this clip (from `0.0` to `1.0`). + /// 0.85 + [DataMember(Name = "confidence", EmitDefaultValue = false)] + public double Confidence { get; set; } + + /// + /// The start time of this clip within the recording, in seconds. + /// + /// The start time of this clip within the recording, in seconds. + /// 2.3 + [DataMember(Name = "startTimeSeconds", EmitDefaultValue = false)] + public double StartTimeSeconds { get; set; } + + /// + /// The end time of this clip within the recording, in seconds. + /// + /// The end time of this clip within the recording, in seconds. + /// 3.1 + [DataMember(Name = "endTimeSeconds", EmitDefaultValue = false)] + public double EndTimeSeconds { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class RecordingTranscriptionClip {\n"); + sb.Append(" Speaker: ").Append(Speaker).Append("\n"); + sb.Append(" Text: ").Append(Text).Append("\n"); + sb.Append(" Confidence: ").Append(Confidence).Append("\n"); + sb.Append(" StartTimeSeconds: ").Append(StartTimeSeconds).Append("\n"); + sb.Append(" EndTimeSeconds: ").Append(EndTimeSeconds).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + // Confidence (double) maximum + if (this.Confidence > (double)1) + { + yield return new ValidationResult("Invalid value for Confidence, must be a value less than or equal to 1.", new [] { "Confidence" }); + } + + // Confidence (double) minimum + if (this.Confidence < (double)0) + { + yield return new ValidationResult("Invalid value for Confidence, must be a value greater than or equal to 0.", new [] { "Confidence" }); + } + + yield break; + } + } + +} diff --git a/src/Bandwidth.Standard/Model/RecordingTranscriptions.cs b/src/Bandwidth.Standard/Model/RecordingTranscriptions.cs index e54c76a5..229baea8 100644 --- a/src/Bandwidth.Standard/Model/RecordingTranscriptions.cs +++ b/src/Bandwidth.Standard/Model/RecordingTranscriptions.cs @@ -36,9 +36,11 @@ public partial class RecordingTranscriptions : IValidatableObject /// Initializes a new instance of the class. /// /// transcripts. - public RecordingTranscriptions(List transcripts = default(List)) + /// A list of individual speech clips with speaker, timing, and confidence information.. + public RecordingTranscriptions(List transcripts = default(List), List clips = default(List)) { this.Transcripts = transcripts; + this.Clips = clips; } /// @@ -47,6 +49,13 @@ public partial class RecordingTranscriptions : IValidatableObject [DataMember(Name = "transcripts", EmitDefaultValue = false)] public List Transcripts { get; set; } + /// + /// A list of individual speech clips with speaker, timing, and confidence information. + /// + /// A list of individual speech clips with speaker, timing, and confidence information. + [DataMember(Name = "clips", EmitDefaultValue = false)] + public List Clips { get; set; } + /// /// Returns the string presentation of the object /// @@ -56,6 +65,7 @@ public override string ToString() StringBuilder sb = new StringBuilder(); sb.Append("class RecordingTranscriptions {\n"); sb.Append(" Transcripts: ").Append(Transcripts).Append("\n"); + sb.Append(" Clips: ").Append(Clips).Append("\n"); sb.Append("}\n"); return sb.ToString(); } diff --git a/src/Bandwidth.Standard/Model/Transcription.cs b/src/Bandwidth.Standard/Model/Transcription.cs index cbacda4e..66067923 100644 --- a/src/Bandwidth.Standard/Model/Transcription.cs +++ b/src/Bandwidth.Standard/Model/Transcription.cs @@ -35,14 +35,24 @@ public partial class Transcription : IValidatableObject /// /// Initializes a new instance of the class. /// + /// Zero-based index identifying the speaker.. /// The transcribed text. /// The confidence on the recognized content, ranging from `0.0` to `1.0` with `1.0` being the highest confidence.. - public Transcription(string text = default(string), double confidence = default(double)) + public Transcription(int speaker = default(int), string text = default(string), double confidence = default(double)) { + this.Speaker = speaker; this.Text = text; this.Confidence = confidence; } + /// + /// Zero-based index identifying the speaker. + /// + /// Zero-based index identifying the speaker. + /// 0 + [DataMember(Name = "speaker", EmitDefaultValue = false)] + public int Speaker { get; set; } + /// /// The transcribed text /// @@ -67,6 +77,7 @@ public override string ToString() { StringBuilder sb = new StringBuilder(); sb.Append("class Transcription {\n"); + sb.Append(" Speaker: ").Append(Speaker).Append("\n"); sb.Append(" Text: ").Append(Text).Append("\n"); sb.Append(" Confidence: ").Append(Confidence).Append("\n"); sb.Append("}\n"); From dd83fb47a25e4289f6ea8286a6f478535d8bfc26 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Thu, 25 Jun 2026 13:20:02 -0400 Subject: [PATCH 2/5] potential oauth fixes --- .../auth/OAuthAuthenticator.mustache | 59 +++++++++++++++++-- custom_templates/auth/TokenResponse.mustache | 16 +++++ .../Client/Auth/OAuthAuthenticator.cs | 59 +++++++++++++++++-- .../Client/Auth/TokenResponse.cs | 2 + 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 custom_templates/auth/TokenResponse.mustache diff --git a/custom_templates/auth/OAuthAuthenticator.mustache b/custom_templates/auth/OAuthAuthenticator.mustache index 4faec1e0..f726c092 100644 --- a/custom_templates/auth/OAuthAuthenticator.mustache +++ b/custom_templates/auth/OAuthAuthenticator.mustache @@ -1,6 +1,7 @@ {{>partial_header}} using System; +using System.Collections.Concurrent; using System.Threading.Tasks; using Newtonsoft.Json; using RestSharp; @@ -20,6 +21,25 @@ namespace {{packageName}}.Client.Auth readonly JsonSerializerSettings _serializerSettings; readonly IReadableConfiguration _configuration; + /// + /// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires. + /// + const int TokenExpiryBufferSeconds = 60; + + /// + /// Process-wide token cache, keyed by token URL + client id. A new OAuthAuthenticator is created per + /// request by the ApiClient, so the cache must be static for tokens to be reused across requests. + /// + static readonly ConcurrentDictionary _tokenCache = new ConcurrentDictionary(); + + sealed class CachedToken + { + public string Token { get; set; } + public DateTime ExpiresAtUtc { get; set; } + } + + string CacheKey => $"{_tokenUrl}|{_clientId}"; + /// /// Initialize the OAuth2 Authenticator /// @@ -63,10 +83,24 @@ namespace {{packageName}}.Client.Auth /// An authentication parameter. protected override async ValueTask GetAuthenticationParameter(string accessToken) { - var token = string.IsNullOrEmpty(Token) ? await GetToken().ConfigureAwait(false) : Token; + var token = await GetCachedOrFetchToken().ConfigureAwait(false); return new HeaderParameter(KnownHeaders.Authorization, token); } + /// + /// Returns a cached, unexpired token when one is available; otherwise fetches a new one. + /// + /// An authentication token. + async Task GetCachedOrFetchToken() + { + if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow) + { + return cached.Token; + } + + return await GetToken().ConfigureAwait(false); + } + /// /// Gets the token from the OAuth2 server. /// @@ -81,17 +115,34 @@ namespace {{packageName}}.Client.Auth .AddHeader("Authorization", $"Basic {credentials}") .AddParameter("grant_type", _grantType, ParameterType.GetOrPost); var response = await client.PostAsync(request).ConfigureAwait(false); - + // RFC6749 - token_type is case insensitive. // RFC6750 - In Authorization header Bearer should be capitalized. // Fix the capitalization irrespective of token_type casing. + string token; switch (response.TokenType?.ToLower()) { case "bearer": - return $"Bearer {response.AccessToken}"; + token = $"Bearer {response.AccessToken}"; + break; default: - return $"{response.TokenType} {response.AccessToken}"; + token = $"{response.TokenType} {response.AccessToken}"; + break; + } + + // Only cache when the server tells us how long the token is valid. Caching without a known + // expiry would risk serving a stale token indefinitely; when expires_in is absent we fall back + // to fetching per request (always valid, just not cached). + if (response.ExpiresIn > TokenExpiryBufferSeconds) + { + _tokenCache[CacheKey] = new CachedToken + { + Token = token, + ExpiresAtUtc = DateTime.UtcNow.AddSeconds(response.ExpiresIn - TokenExpiryBufferSeconds) + }; } + + return token; } } } diff --git a/custom_templates/auth/TokenResponse.mustache b/custom_templates/auth/TokenResponse.mustache new file mode 100644 index 00000000..f8b617e5 --- /dev/null +++ b/custom_templates/auth/TokenResponse.mustache @@ -0,0 +1,16 @@ +{{>partial_header}} + +using Newtonsoft.Json; + +namespace {{packageName}}.Client.Auth +{ + class TokenResponse + { + [JsonProperty("token_type")] + public string TokenType { get; set; } + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + } +} diff --git a/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs b/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs index 4217b129..9c9da17c 100644 --- a/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs +++ b/src/Bandwidth.Standard/Client/Auth/OAuthAuthenticator.cs @@ -10,6 +10,7 @@ using System; +using System.Collections.Concurrent; using System.Threading.Tasks; using Newtonsoft.Json; using RestSharp; @@ -29,6 +30,25 @@ public class OAuthAuthenticator : AuthenticatorBase readonly JsonSerializerSettings _serializerSettings; readonly IReadableConfiguration _configuration; + /// + /// Refresh slightly before the server-stated expiry so a token is never used in-flight as it expires. + /// + const int TokenExpiryBufferSeconds = 60; + + /// + /// Process-wide token cache, keyed by token URL + client id. A new OAuthAuthenticator is created per + /// request by the ApiClient, so the cache must be static for tokens to be reused across requests. + /// + static readonly ConcurrentDictionary _tokenCache = new ConcurrentDictionary(); + + sealed class CachedToken + { + public string Token { get; set; } + public DateTime ExpiresAtUtc { get; set; } + } + + string CacheKey => $"{_tokenUrl}|{_clientId}"; + /// /// Initialize the OAuth2 Authenticator /// @@ -72,10 +92,24 @@ public OAuthAuthenticator( /// An authentication parameter. protected override async ValueTask GetAuthenticationParameter(string accessToken) { - var token = string.IsNullOrEmpty(Token) ? await GetToken().ConfigureAwait(false) : Token; + var token = await GetCachedOrFetchToken().ConfigureAwait(false); return new HeaderParameter(KnownHeaders.Authorization, token); } + /// + /// Returns a cached, unexpired token when one is available; otherwise fetches a new one. + /// + /// An authentication token. + async Task GetCachedOrFetchToken() + { + if (_tokenCache.TryGetValue(CacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow) + { + return cached.Token; + } + + return await GetToken().ConfigureAwait(false); + } + /// /// Gets the token from the OAuth2 server. /// @@ -90,17 +124,34 @@ async Task GetToken() .AddHeader("Authorization", $"Basic {credentials}") .AddParameter("grant_type", _grantType, ParameterType.GetOrPost); var response = await client.PostAsync(request).ConfigureAwait(false); - + // RFC6749 - token_type is case insensitive. // RFC6750 - In Authorization header Bearer should be capitalized. // Fix the capitalization irrespective of token_type casing. + string token; switch (response.TokenType?.ToLower()) { case "bearer": - return $"Bearer {response.AccessToken}"; + token = $"Bearer {response.AccessToken}"; + break; default: - return $"{response.TokenType} {response.AccessToken}"; + token = $"{response.TokenType} {response.AccessToken}"; + break; + } + + // Only cache when the server tells us how long the token is valid. Caching without a known + // expiry would risk serving a stale token indefinitely; when expires_in is absent we fall back + // to fetching per request (always valid, just not cached). + if (response.ExpiresIn > TokenExpiryBufferSeconds) + { + _tokenCache[CacheKey] = new CachedToken + { + Token = token, + ExpiresAtUtc = DateTime.UtcNow.AddSeconds(response.ExpiresIn - TokenExpiryBufferSeconds) + }; } + + return token; } } } diff --git a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs index 56e84de6..8707570e 100644 --- a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs +++ b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs @@ -19,5 +19,7 @@ class TokenResponse public string TokenType { get; set; } [JsonProperty("access_token")] public string AccessToken { get; set; } + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } } } \ No newline at end of file From 0d8b60609329e207e5ccfdaf51ad11c8b53193c5 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Thu, 25 Jun 2026 14:40:46 -0400 Subject: [PATCH 3/5] ignore op server --- custom_templates/Configuration.mustache | 16 +++++++++++++++- .../Client/Auth/TokenResponse.cs | 2 +- src/Bandwidth.Standard/Client/Configuration.cs | 16 +++++++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/custom_templates/Configuration.mustache b/custom_templates/Configuration.mustache index d6f5642d..c6e4b432 100644 --- a/custom_templates/Configuration.mustache +++ b/custom_templates/Configuration.mustache @@ -255,12 +255,19 @@ namespace {{packageName}}.Client /// /// Gets or sets the base path for API access. /// - public virtual string BasePath + public virtual string BasePath { get { return _basePath; } set { _basePath = value; } } + /// + /// Gets or sets whether to ignore the per-operation servers defined in the spec and route + /// all requests through instead. The default is false. Useful for + /// pointing the SDK at a proxy, mock server, or alternate gateway. + /// + public virtual bool IgnoreOperationServers { get; set; } + /// /// Determine whether or not the "default credentials" (e.g. the user account under which the current process is running) will be sent along to the server. The default is false. /// @@ -563,6 +570,11 @@ namespace {{packageName}}.Client /// The operation server URL. public string GetOperationServerUrl(string operation, int index, Dictionary inputVariables) { + if (IgnoreOperationServers) + { + return null; + } + if (operation != null && OperationServers.TryGetValue(operation, out var operationServer)) { return GetServerUrl(operationServer, index, inputVariables); @@ -727,6 +739,8 @@ namespace {{packageName}}.Client DateTimeFormat = second.DateTimeFormat ?? first.DateTimeFormat, ClientCertificates = second.ClientCertificates ?? first.ClientCertificates, UseDefaultCredentials = second.UseDefaultCredentials, + IgnoreOperationServers = ((second as Configuration)?.IgnoreOperationServers ?? false) + || ((first as Configuration)?.IgnoreOperationServers ?? false), RemoteCertificateValidationCallback = second.RemoteCertificateValidationCallback ?? first.RemoteCertificateValidationCallback, }; return config; diff --git a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs index 8707570e..c5baec4b 100644 --- a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs +++ b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs @@ -22,4 +22,4 @@ class TokenResponse [JsonProperty("expires_in")] public int ExpiresIn { get; set; } } -} \ No newline at end of file +} diff --git a/src/Bandwidth.Standard/Client/Configuration.cs b/src/Bandwidth.Standard/Client/Configuration.cs index d37d683e..603438e5 100644 --- a/src/Bandwidth.Standard/Client/Configuration.cs +++ b/src/Bandwidth.Standard/Client/Configuration.cs @@ -842,12 +842,19 @@ public Configuration( /// /// Gets or sets the base path for API access. /// - public virtual string BasePath + public virtual string BasePath { get { return _basePath; } set { _basePath = value; } } + /// + /// Gets or sets whether to ignore the per-operation servers defined in the spec and route + /// all requests through instead. The default is false. Useful for + /// pointing the SDK at a proxy, mock server, or alternate gateway. + /// + public virtual bool IgnoreOperationServers { get; set; } + /// /// Determine whether or not the "default credentials" (e.g. the user account under which the current process is running) will be sent along to the server. The default is false. /// @@ -1145,6 +1152,11 @@ public string GetOperationServerUrl(string operation, int index) /// The operation server URL. public string GetOperationServerUrl(string operation, int index, Dictionary inputVariables) { + if (IgnoreOperationServers) + { + return null; + } + if (operation != null && OperationServers.TryGetValue(operation, out var operationServer)) { return GetServerUrl(operationServer, index, inputVariables); @@ -1290,6 +1302,8 @@ public static IReadableConfiguration MergeConfigurations(IReadableConfiguration DateTimeFormat = second.DateTimeFormat ?? first.DateTimeFormat, ClientCertificates = second.ClientCertificates ?? first.ClientCertificates, UseDefaultCredentials = second.UseDefaultCredentials, + IgnoreOperationServers = ((second as Configuration)?.IgnoreOperationServers ?? false) + || ((first as Configuration)?.IgnoreOperationServers ?? false), RemoteCertificateValidationCallback = second.RemoteCertificateValidationCallback ?? first.RemoteCertificateValidationCallback, }; return config; From 5dfb65a1454459fd6dae356ba3e2e8fb97ae8a04 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Thu, 25 Jun 2026 15:35:09 -0400 Subject: [PATCH 4/5] add test --- .../Unit/Client/OAuthAuthenticatorTests.cs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs diff --git a/src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs b/src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs new file mode 100644 index 00000000..dd499d6c --- /dev/null +++ b/src/Bandwidth.Standard.Test/Unit/Client/OAuthAuthenticatorTests.cs @@ -0,0 +1,186 @@ +/* + * Bandwidth + * + * Bandwidth's Communication APIs + * + * The version of the OpenAPI document: 1.0.0 + * Contact: letstalk@bandwidth.com + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Newtonsoft.Json; +using Bandwidth.Standard.Client; +using Bandwidth.Standard.Client.Auth; + +namespace Bandwidth.Standard.Test.Unit.Client +{ + /// + /// Class for testing OAuthAuthenticator token handling and caching. + /// + public class OAuthAuthenticatorTests : IDisposable + { + private readonly HttpListener _listener; + private readonly string _tokenUrl; + private readonly string _clientId; + private const string ClientSecret = "test-secret"; + private const string AccessToken = "test-access-token"; + + // Each call to the fake token endpoint increments this so tests can assert how + // many times a token was actually fetched from the server. + private int _tokenRequestCount; + private string _lastAuthorizationHeader; + + // Controls whether the fake endpoint returns expires_in; toggled per test. + private int _expiresInSeconds = 3600; + private bool _includeExpiresIn = true; + + public OAuthAuthenticatorTests() + { + // Unique client id per test keeps the authenticator's process-wide static + // token cache isolated between tests (cache key is tokenUrl + clientId). + _clientId = "test-client-" + Guid.NewGuid().ToString("N"); + + int port = GetFreeTcpPort(); + _tokenUrl = $"http://localhost:{port}/oauth2/token"; + + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://localhost:{port}/"); + _listener.Start(); + _ = Task.Run(ServeTokenRequestsAsync); + } + + public void Dispose() + { + if (_listener.IsListening) + _listener.Stop(); + _listener.Close(); + } + + /// + /// A valid token (with expires_in) should be fetched once and reused on subsequent calls. + /// + [Fact] + public async Task GetAuthenticationParameter_CachesToken_FetchesOnlyOnce() + { + var authenticator = CreateAuthenticator(); + + string first = await authenticator.GetAuthHeaderAsync(); + string second = await authenticator.GetAuthHeaderAsync(); + + Assert.Equal($"Bearer {AccessToken}", first); + Assert.Equal(first, second); + Assert.Equal(1, _tokenRequestCount); + } + + /// + /// The token request should authenticate with the client credentials via HTTP Basic auth. + /// + [Fact] + public async Task GetAuthenticationParameter_SendsClientCredentialsAsBasicAuth() + { + var authenticator = CreateAuthenticator(); + + await authenticator.GetAuthHeaderAsync(); + + string expected = "Basic " + Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_clientId}:{ClientSecret}")); + Assert.Equal(expected, _lastAuthorizationHeader); + } + + /// + /// When the server omits expires_in, the token must not be cached (falls back to fetching per call). + /// + [Fact] + public async Task GetAuthenticationParameter_WithoutExpiresIn_FetchesEachTime() + { + _includeExpiresIn = false; + var authenticator = CreateAuthenticator(); + + await authenticator.GetAuthHeaderAsync(); + await authenticator.GetAuthHeaderAsync(); + + Assert.Equal(2, _tokenRequestCount); + } + + private TestableOAuthAuthenticator CreateAuthenticator() + { + return new TestableOAuthAuthenticator( + _tokenUrl, + _clientId, + ClientSecret, + OAuthFlow.APPLICATION, + new JsonSerializerSettings(), + new Configuration()); + } + + private async Task ServeTokenRequestsAsync() + { + while (_listener.IsListening) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch + { + // Listener stopped/disposed during teardown. + break; + } + + Interlocked.Increment(ref _tokenRequestCount); + _lastAuthorizationHeader = context.Request.Headers["Authorization"]; + + string body = _includeExpiresIn + ? $"{{\"token_type\":\"Bearer\",\"access_token\":\"{AccessToken}\",\"expires_in\":{_expiresInSeconds}}}" + : $"{{\"token_type\":\"Bearer\",\"access_token\":\"{AccessToken}\"}}"; + + byte[] bytes = Encoding.UTF8.GetBytes(body); + context.Response.ContentType = "application/json"; + context.Response.ContentLength64 = bytes.Length; + await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length); + context.Response.Close(); + } + } + + private static int GetFreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// Exposes the protected GetAuthenticationParameter as the resolved Authorization header value. + /// + private sealed class TestableOAuthAuthenticator : OAuthAuthenticator + { + public TestableOAuthAuthenticator( + string tokenUrl, + string clientId, + string clientSecret, + OAuthFlow? flow, + JsonSerializerSettings serializerSettings, + IReadableConfiguration configuration) + : base(tokenUrl, clientId, clientSecret, flow, serializerSettings, configuration) + { + } + + public async Task GetAuthHeaderAsync() + { + var parameter = await GetAuthenticationParameter(string.Empty); + return parameter.Value?.ToString(); + } + } + } +} From 8c7501aefac6e8e1e8184a81e0fed6ceac29fd07 Mon Sep 17 00:00:00 2001 From: ckoegel Date: Thu, 25 Jun 2026 15:41:47 -0400 Subject: [PATCH 5/5] Revert "ignore op server" This reverts commit 0d8b60609329e207e5ccfdaf51ad11c8b53193c5. --- custom_templates/Configuration.mustache | 16 +--------------- .../Client/Auth/TokenResponse.cs | 2 +- src/Bandwidth.Standard/Client/Configuration.cs | 16 +--------------- 3 files changed, 3 insertions(+), 31 deletions(-) diff --git a/custom_templates/Configuration.mustache b/custom_templates/Configuration.mustache index c6e4b432..d6f5642d 100644 --- a/custom_templates/Configuration.mustache +++ b/custom_templates/Configuration.mustache @@ -255,19 +255,12 @@ namespace {{packageName}}.Client /// /// Gets or sets the base path for API access. /// - public virtual string BasePath + public virtual string BasePath { get { return _basePath; } set { _basePath = value; } } - /// - /// Gets or sets whether to ignore the per-operation servers defined in the spec and route - /// all requests through instead. The default is false. Useful for - /// pointing the SDK at a proxy, mock server, or alternate gateway. - /// - public virtual bool IgnoreOperationServers { get; set; } - /// /// Determine whether or not the "default credentials" (e.g. the user account under which the current process is running) will be sent along to the server. The default is false. /// @@ -570,11 +563,6 @@ namespace {{packageName}}.Client /// The operation server URL. public string GetOperationServerUrl(string operation, int index, Dictionary inputVariables) { - if (IgnoreOperationServers) - { - return null; - } - if (operation != null && OperationServers.TryGetValue(operation, out var operationServer)) { return GetServerUrl(operationServer, index, inputVariables); @@ -739,8 +727,6 @@ namespace {{packageName}}.Client DateTimeFormat = second.DateTimeFormat ?? first.DateTimeFormat, ClientCertificates = second.ClientCertificates ?? first.ClientCertificates, UseDefaultCredentials = second.UseDefaultCredentials, - IgnoreOperationServers = ((second as Configuration)?.IgnoreOperationServers ?? false) - || ((first as Configuration)?.IgnoreOperationServers ?? false), RemoteCertificateValidationCallback = second.RemoteCertificateValidationCallback ?? first.RemoteCertificateValidationCallback, }; return config; diff --git a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs index c5baec4b..8707570e 100644 --- a/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs +++ b/src/Bandwidth.Standard/Client/Auth/TokenResponse.cs @@ -22,4 +22,4 @@ class TokenResponse [JsonProperty("expires_in")] public int ExpiresIn { get; set; } } -} +} \ No newline at end of file diff --git a/src/Bandwidth.Standard/Client/Configuration.cs b/src/Bandwidth.Standard/Client/Configuration.cs index 603438e5..d37d683e 100644 --- a/src/Bandwidth.Standard/Client/Configuration.cs +++ b/src/Bandwidth.Standard/Client/Configuration.cs @@ -842,19 +842,12 @@ public Configuration( /// /// Gets or sets the base path for API access. /// - public virtual string BasePath + public virtual string BasePath { get { return _basePath; } set { _basePath = value; } } - /// - /// Gets or sets whether to ignore the per-operation servers defined in the spec and route - /// all requests through instead. The default is false. Useful for - /// pointing the SDK at a proxy, mock server, or alternate gateway. - /// - public virtual bool IgnoreOperationServers { get; set; } - /// /// Determine whether or not the "default credentials" (e.g. the user account under which the current process is running) will be sent along to the server. The default is false. /// @@ -1152,11 +1145,6 @@ public string GetOperationServerUrl(string operation, int index) /// The operation server URL. public string GetOperationServerUrl(string operation, int index, Dictionary inputVariables) { - if (IgnoreOperationServers) - { - return null; - } - if (operation != null && OperationServers.TryGetValue(operation, out var operationServer)) { return GetServerUrl(operationServer, index, inputVariables); @@ -1302,8 +1290,6 @@ public static IReadableConfiguration MergeConfigurations(IReadableConfiguration DateTimeFormat = second.DateTimeFormat ?? first.DateTimeFormat, ClientCertificates = second.ClientCertificates ?? first.ClientCertificates, UseDefaultCredentials = second.UseDefaultCredentials, - IgnoreOperationServers = ((second as Configuration)?.IgnoreOperationServers ?? false) - || ((first as Configuration)?.IgnoreOperationServers ?? false), RemoteCertificateValidationCallback = second.RemoteCertificateValidationCallback ?? first.RemoteCertificateValidationCallback, }; return config;