Skip to content
Merged
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
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ See the runnable [sample application](src/SampleApp/Sample.cs).
### Migrating from 1.x

- Requires .NET 8+. The 25 existing public method names and parameter-dictionary
calling convention remain. Five playlist write methods are added.
calling convention remain. Six playlist write methods are available in 3.1.0,
including permanent playlist deletion.
- RestSharp has been removed. The public `client.restClient` and
`response.response` fields are removed; use constructor transport options and
`response.StatusCode` / `response.Headers` instead. `ApiResponse`'s constructor
Expand All @@ -91,7 +92,7 @@ See the runnable [sample application](src/SampleApp/Sample.cs).
no longer loses its path identifiers. Dispose the SDK client when finished.
- Every non-2xx status now throws, including 403 and redirects. SDK exceptions
share `ListenApiException` as their base. Redirects are not followed by the
default transport, and the user agent is `podcast-api-dotnet 3.0.0`.
default transport, and the user agent is `podcast-api-dotnet <version>`.

## Method index

Expand Down Expand Up @@ -124,6 +125,7 @@ See the runnable [sample application](src/SampleApp/Sample.cs).
- [`FetchPodcastsByDomain`](#fetchpodcastsbydomain) — `GET /podcasts/domains/{domain_name}`
- [`CreatePlaylist`](#createplaylist) — `POST /playlists`
- [`UpdatePlaylist`](#updateplaylist) — `PUT /playlists/{id}`
- [`DeletePlaylist`](#deleteplaylist) — `DELETE /playlists/{id}`
- [`AddPlaylistItem`](#addplaylistitem) — `POST /playlists/{id}/items`
- [`DeletePlaylistItem`](#deleteplaylistitem) — `DELETE /playlists/{id}/items/{item_id}`
- [`UpdatePlaylistItemNotes`](#updateplaylistitemnotes) — `PUT /playlists/{id}/items/{item_id}`
Expand Down Expand Up @@ -838,6 +840,33 @@ Console.WriteLine(response.ToJSON<dynamic>());

[Full API documentation](https://www.listennotes.com/api/docs/#put-api-v2-playlists-id)

### DeletePlaylist

Delete a playlist.

`DELETE /playlists/{id}`

Permanently delete a playlist, including all episode and podcast references saved in this specific playlist and their notes. The actual episodes and podcasts remain in the Listen Notes podcast database.

**Warning: Deletion cannot be undone. Once deleted, the playlist is gone, regardless of how many episodes or podcasts it contains. You, the developer, are responsible for adding a confirmation step in your app's UI before calling this endpoint to prevent accidental deletion.**

Only playlists owned by your admin API account can be modified; contributor membership does not grant write access.

```csharp
using System;
using System.Collections.Generic;

using var client = new PodcastAPI.Client(Environment.GetEnvironmentVariable("LISTEN_API_KEY"));
var parameters = new Dictionary<string, string>
{
["id"] = "m1pe7z60bsw",
};
var response = await client.DeletePlaylist(parameters);
Console.WriteLine(response.ToJSON<dynamic>());
```

[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id)

### AddPlaylistItem

Add an episode or podcast to a playlist.
Expand Down Expand Up @@ -944,7 +973,7 @@ LISTEN_API_MOCK_INTEGRATION=1 dotnet test src/PodcastAPI.Tests --filter 'TestCat
```

It calls only `https://listen-api-test.listennotes.com/api/v2` with no API key,
including all five playlist writes. It never reads credentials from the environment
including all six playlist writes. It never reads credentials from the environment
or permits a destination override. The mock does not prove persistence or production
permissions. CI runs these checks separately from offline tests.

Expand Down
15 changes: 15 additions & 0 deletions scripts/verify-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,27 @@ var response = await client.UpdatePlaylistItemNotes(new Dictionary<string, strin
});
if (response.StatusCode != HttpStatusCode.OK || (int)response.ToJSON<dynamic>()!.id != 23)
throw new Exception("Packaged response did not deserialize");
var deleted = await client.DeletePlaylist(new Dictionary<string, string> { ["id"] = "playlist/encoded" });
if (deleted.StatusCode != HttpStatusCode.OK || !(bool)deleted.ToJSON<dynamic>()!.deleted ||
(string)deleted.ToJSON<dynamic>()!.id != "playlist/encoded")
throw new Exception("Packaged deletion response did not deserialize");
Console.WriteLine("Packaged SDK and all README examples verified without API requests.");

sealed class OfflineHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
if (request.Method == HttpMethod.Delete)
{
if (request.RequestUri!.AbsolutePath != "/api/v2/playlists/playlist%2Fencoded" ||
request.RequestUri.Query != "" || request.Content is not null ||
request.Headers.Contains("X-ListenAPI-Key"))
throw new Exception("Packaged deletion request contract mismatch");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"id\":\"playlist/encoded\",\"deleted\":true}"),
};
}
if (request.Method != HttpMethod.Put ||
request.RequestUri!.AbsolutePath != "/api/v2/playlists/playlist%2Fencoded/items/23" ||
request.Headers.Contains("X-ListenAPI-Key") ||
Expand Down
100 changes: 82 additions & 18 deletions src/PodcastAPI.Tests/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ public void CiUsesTheExpectedRuntime()
if (expected is not null) Assert.AreEqual(expected, Environment.Version.Major.ToString());
}

internal static readonly JsonElement[] Operations = JsonDocument.Parse(
private static readonly JsonElement Contract = JsonDocument.Parse(
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "api-contract.json")))
.RootElement.GetProperty("operations").EnumerateArray().Select(op => op.Clone()).ToArray();
.RootElement;
internal static readonly JsonElement[] Operations = Contract.GetProperty("operations")
.EnumerateArray().Select(op => op.Clone()).ToArray();

public static IEnumerable<object[]> OperationCases => Operations.Select(op => new object[] { op.GetProperty("operationId").GetString()! });

Expand Down Expand Up @@ -76,7 +78,7 @@ public async Task EveryGeneratedMethodMatchesContract(string operation)
CollectionAssert.AreEquivalent(query.ToArray(), Decode(request.RequestUri.Query).ToArray());
CollectionAssert.AreEquivalent(body.ToArray(), Decode(request.Content is null ? null : await request.Content.ReadAsStringAsync(token)).ToArray());
Assert.AreEqual("fixture-key", request.Headers.GetValues("X-ListenAPI-Key").Single());
Assert.AreEqual("podcast-api-dotnet 3.0.0", request.Headers.UserAgent.ToString());
Assert.AreEqual("podcast-api-dotnet " + Contract.GetProperty("version").GetString(), request.Headers.UserAgent.ToString());
Assert.AreEqual("application/json", request.Headers.Accept.Single().MediaType);
if (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put)
Assert.AreEqual("application/x-www-form-urlencoded", request.Content!.Headers.ContentType!.MediaType);
Expand All @@ -92,6 +94,33 @@ public async Task EveryGeneratedMethodMatchesContract(string operation)
Assert.AreEqual(1, handler.Calls);
}

[TestMethod]
public async Task DeletePlaylistEncodesItsIdentifierWithoutQueryOrBody()
{
const string id = "a/b ?#%é";
var parameters = new Dictionary<string, string> { ["id"] = id };
using var handler = new Handler((request, _) =>
{
Assert.AreEqual(HttpMethod.Delete, request.Method);
Assert.AreEqual("/api/v2/playlists/a%2Fb%20%3F%23%25%C3%A9", request.RequestUri!.AbsolutePath);
Assert.AreEqual("", request.RequestUri.Query);
Assert.IsNull(request.Content);
var response = Response(200, "{\"id\":\"a/b ?#%é\",\"deleted\":true}");
response.Headers.Add("X-ListenAPI-Usage", "12");
return Task.FromResult(response);
});
using var http = new HttpClient(handler);
using var client = new Client(httpClient: http);
var response = await client.DeletePlaylist(parameters);
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
Assert.AreEqual(id, (string)response.ToJSON<dynamic>()!.id);
Assert.AreEqual(true, (bool)response.ToJSON<dynamic>()!.deleted);
Assert.AreEqual(12, response.GetUsage());
Assert.AreEqual(id, parameters["id"]);
Assert.AreEqual(1, parameters.Count);
Assert.AreEqual(1, handler.Calls);
}

[TestMethod]
public async Task NestedIdentifiersQueryAndEmptyBodyValuesAreEncoded()
{
Expand Down Expand Up @@ -152,17 +181,31 @@ public async Task RepeatedWritesAreNotRetriedAndOmittedNotesStayOmitted()
[DataRow(500, typeof(ListenApiException))]
[DataRow(503, typeof(ListenApiException))]
[DataRow(302, typeof(ListenApiException))]
[DataRow(307, typeof(ListenApiException))]
[DataRow(308, typeof(ListenApiException))]
[DataRow(422, typeof(ListenApiException))]
public async Task HttpErrorsRetainResponseWithoutRetries(int status, Type type)
{
using var handler = new Handler((_, _) => Task.FromResult(Response(status, "{\"error\":\"episode not found\"}")));
using var handler = new Handler((_, _) =>
{
var response = Response(status, "{\"error\":\"Exact reason\"}");
response.Headers.Add("X-ListenAPI-Usage", "123");
return Task.FromResult(response);
});
using var http = new HttpClient(handler);
using var client = new Client(httpClient: http);
var error = await Assert.ThrowsAsync<ListenApiException>(() => client.CreatePlaylist(new Dictionary<string, string> { ["name"] = "test" }));
Assert.AreEqual(type, error.GetType());
Assert.AreEqual(status, (int)error.Response!.StatusCode);
Assert.AreEqual("episode not found", (string)error.Response.ToJSON<dynamic>()!.error);
Assert.AreEqual(1, handler.Calls);
foreach (var operation in new[] { "createPlaylist", "deletePlaylist" })
{
var parameters = operation == "createPlaylist"
? new Dictionary<string, string> { ["name"] = "test" }
: new Dictionary<string, string> { ["id"] = "playlist" };
var error = await Assert.ThrowsAsync<ListenApiException>(() => MethodDispatch.Call(operation, client, parameters));
Assert.AreEqual(type, error.GetType());
Assert.AreEqual(status, (int)error.Response!.StatusCode);
Assert.AreEqual("Exact reason", (string)error.Response.ToJSON<dynamic>()!.error);
Assert.AreEqual(123, error.Response.GetUsage());
}
Assert.AreEqual(2, handler.Calls);
}

[TestMethod]
Expand Down Expand Up @@ -227,7 +270,12 @@ public async Task PathValidationHappensBeforeSending()
using var http = new HttpClient(handler);
using var client = new Client(httpClient: http);
foreach (var value in new[] { "", " ", ".", ".." })
{
await Assert.ThrowsExactlyAsync<ArgumentException>(() => client.FetchPlaylistById(new Dictionary<string, string> { ["id"] = value }));
await Assert.ThrowsExactlyAsync<ArgumentException>(() => client.DeletePlaylist(new Dictionary<string, string> { ["id"] = value }));
}
await Assert.ThrowsExactlyAsync<ArgumentException>(() => client.DeletePlaylist());
await Assert.ThrowsExactlyAsync<ArgumentException>(() => client.DeletePlaylist(new Dictionary<string, string>()));
await Assert.ThrowsExactlyAsync<ArgumentException>(() => client.DeletePlaylistItem(new Dictionary<string, string> { ["id"] = "playlist" }));
Assert.AreEqual(0, handler.Calls);
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new Client(timeout: TimeSpan.Zero));
Expand All @@ -236,29 +284,40 @@ public async Task PathValidationHappensBeforeSending()
}

[TestMethod]
public async Task CancellationTimeoutAndConnectionFailureAreDistinct()
[DataRow("getLanguages")]
[DataRow("deletePlaylist")]
public async Task CancellationTimeoutAndConnectionFailureAreDistinct(string operation)
{
var parameters = operation == "deletePlaylist"
? new Dictionary<string, string> { ["id"] = "playlist" } : new Dictionary<string, string>();
using var handler = new Handler(async (_, token) => { await Task.Delay(System.Threading.Timeout.Infinite, token); return Response(); });
using var http = new HttpClient(handler);
using var client = new Client(httpClient: http, timeout: TimeSpan.FromMilliseconds(50));
var timeout = await Assert.ThrowsExactlyAsync<ApiConnectionException>(() => client.FetchPodcastLanguages());
var timeout = await Assert.ThrowsExactlyAsync<ApiConnectionException>(() => MethodDispatch.Call(operation, client, parameters));
Assert.IsNull(timeout.Response);
Assert.AreEqual(1, handler.Calls);
using var cancel = new CancellationTokenSource();
cancel.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => client.FetchPodcastLanguages(cancellationToken: cancel.Token));
await Assert.ThrowsAsync<OperationCanceledException>(() => MethodDispatch.Call(operation, client, parameters, cancel.Token));
Assert.AreEqual(1, handler.Calls);
using var inFlight = new CancellationTokenSource(TimeSpan.FromMilliseconds(25));
using var slower = new Client(httpClient: http, timeout: TimeSpan.FromSeconds(10));
await Assert.ThrowsAsync<OperationCanceledException>(() => slower.FetchPodcastLanguages(cancellationToken: inFlight.Token));
await Assert.ThrowsAsync<OperationCanceledException>(() => MethodDispatch.Call(operation, slower, parameters, inFlight.Token));
Assert.AreEqual(2, handler.Calls);
using var failed = new Handler((_, _) => throw new HttpRequestException("must not leak a secret request URI"));
using var failedHttp = new HttpClient(failed);
using var disconnected = new Client(httpClient: failedHttp);
var error = await Assert.ThrowsExactlyAsync<ApiConnectionException>(() => disconnected.FetchPodcastLanguages());
var error = await Assert.ThrowsExactlyAsync<ApiConnectionException>(() => MethodDispatch.Call(operation, disconnected, parameters));
Assert.IsFalse(error.ToString().Contains("secret request URI", StringComparison.Ordinal));
Assert.AreEqual(1, failed.Calls);
}

[TestMethod]
public async Task DefaultTransportDoesNotFollowRedirects()
[DataRow("getLanguages", 302)]
[DataRow("deletePlaylist", 302)]
[DataRow("deletePlaylist", 307)]
[DataRow("deletePlaylist", 308)]
public async Task DefaultTransportDoesNotFollowRedirects(string operation, int status)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
Expand All @@ -269,13 +328,18 @@ public async Task DefaultTransportDoesNotFollowRedirects()
using var connection = await listener.AcceptTcpClientAsync(deadline.Token);
await using var stream = connection.GetStream();
using var reader = new StreamReader(stream, leaveOpen: true);
var expectedRequest = operation == "deletePlaylist"
? "DELETE /api/v2/playlists/playlist HTTP/1.1" : "GET /api/v2/languages HTTP/1.1";
Assert.AreEqual(expectedRequest, await reader.ReadLineAsync(deadline.Token));
while (!string.IsNullOrEmpty(await reader.ReadLineAsync(deadline.Token))) { }
var response = Encoding.ASCII.GetBytes($"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{port}/redirected\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}");
var response = Encoding.ASCII.GetBytes($"HTTP/1.1 {status} Redirect\r\nLocation: http://127.0.0.1:{port}/redirected\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}");
await stream.WriteAsync(response, deadline.Token);
}, deadline.Token);
using var client = new Client("fixture-key", baseUrl: new Uri($"http://127.0.0.1:{port}/api/v2"));
var error = await Assert.ThrowsExactlyAsync<ListenApiException>(() => client.FetchPodcastLanguages(cancellationToken: deadline.Token));
Assert.AreEqual(302, (int)error.Response!.StatusCode);
var parameters = operation == "deletePlaylist"
? new Dictionary<string, string> { ["id"] = "playlist" } : new Dictionary<string, string>();
var error = await Assert.ThrowsExactlyAsync<ListenApiException>(() => MethodDispatch.Call(operation, client, parameters, deadline.Token));
Assert.AreEqual(status, (int)error.Response!.StatusCode);
await server;
Assert.IsFalse(listener.Pending());
}
Expand Down
1 change: 1 addition & 0 deletions src/PodcastAPI.Tests/MethodDispatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static Task<ApiResponse> Call(string operation, Client client,
"getPodcastsByDomainName" => client.FetchPodcastsByDomain(parameters, token),
"createPlaylist" => client.CreatePlaylist(parameters, token),
"updatePlaylist" => client.UpdatePlaylist(parameters, token),
"deletePlaylist" => client.DeletePlaylist(parameters, token),
"addPlaylistItem" => client.AddPlaylistItem(parameters, token),
"deletePlaylistItem" => client.DeletePlaylistItem(parameters, token),
"updatePlaylistItemNotes" => client.UpdatePlaylistItemNotes(parameters, token),
Expand Down
7 changes: 7 additions & 0 deletions src/PodcastAPI.Tests/MockIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public async Task AllMethodsReachOnlyThePublicMockWithoutCredentials()
// No environment API key or base URL; redirects are disabled by Client.
using var client = new Client();
Assert.AreEqual("https://listen-api-test.listennotes.com/api/v2/", client.BaseUrl.AbsoluteUri);
Assert.AreEqual(31, ClientTests.Operations.Length);
foreach (var op in ClientTests.Operations)
{
var operation = op.GetProperty("operationId").GetString()!;
Expand All @@ -26,6 +27,12 @@ public async Task AllMethodsReachOnlyThePublicMockWithoutCredentials()
Assert.IsNotNull(json, operation);
Assert.IsTrue(json.Count > 0, operation);
if (operation == "deletePlaylistItem") Assert.AreEqual(true, (bool?)json["deleted"], operation);
if (operation == "deletePlaylist")
{
Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode);
Assert.AreEqual(true, (bool?)json["deleted"]);
Assert.AreEqual(ClientTests.Examples(op)["id"], (string?)json["id"]);
}
if (operation is "createPlaylist" or "updatePlaylist" or "addPlaylistItem" or "updatePlaylistItemNotes")
Assert.IsNotNull(json["id"], operation);
}
Expand Down
Loading
Loading