Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/Resend/IResend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,36 @@ public interface IResend

#endregion

#region OAuth Grants

/// <summary>
/// Lists all OAuth grants.
/// </summary>
/// <param name="cancellationToken">
/// Cancellation token.
/// </param>
/// <returns>List of OAuth grants.</returns>
/// <see href="https://resend.com/docs/api-reference/oauth/list-grants"/>
Task<ResendResponse<List<OAuthGrant>>> OAuthGrantListAsync( CancellationToken cancellationToken = default );

/// <summary>
/// Revoke an existing OAuth grant. Revoking a grant invalidates every access
/// and refresh token issued under it.
/// </summary>
/// <param name="oauthGrantId">
/// OAuth grant identifier.
/// </param>
/// <param name="cancellationToken">
/// Cancellation token.
/// </param>
/// <returns>
/// Outcome of the revocation.
/// </returns>
/// <see href="https://resend.com/docs/api-reference/oauth/revoke-grant"/>
Task<ResendResponse<OAuthGrantRevoked>> OAuthGrantRevokeAsync( Guid oauthGrantId, CancellationToken cancellationToken = default );

#endregion

#region Audiences

/// <summary>
Expand Down
108 changes: 108 additions & 0 deletions src/Resend/OAuthGrant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// OAuth grant, representing access a user has delegated to an OAuth client.
/// </summary>
public class OAuthGrant
{
/// <summary>
/// OAuth grant identifier.
/// </summary>
[JsonPropertyName( "id" )]
public Guid Id { get; set; }

/// <summary>
/// Identifier of the OAuth client the grant was issued to.
/// </summary>
[JsonPropertyName( "client_id" )]
public Guid ClientId { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The OAuth grant identifiers are narrower than the current API contract: the OpenAPI schema defines the grant id, client_id, revoke response id, and path parameter as plain strings, but the model uses Guid. If the API returns or accepts a non-UUID string identifier, list/revoke calls will fail in the SDK before the caller can handle the response. Consider using string for the OAuth grant id fields and matching revoke parameter unless the API contract is tightened to format: uuid.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Resend/OAuthGrant.cs, line 20:

<comment>The OAuth grant identifiers are narrower than the current API contract: the OpenAPI schema defines the grant id, `client_id`, revoke response id, and path parameter as plain strings, but the model uses `Guid`. If the API returns or accepts a non-UUID string identifier, list/revoke calls will fail in the SDK before the caller can handle the response. Consider using `string` for the OAuth grant id fields and matching revoke parameter unless the API contract is tightened to `format: uuid`.</comment>

<file context>
@@ -0,0 +1,108 @@
+    /// Identifier of the OAuth client the grant was issued to.
+    /// </summary>
+    [JsonPropertyName( "client_id" )]
+    public Guid ClientId { get; set; }
+
+    /// <summary>
</file context>


/// <summary>
/// Scopes granted to the OAuth client.
/// </summary>
[JsonPropertyName( "scopes" )]
public List<string> Scopes { get; set; } = default!;

/// <summary>
/// Resource the grant is restricted to, if any.
/// </summary>
[JsonPropertyName( "resource" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? Resource { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This adds a public Resource member that is no longer in the current OAuth grant schema. Keeping it in the first release of the SDK shape can mislead callers into depending on a field the API does not return, and it becomes a public API member that is harder to remove later. Consider dropping Resource unless the endpoint contract is restored to include it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Resend/OAuthGrant.cs, line 33:

<comment>This adds a public `Resource` member that is no longer in the current OAuth grant schema. Keeping it in the first release of the SDK shape can mislead callers into depending on a field the API does not return, and it becomes a public API member that is harder to remove later. Consider dropping `Resource` unless the endpoint contract is restored to include it.</comment>

<file context>
@@ -0,0 +1,108 @@
+    /// </summary>
+    [JsonPropertyName( "resource" )]
+    [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
+    public string? Resource { get; set; }
+
+    /// <summary>
</file context>


/// <summary>
/// Moment in which the grant was created.
/// </summary>
[JsonPropertyName( "created_at" )]
[JsonConverter( typeof( JsonUtcDateTimeConverter ) )]
public DateTime MomentCreated { get; set; }

/// <summary>
/// Moment in which the grant was revoked, if it has been revoked.
/// </summary>
[JsonPropertyName( "revoked_at" )]
[JsonConverter( typeof( JsonUtcDateTimeConverter ) )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public DateTime? MomentRevoked { get; set; }

/// <summary>
/// Reason the grant was revoked, if it has been revoked.
/// </summary>
[JsonPropertyName( "revoked_reason" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? RevokedReason { get; set; }

/// <summary>
/// OAuth client the grant was issued to.
/// </summary>
[JsonPropertyName( "client" )]
public OAuthGrantClient Client { get; set; } = default!;
}


/// <summary>
/// OAuth client associated with a grant.
/// </summary>
public class OAuthGrantClient
{
/// <summary>
/// Display name of the OAuth client.
/// </summary>
[JsonPropertyName( "name" )]
public string Name { get; set; } = default!;

/// <summary>
/// URI of the OAuth client logo, if any.
/// </summary>
[JsonPropertyName( "logo_uri" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? LogoUri { get; set; }
}


/// <summary>
/// Outcome of revoking an OAuth grant.
/// </summary>
public class OAuthGrantRevoked
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{
/// <summary>
/// OAuth grant identifier.
/// </summary>
[JsonPropertyName( "id" )]
public Guid Id { get; set; }

/// <summary>
/// Moment in which the grant was revoked.
/// </summary>
[JsonPropertyName( "revoked_at" )]
[JsonConverter( typeof( JsonUtcDateTimeConverter ) )]
public DateTime MomentRevoked { get; set; }

/// <summary>
/// Reason the grant was revoked.
/// </summary>
[JsonPropertyName( "revoked_reason" )]
public string RevokedReason { get; set; } = default!;
}
25 changes: 25 additions & 0 deletions src/Resend/ResendClient.OAuthGrants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Resend.Payloads;

namespace Resend;

public partial class ResendClient
{
/// <inheritdoc />
public Task<ResendResponse<List<OAuthGrant>>> OAuthGrantListAsync( CancellationToken cancellationToken = default )
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
{
var path = $"/oauth/grants";
var req = new HttpRequestMessage( HttpMethod.Get, path );

return Execute<ListOf<OAuthGrant>, List<OAuthGrant>>( req, ( x ) => x.Data, cancellationToken );
}


/// <inheritdoc />
public Task<ResendResponse<OAuthGrantRevoked>> OAuthGrantRevokeAsync( Guid oauthGrantId, CancellationToken cancellationToken = default )
{
var path = $"/oauth/grants/{oauthGrantId}";
var req = new HttpRequestMessage( HttpMethod.Delete, path );

return Execute<OAuthGrantRevoked, OAuthGrantRevoked>( req, ( x ) => x, cancellationToken );
}
}
37 changes: 37 additions & 0 deletions tests/Resend.Tests/ResendClientTests.OAuthGrants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Resend.Tests;

/// <summary />
public partial class ResendClientTests
{
/// <summary />
[Fact]
public async Task OAuthGrantList()
{
var resp = await _resend.OAuthGrantListAsync();

Assert.NotNull( resp );
Assert.NotNull( resp.Content );
Assert.NotEmpty( resp.Content );

var grant = resp.Content[ 0 ];

Assert.NotEqual( Guid.Empty, grant.Id );
Assert.Equal( "Resend CLI", grant.Client.Name );
Assert.Contains( "emails:send", grant.Scopes );
}


/// <summary />
[Fact]
public async Task OAuthGrantRevoke()
{
var id = Guid.NewGuid();

var resp = await _resend.OAuthGrantRevokeAsync( id );

Assert.NotNull( resp );
Assert.NotNull( resp.Content );
Assert.Equal( id, resp.Content.Id );
Assert.Equal( "revoked_from_api", resp.Content.RevokedReason );
}
}
55 changes: 55 additions & 0 deletions tools/Resend.ApiServer/Controllers/OAuthGrantController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using Resend.Payloads;

namespace Resend.ApiServer.Controllers;

/// <summary />
[ApiController]
public class OAuthGrantController : ControllerBase
{
private readonly ILogger<OAuthGrantController> _logger;


/// <summary />
public OAuthGrantController( ILogger<OAuthGrantController> logger )
{
_logger = logger;
}


/// <summary />
[HttpGet]
[Route( "oauth/grants" )]
public ListOf<OAuthGrant> OAuthGrantList()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
{
_logger.LogDebug( "OAuthGrantList" );

var list = new List<OAuthGrant>();
list.Add( new OAuthGrant()
{
Id = Guid.NewGuid(),
ClientId = Guid.NewGuid(),
Scopes = new List<string>() { "emails:send" },
MomentCreated = DateTime.UtcNow,
Client = new OAuthGrantClient() { Name = "Resend CLI" },
} );

return new ListOf<OAuthGrant>() { Data = list };
}


/// <summary />
[HttpDelete]
[Route( "oauth/grants/{id}" )]
public OAuthGrantRevoked OAuthGrantRevoke( [FromRoute] Guid id )
{
_logger.LogDebug( "OAuthGrantRevoke" );

return new OAuthGrantRevoked()
{
Id = id,
MomentRevoked = DateTime.UtcNow,
RevokedReason = "revoked_from_api",
};
}
}
64 changes: 64 additions & 0 deletions tools/Resend.Cli/OAuthGrant/OAuthGrantListCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using McMaster.Extensions.CommandLineUtils;
using Spectre.Console;
using System.Text.Json;

namespace Resend.Cli.OAuthGrant;

/// <summary />
[Command( "list", Description = "Lists OAuth grants" )]
public class OAuthGrantListCommand
{
private readonly IResend _resend;


/// <summary />
[Option( "-j|--json", CommandOptionType.NoValue, Description = "Emit output as JSON array" )]
public bool InJson { get; set; }


/// <summary />
public OAuthGrantListCommand( IResend resend )
{
_resend = resend;
}


/// <summary />
public async Task<int> OnExecuteAsync()
{
var res = await _resend.OAuthGrantListAsync();
var grants = res.Content;


if ( this.InJson == true )
{
var jso = new JsonSerializerOptions() { WriteIndented = true };
var json = JsonSerializer.Serialize( grants, jso );

Console.WriteLine( json );
}
else
{
var table = new Table();
table.Border = TableBorder.SimpleHeavy;
table.AddColumn( "Grant Id" );
table.AddColumn( "Client" );
table.AddColumn( "Scopes" );
table.AddColumn( "Created" );

foreach ( var g in grants )
{
table.AddRow(
new Markup( g.Id.ToString() ),
new Markup( g.Client.Name ),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
new Markup( string.Join( ", ", g.Scopes ) ),
new Markup( g.MomentCreated.ToString() )
);
}

AnsiConsole.Write( table );
}

return 0;
}
}
33 changes: 33 additions & 0 deletions tools/Resend.Cli/OAuthGrant/OAuthGrantRevokeCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using McMaster.Extensions.CommandLineUtils;
using System.ComponentModel.DataAnnotations;

namespace Resend.Cli.OAuthGrant;

/// <summary />
[Command( "revoke", Description = "Revoke an OAuth grant" )]
public class OAuthGrantRevokeCommand
{
private readonly IResend _resend;


/// <summary />
[Argument( 0, Description = "OAuth grant identifier" )]
[Required]
public Guid? GrantId { get; set; }


/// <summary />
public OAuthGrantRevokeCommand( IResend resend )
{
_resend = resend;
}


/// <summary />
public async Task<int> OnExecuteAsync()
{
await _resend.OAuthGrantRevokeAsync( this.GrantId!.Value );

return 0;
}
}
17 changes: 17 additions & 0 deletions tools/Resend.Cli/OAuthGrantCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using McMaster.Extensions.CommandLineUtils;

namespace Resend.Cli;

/// <summary />
[Command( "oauth-grant", Description = "OAuth grant management" )]
[Subcommand( typeof( OAuthGrant.OAuthGrantListCommand ) )]
[Subcommand( typeof( OAuthGrant.OAuthGrantRevokeCommand ) )]
public class OAuthGrantCommand
{
/// <summary />
public int OnExecute( CommandLineApplication app )
{
app.ShowHelp();
return 1;
}
}
1 change: 1 addition & 0 deletions tools/Resend.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Resend.Cli;
[Subcommand( typeof( ContactPropCommand ) )]
[Subcommand( typeof( DomainCommand ) )]
[Subcommand( typeof( EmailCommand ) )]
[Subcommand( typeof( OAuthGrantCommand ) )]
[Subcommand( typeof( ReceiveCommand ) )]
[Subcommand( typeof( SegmentCommand ) )]
[Subcommand( typeof( TemplateCommand ) )]
Expand Down
Loading