diff --git a/README.md b/README.md index 3d02b9a..15023a5 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,20 @@ and redirects disabled. The SDK does not add retries. `WithHTTPClient` accepts your own HTTP client and its timeout, redirect, proxy, and transport policies. `WithBaseURL` supports local test servers and sends your key to that chosen URL. +### Upgrading from 3.0 + +The `HTTPClient` interface now also requires +`DeletePlaylist(id string, args map[string]string) (*Response, error)`. +Custom implementations and mocks of this interface must add the method. +Clients returned by `NewClient` already implement it. + ### Upgrading from 1.x - Change imports and `go get` commands to `github.com/ListenNotes/podcast-api-go/v3`, as required by Go module versioning. - Use Go 1.26+. Existing method names, positional identifiers, string parameter maps, `Response.Data`, `Response.Stats`, and `ToJSON()` remain available. -- The `HTTPClient` interface adds five playlist methods; custom implementations +- The `HTTPClient` interface adds six playlist write methods; custom implementations of that interface must add them too. - Match errors with `errors.Is(err, listennotes.ErrUnauthorized)`, replacing direct equality checks. Use `errors.As(err, &apiError)` with @@ -90,14 +97,14 @@ go test -tags=integration -run '^TestMockIntegration' -count=1 -timeout=3m ./... ``` Integration tests use only the fixed public mock origin, with no API key, -environment-selected destination, proxy, or redirects. They cover all 30 methods. +environment-selected destination, proxy, or redirects. They cover all 31 methods. The mock is stateless; these tests do not establish production write persistence or authorization. `go run ./example` makes one explicit read-only mock request. The monorepo's `sync.py go` generates methods, the contract, test dispatch, compile-only examples, and the marked README sections. Do not hand-edit generated outputs. This module builds and tests independently of the monorepo. Publishing -the `v3.0.0` Git tag and enabling website snippets are separate release steps. +the `v3.1.0` Git tag and enabling website snippets are separate release steps. ## Method index @@ -130,6 +137,7 @@ the `v3.0.0` Git tag and enabling website snippets are separate release steps. - [`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}` @@ -963,6 +971,39 @@ func main() { [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. + +```go +package main + +import ( + "fmt" + listennotes "github.com/ListenNotes/podcast-api-go/v3" +) + +func main() { + client := listennotes.NewClient("") + response, err := client.DeletePlaylist("m1pe7z60bsw", nil) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(response.ToJSON()) +} +``` + +[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id) + ### AddPlaylistItem Add an episode or podcast to a playlist. diff --git a/api-contract.json b/api-contract.json index e0158a7..df39c30 100644 --- a/api-contract.json +++ b/api-contract.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "version": "3.0.0", + "version": "3.1.0", "operations": [ { "operationId": "search", @@ -815,6 +815,25 @@ "summary": "Update playlist metadata.", "description": "Update any subset of name, description, visibility, and type. Omitted fields remain unchanged; at least one field is required. Switching to private rotates the playlist RSS secret. Type selects the saved default view (episode_list or podcast_list) and the returned listennotes_url; changing it preserves all existing episodes and podcasts.\n\nOnly playlists owned by your admin API account can be modified; contributor membership does not grant write access." }, + { + "operationId": "deletePlaylist", + "func": "DeletePlaylist", + "available_from": "3.1.0", + "method": "DELETE", + "path": "/playlists/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true + } + ], + "example_params": { + "id": "m1pe7z60bsw" + }, + "summary": "Delete a playlist.", + "description": "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.\n\n**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.**\n\nOnly playlists owned by your admin API account can be modified; contributor membership does not grant write access." + }, { "operationId": "addPlaylistItem", "func": "AddPlaylistItem", diff --git a/api_dispatch_test.go b/api_dispatch_test.go index 8e3d726..0124603 100644 --- a/api_dispatch_test.go +++ b/api_dispatch_test.go @@ -57,6 +57,8 @@ func callSDKMethod(client HTTPClient, operation string, args map[string]string) return client.CreatePlaylist(args) case "updatePlaylist": return client.UpdatePlaylist(args["id"], args) + case "deletePlaylist": + return client.DeletePlaylist(args["id"], args) case "addPlaylistItem": return client.AddPlaylistItem(args["id"], args) case "deletePlaylistItem": diff --git a/api_methods.go b/api_methods.go index f81217b..5a2a66c 100644 --- a/api_methods.go +++ b/api_methods.go @@ -32,6 +32,7 @@ type HTTPClient interface { FetchPodcastsByDomain(domainName string, args map[string]string) (*Response, error) CreatePlaylist(args map[string]string) (*Response, error) UpdatePlaylist(id string, args map[string]string) (*Response, error) + DeletePlaylist(id string, args map[string]string) (*Response, error) AddPlaylistItem(id string, args map[string]string) (*Response, error) DeletePlaylistItem(id string, itemID string, args map[string]string) (*Response, error) UpdatePlaylistItemNotes(id string, itemID string, args map[string]string) (*Response, error) @@ -199,6 +200,12 @@ func (c *standardHTTPClient) UpdatePlaylist(id string, args map[string]string) ( return c.requestAPI(http.MethodPut, "/playlists/{id}", []pathParam{{"id", id}}, nil, args) } +// DeletePlaylist: Delete a playlist. +// See https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id. +func (c *standardHTTPClient) DeletePlaylist(id string, args map[string]string) (*Response, error) { + return c.requestAPI(http.MethodDelete, "/playlists/{id}", []pathParam{{"id", id}}, nil, args) +} + // AddPlaylistItem: Add an episode or podcast to a playlist. // See https://www.listennotes.com/api/docs/#post-api-v2-playlists-id-items. func (c *standardHTTPClient) AddPlaylistItem(id string, args map[string]string) (*Response, error) { diff --git a/constants.go b/constants.go index 6f19a2b..7cc24d9 100644 --- a/constants.go +++ b/constants.go @@ -1,7 +1,7 @@ package listennotes // Version is the SDK version sent in the User-Agent header. -const Version = "3.0.0" +const Version = "3.1.0" // Base urls for access the available api endpoints const ( diff --git a/contract_test.go b/contract_test.go index e998a39..903a034 100644 --- a/contract_test.go +++ b/contract_test.go @@ -43,7 +43,7 @@ func loadSDKContract(t *testing.T) []sdkOperation { if err := json.Unmarshal(data, &contract); err != nil { t.Fatal(err) } - if contract.Version != Version || len(contract.Operations) != 30 { + if contract.Version != Version || len(contract.Operations) != 31 { t.Fatalf("unexpected contract version/method count: %s/%d", contract.Version, len(contract.Operations)) } return contract.Operations @@ -160,6 +160,39 @@ func TestNestedPathsAndEmptyValues(t *testing.T) { } } +func TestDeletePlaylistEncodesIDWithoutQueryOrBody(t *testing.T) { + for name, args := range map[string]map[string]string{"nil": nil, "empty": {}, "path field": {"id": "must-not-leak"}} { + t.Run(name, func(t *testing.T) { + id := "abc/def?# café%" + before := maps.Clone(args) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodDelete || r.URL.EscapedPath() != "/api/v2/playlists/"+url.PathEscape(id) || r.URL.RawQuery != "" { + t.Errorf("incorrect deletion request: %s %s", r.Method, r.URL) + } + body, _ := io.ReadAll(r.Body) + if len(body) != 0 || r.Header.Get("Content-Type") != "" { + t.Error("playlist deletion must not send a request body or content type") + } + w.Header().Set(ResponseHeaderKeyUsage, "12") + json.NewEncoder(w).Encode(map[string]interface{}{"id": id, "deleted": true}) + })) + defer server.Close() + response, err := NewClient("fixture-key", WithBaseURL(server.URL+"/api/v2")).DeletePlaylist(id, args) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 || response.Data["id"] != id || response.Data["deleted"] != true || response.Stats.Usage != 12 { + t.Fatalf("lost deletion response details: %+v", response) + } + if requests.Load() != 1 || !maps.Equal(args, before) { + t.Fatal("deletion retried or changed the caller's parameters") + } + }) + } +} + func TestWriteQueryAndBodyFieldsAreSeparated(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) @@ -177,7 +210,7 @@ func TestWriteQueryAndBodyFieldsAreSeparated(t *testing.T) { } func TestHTTPFailuresRetainDetailsAndNeverRetry(t *testing.T) { - for _, code := range []int{301, 307, 400, 401, 403, 404, 405, 418, 429, 500, 502, 503} { + for _, code := range []int{301, 302, 307, 308, 400, 401, 403, 404, 405, 418, 429, 500, 502, 503} { t.Run(fmt.Sprint(code), func(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -188,23 +221,30 @@ func TestHTTPFailuresRetainDetailsAndNeverRetry(t *testing.T) { fmt.Fprint(w, "exact server explanation") })) defer server.Close() - response, err := NewClient("", WithBaseURL(server.URL)).CreatePlaylist(map[string]string{"name": "fixture"}) - var apiError *APIError - if !errors.As(err, &apiError) || apiError.StatusCode != code || apiError.Body != "exact server explanation" || apiError.Headers.Get("X-Fixture") != "retained" { - t.Fatalf("missing HTTP error context: %v", err) - } - if response == nil || response.Stats.Usage != 10 || response.Body != apiError.Body || requests.Load() != 1 { - t.Fatal("lost response or retried write") - } - expected := errMap[code] - if expected == nil { - expected = ErrUnexpectedStatus - if code >= 500 { - expected = ErrInternalServerError + client := NewClient("", WithBaseURL(server.URL)) + for index, operation := range []string{"createPlaylist", "deletePlaylist"} { + args := map[string]string{"name": "fixture"} + if operation == "deletePlaylist" { + args = map[string]string{"id": "fixture"} + } + response, err := callSDKMethod(client, operation, args) + var apiError *APIError + if !errors.As(err, &apiError) || apiError.StatusCode != code || apiError.Body != "exact server explanation" || apiError.Headers.Get("X-Fixture") != "retained" { + t.Fatalf("missing HTTP error context: %v", err) + } + if response == nil || response.Stats.Usage != 10 || response.Body != apiError.Body || requests.Load() != int32(index+1) { + t.Fatal("lost response or retried write") + } + expected := errMap[code] + if expected == nil { + expected = ErrUnexpectedStatus + if code >= 500 { + expected = ErrInternalServerError + } + } + if !errors.Is(err, expected) { + t.Fatalf("wrong classification: %v", err) } - } - if !errors.Is(err, expected) { - t.Fatalf("wrong classification: %v", err) } }) } @@ -232,9 +272,15 @@ func TestDefaultRedirectsAndClientIsolation(t *testing.T) { if _, err := NewClient("fixture-key", WithBaseURL(redirect.URL)).Search(nil); !errors.Is(err, ErrUnexpectedStatus) || leaked.Load() != 0 { t.Fatalf("redirect followed: %v", err) } + if _, err := NewClient("fixture-key", WithBaseURL(redirect.URL)).DeletePlaylist("list", nil); !errors.Is(err, ErrUnexpectedStatus) || leaked.Load() != 0 { + t.Fatalf("deletion redirect followed: %v", err) + } var seen sync.Map server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen.Store(r.URL.Query().Get("q"), r.Header.Get(RequestHeaderKeyAPI)) + if r.Method == http.MethodDelete { + seen.Store(r.URL.Path, r.Header.Get(RequestHeaderKeyAPI)) + } fmt.Fprint(w, `{}`) })) defer server.Close() @@ -250,6 +296,9 @@ func TestDefaultRedirectsAndClientIsolation(t *testing.T) { if _, err := client.Search(map[string]string{"q": fmt.Sprint(i % 2)}); err != nil { t.Error(err) } + if _, err := client.DeletePlaylist(fmt.Sprint(i%2), nil); err != nil { + t.Error(err) + } }) } wg.Wait() @@ -259,6 +308,12 @@ func TestDefaultRedirectsAndClientIsolation(t *testing.T) { if key, _ := seen.Load("1"); key != "second" { t.Error("second key was overwritten") } + if key, _ := seen.Load("/playlists/0"); key != "first" { + t.Error("first deletion key was overwritten") + } + if key, _ := seen.Load("/playlists/1"); key != "second" { + t.Error("second deletion key was overwritten") + } } func TestTimeoutAndInvalidParameters(t *testing.T) { @@ -281,6 +336,9 @@ func TestTimeoutAndInvalidParameters(t *testing.T) { defer fast.Close() client = NewClient("", WithBaseURL(fast.URL), WithHTTPClient(nil), nil) for _, id := range []string{"", ".", ".."} { + if _, err := client.DeletePlaylist(id, nil); err == nil { + t.Errorf("accepted invalid playlist deletion identifier %q", id) + } if _, err := client.DeletePlaylistItem(id, "23", nil); err == nil { t.Errorf("accepted invalid playlist identifier %q", id) } diff --git a/examples_test.go b/examples_test.go index 0c611f9..2ff6490 100644 --- a/examples_test.go +++ b/examples_test.go @@ -277,6 +277,16 @@ func ExampleHTTPClient_UpdatePlaylist() { fmt.Println(response.ToJSON()) } +func ExampleHTTPClient_DeletePlaylist() { + client := listennotes.NewClient("") + response, err := client.DeletePlaylist("m1pe7z60bsw", nil) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(response.ToJSON()) +} + func ExampleHTTPClient_AddPlaylistItem() { client := listennotes.NewClient("") response, err := client.AddPlaylistItem("m1pe7z60bsw", map[string]string{"episode_id": "e53e6992a5b7492f9ea6fcd85d9ad95f", "notes": "Worth a listen."}) diff --git a/integration_test.go b/integration_test.go index 785ba83..8dbceb3 100644 --- a/integration_test.go +++ b/integration_test.go @@ -89,6 +89,13 @@ func TestMockIntegrationAllMethods(t *testing.T) { if response.Data["deleted"] != true { t.Error("missing deletion confirmation") } + case "deletePlaylist": + if response.Data["deleted"] != true || response.Data["id"] != op.ExampleParams["id"] { + t.Error("incorrect playlist deletion confirmation") + } + if guard.last.URL.Path != "/api/v2/playlists/m1pe7z60bsw" || guard.last.URL.RawQuery != "" || guard.body != "" || guard.last.Header.Get("Content-Type") != "" { + t.Errorf("incorrect playlist deletion request: %s", guard.last.URL) + } } }) time.Sleep(100 * time.Millisecond)