Skip to content
Open

v2 #1

Show file tree
Hide file tree
Changes from all commits
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## 2.0.0

Breaking rewrite for Node SDK parity (version aligned with Node/Python/Go/Java `2.0.0`):

- Typed params/responses (`SendMailParams`, `ContactModels`, etc.) instead of untyped dictionaries for mail/contacts
- `ReloopValidationException` for invalid client input (no HTTP); `ReloopApiException` for HTTP/network; `WebhookSignatureException` for local HMAC verify
- Facade: `client.ApiKey` (was `ApiKeys`), `Mail`, `Domain`, `Contacts`, `Webhook`, `Inbox`
- Typed Contacts with nested `Properties`, `Groups`, `Channels`
- Full Webhook CRUD + `PauseAsync`/`EnableAsync`/`DisableAsync`/`TriggerAsync`/deliveries + local HMAC `WebhookVerify`
- Full Inbox: `Mailboxes`, `Messages`, `Threads`
- Removed: API key `PauseAsync`, domain `GetNameserversAsync` / `ForwardDnsAsync`
- Paths aligned with Node (`/api/mail/v1/send`, `/api/api-key/v1/`, `/api/domain/v1/`, …)
- xUnit tests with `MockHttpMessageHandler` and surface-lock checks

## 0.1.0

Initial thin client for mail, domain, API keys, and dictionary-based contacts.
42 changes: 25 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Contributing to the Reloop .NET SDK

NuGet package: **`Reloop`**.
NuGet package: **`Reloop`** (version aligned with Node/Python/Go/Java at **2.0.0**).

**License:** [Apache License 2.0](./LICENSE) with additional use restrictions from Reloop Labs.

**API reference:** [reloop.sh/docs](https://reloop.sh/docs)

Port new endpoints from the [Node.js SDK](https://github.com/reloop-labs/reloop-node) reference.
Port new endpoints from the [Node.js SDK](https://github.com/reloop-labs/reloop-node) — Node wins on paths, bodies, and validation messages.

---

Expand All @@ -18,18 +18,21 @@ cd reloop-dotnet
dotnet test tests/Reloop.Tests/Reloop.Tests.csproj
```

Requires **.NET 8 SDK** (CI); targets **netstandard2.0**.
Requires **.NET 8 SDK** for tests; the library targets **netstandard2.0** (`System.Text.Json`).

---

## Project layout

```
```text
ReloopClient.cs
Services/ # MailService, DomainService, …
Models/Models.cs # Records with JsonPropertyName
tests/Reloop.Tests/ # Route tests + MockHttpMessageHandler
Reloop.csproj # Version, PackageVersion, PackageLicenseExpression
Version.cs
Exceptions/ # ReloopValidationException, ReloopApiException, ApiErrorBody, WebhookSignatureException
Validation/ # Validators
Models/ # MailModels, ApiKeyModels, DomainModels, ContactModels, WebhookModels, InboxModels
Services/ # Mail, ApiKey, Domain, Contacts*, Webhook*, Inbox*
tests/Reloop.Tests/ # Route + validation + surface-lock tests, MockHttpMessageHandler
Reloop.csproj # Version, PackageVersion, PackageLicenseExpression
```

---
Expand All @@ -38,29 +41,34 @@ Reloop.csproj # Version, PackageVersion, PackageLicenseExpression

| Topic | Rule |
|-------|------|
| Domain | Typed records; snake_case JSON names |
| Mail send | `Dictionary<string, object?>` with snake_case keys |
| Contacts | camelCase via request helpers |
| Tests | Inject `HttpClient` with `MockHttpMessageHandler` |
| Errors | `ReloopValidationException` (no HTTP) / `ReloopApiException` (HTTP/network) / `WebhookSignatureException` (local HMAC) |
| Wire JSON | Match Node (`[JsonPropertyName]` for snake_case vs camelCase) |
| Validation | Fail before HTTP via `Validators`; tests assert **0** requests on validation failures |
| Async API | Public methods end with `Async`; inject `HttpClient` in ctor for tests |
| Tests | `MockHttpMessageHandler`: method, path, `x-api-key`, body; surface-lock method sets |
| Endpoints | Do not invent — copy from Node `paths.ts` / Java services |
| Version | Same semver as Node/Python/Go/Java (`Version.VERSION` + `Reloop.csproj`) |

---

## Pull request checklist

- [ ] `dotnet test tests/Reloop.Tests/Reloop.Tests.csproj` passes
- [ ] `<Version>` in `Reloop.csproj` bumped only for releases
- [ ] New ops have happy path + API error + validation (0 HTTP) + surface test where applicable
- [ ] `Reloop.csproj` / `Version.cs` updated only when releasing

---

## Releasing

Version: **`Reloop.csproj`** → `<Version>` and `<PackageVersion>`.
1. Set `<Version>` and `<PackageVersion>` in `Reloop.csproj` and `Version.VERSION` to the same value (match other SDKs)
2. Tag and publish via existing GitHub Actions workflows

```bash
git commit -am "chore: release v1.9.0"
git commit -am "chore: release v2.0.0"
git push origin main
git tag v1.9.0
git push origin v1.9.0
git tag v2.0.0
git push origin v2.0.0
```

[`.github/workflows/release.yml`](./.github/workflows/release.yml) uploads source zip + `.nupkg` files.
Expand Down
19 changes: 19 additions & 0 deletions Exceptions/ApiErrorBody.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;

namespace Reloop.Exceptions;

/** Decoded Reloop API error payload. */
public class ApiErrorBody
{
[JsonPropertyName("message")]
public string? Message { get; set; }

[JsonPropertyName("why")]
public string? Why { get; set; }

[JsonPropertyName("tip")]
public string? Tip { get; set; }

[JsonPropertyName("link")]
public string? Link { get; set; }
}
40 changes: 40 additions & 0 deletions Exceptions/ReloopApiException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace Reloop.Exceptions;

/** Thrown for non-2xx HTTP responses and network failures. */
public class ReloopApiException : Exception
{
public int Status { get; }
public string StatusText { get; }
public ApiErrorBody Body { get; }

public ReloopApiException(int status, string statusText, ApiErrorBody? body)
: base(BuildMessage(status, statusText, body))
{
Status = status;
StatusText = statusText;
Body = body ?? new ApiErrorBody();
}

public ReloopApiException(string message, Exception innerException)
: base(message, innerException)
{
Status = 0;
StatusText = "Network Error";
Body = new ApiErrorBody { Message = message };
}

private static string BuildMessage(int status, string statusText, ApiErrorBody? body)
{
if (body?.Message is { Length: > 0 } msg)
{
return msg;
}

if (status == 0)
{
return "Reloop network error: " + statusText;
}

return "Reloop API Error: " + status + " " + statusText;
}
}
18 changes: 18 additions & 0 deletions Exceptions/ReloopValidationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Reloop.Exceptions;

/** Thrown when client arguments are invalid (no HTTP call is made). */
public class ReloopValidationException : Exception
{
public string? Field { get; }

public ReloopValidationException(string message)
: this(message, null)
{
}

public ReloopValidationException(string message, string? field)
: base(message)
{
Field = field;
}
}
10 changes: 10 additions & 0 deletions Exceptions/WebhookSignatureException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Reloop.Exceptions;

/** Thrown when webhook signature verification fails. */
public class WebhookSignatureException : Exception
{
public WebhookSignatureException(string message)
: base(message)
{
}
}
201 changes: 201 additions & 0 deletions Models/ApiKeyModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System.Text.Json.Serialization;

namespace Reloop.Models;

public static class ApiKeyModels
{
public class User
{
[JsonPropertyName("id")]
public string? Id { get; set; }

[JsonPropertyName("name")]
public string? Name { get; set; }

[JsonPropertyName("image")]
public string? Image { get; set; }

[JsonPropertyName("email")]
public string? Email { get; set; }
}

public class ApiKey
{
[JsonPropertyName("id")]
public string? Id { get; set; }

[JsonPropertyName("name")]
public string? Name { get; set; }

[JsonPropertyName("start")]
public string? Start { get; set; }

[JsonPropertyName("prefix")]
public string? Prefix { get; set; }

[JsonPropertyName("organizationId")]
public string? OrganizationId { get; set; }

[JsonPropertyName("userId")]
public string? UserId { get; set; }

[JsonPropertyName("refillInterval")]
public int? RefillInterval { get; set; }

[JsonPropertyName("refillAmount")]
public int? RefillAmount { get; set; }

[JsonPropertyName("lastRefillAt")]
public string? LastRefillAt { get; set; }

[JsonPropertyName("enabled")]
public bool Enabled { get; set; }

[JsonPropertyName("rateLimitEnabled")]
public bool RateLimitEnabled { get; set; }

[JsonPropertyName("rateLimitTimeWindow")]
public int RateLimitTimeWindow { get; set; }

[JsonPropertyName("rateLimitMax")]
public int RateLimitMax { get; set; }

[JsonPropertyName("requestCount")]
public int RequestCount { get; set; }

[JsonPropertyName("remaining")]
public int? Remaining { get; set; }

[JsonPropertyName("lastRequest")]
public string? LastRequest { get; set; }

[JsonPropertyName("expiresAt")]
public string? ExpiresAt { get; set; }

[JsonPropertyName("createdAt")]
public string? CreatedAt { get; set; }

[JsonPropertyName("updatedAt")]
public string? UpdatedAt { get; set; }

[JsonPropertyName("permissions")]
public string? Permissions { get; set; }

[JsonPropertyName("metadata")]
public string? Metadata { get; set; }

[JsonPropertyName("createdBy")]
public User? CreatedBy { get; set; }

[JsonPropertyName("object")]
public string? Object { get; set; }

[JsonPropertyName("event")]
public string? Event { get; set; }
}

public class ApiKeyWithKey
{
[JsonPropertyName("id")]
public string? Id { get; set; }

[JsonPropertyName("name")]
public string? Name { get; set; }

[JsonPropertyName("key")]
public string? Key { get; set; }

[JsonPropertyName("enabled")]
public bool Enabled { get; set; }

[JsonPropertyName("createdAt")]
public string? CreatedAt { get; set; }

[JsonPropertyName("updatedAt")]
public string? UpdatedAt { get; set; }

[JsonPropertyName("permissions")]
public string? Permissions { get; set; }

[JsonPropertyName("object")]
public string? Object { get; set; }

[JsonPropertyName("event")]
public string? Event { get; set; }
}

public class ApiKeyListResponse
{
[JsonPropertyName("object")]
public string? Object { get; set; }

[JsonPropertyName("apiKeys")]
public List<ApiKey>? ApiKeys { get; set; }

[JsonPropertyName("total")]
public int Total { get; set; }

[JsonPropertyName("page")]
public int Page { get; set; }

[JsonPropertyName("limit")]
public int Limit { get; set; }

[JsonPropertyName("event")]
public string? Event { get; set; }
}

public class ApiKeyListParams
{
public int? Page { get; set; }
public int? Limit { get; set; }
public bool? Enabled { get; set; }
public string? UserId { get; set; }
public string? Q { get; set; }
}

public class DeleteApiKeyResponse
{
[JsonPropertyName("id")]
public string? Id { get; set; }

[JsonPropertyName("message")]
public string? Message { get; set; }

[JsonPropertyName("object")]
public string? Object { get; set; }

[JsonPropertyName("event")]
public string? Event { get; set; }
}

public class CreateApiKeyParams
{
[JsonPropertyName("name")]
public string? Name { get; set; }

public CreateApiKeyParams()
{
}

public CreateApiKeyParams(string name)
{
Name = name;
}
}

public class UpdateApiKeyParams
{
[JsonPropertyName("name")]
public string? Name { get; set; }

public UpdateApiKeyParams()
{
}

public UpdateApiKeyParams(string name)
{
Name = name;
}
}
}
Loading