diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index f162b01..0d44956 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -32,7 +32,7 @@ jobs: sdk_version=$(ruby -r ./lib/version -e 'print PodcastApi::VERSION') gem install "pkg/podcast_api-$sdk_version.gem" --no-document cd /tmp - ruby -r podcast_api -e 'abort unless PodcastApi::VERSION == ARGV.fetch(0) && PodcastApi::Client.new.respond_to?(:add_playlist_item)' "$sdk_version" + ruby -r podcast_api -e 'abort unless PodcastApi::VERSION == ARGV.fetch(0) && PodcastApi::Client.new.respond_to?(:delete_playlist)' "$sdk_version" integration: name: Integration (public mock API) runs-on: ubuntu-latest diff --git a/Gemfile.lock b/Gemfile.lock index 88f8c01..78f46b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - podcast_api (3.0.0) + podcast_api (3.1.0) httparty (>= 0.24.1, < 0.25) GEM diff --git a/README.md b/README.md index af000a7..490d1b3 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ If you have any questions, please contact [hello@listennotes.com](hello@listenno - [`fetch_podcasts_by_domain`](#fetch_podcasts_by_domain) — `GET /podcasts/domains/{domain_name}` - [`create_playlist`](#create_playlist) — `POST /playlists` - [`update_playlist`](#update_playlist) — `PUT /playlists/{id}` +- [`delete_playlist`](#delete_playlist) — `DELETE /playlists/{id}` - [`add_playlist_item`](#add_playlist_item) — `POST /playlists/{id}/items` - [`delete_playlist_item`](#delete_playlist_item) — `DELETE /playlists/{id}/items/{item_id}` - [`update_playlist_item_notes`](#update_playlist_item_notes) — `PUT /playlists/{id}/items/{item_id}` @@ -791,6 +792,30 @@ puts response.parsed_response [Full API documentation](https://www.listennotes.com/api/docs/#put-api-v2-playlists-id) +### delete_playlist + +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. + +```ruby +require "podcast_api" + +client = PodcastApi::Client.new(api_key: ENV["LISTEN_API_KEY"]) +response = client.delete_playlist(**{ + 'id' => 'm1pe7z60bsw' +}) +puts response.parsed_response +``` + +[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id) + ### add_playlist_item Add an episode or podcast to a playlist. diff --git a/lib/api-contract.json b/lib/api-contract.json index 6d812f8..6fbfffe 100644 --- a/lib/api-contract.json +++ b/lib/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": "delete_playlist", + "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": "add_playlist_item", diff --git a/lib/api_methods.rb b/lib/api_methods.rb index 6cf2cc7..b09501f 100644 --- a/lib/api_methods.rb +++ b/lib/api_methods.rb @@ -138,6 +138,11 @@ def update_playlist(**kwargs) [], kwargs) end + def delete_playlist(**kwargs) + request_api('DELETE', '/playlists/{id}', + [], kwargs) + end + def add_playlist_item(**kwargs) request_api('POST', '/playlists/{id}/items', [], kwargs) diff --git a/lib/version.rb b/lib/version.rb index 3023265..e45d6b8 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PodcastApi - VERSION = '3.0.0' + VERSION = '3.1.0' end diff --git a/tests/integration/mock_api_test.rb b/tests/integration/mock_api_test.rb index 0aff1b8..3664db0 100644 --- a/tests/integration/mock_api_test.rb +++ b/tests/integration/mock_api_test.rb @@ -78,6 +78,10 @@ def assert_response(response, method, path, status = 200) elsif operation['operationId'] == 'deletePlaylistItem' assert_equal true, payload.fetch('deleted') assert_kind_of Integer, payload.fetch('id') + elsif operation['operationId'] == 'deletePlaylist' + assert_equal true, payload.fetch('deleted') + assert_equal operation.fetch('example_params').fetch('id'), payload.fetch('id') + assert_nil response.request.options[:body] end end end diff --git a/tests/podcast_api_test.rb b/tests/podcast_api_test.rb index 13994e3..0ff9c52 100644 --- a/tests/podcast_api_test.rb +++ b/tests/podcast_api_test.rb @@ -79,7 +79,28 @@ def test_omitted_notes_are_not_sent assert_requested stub, times: 1 end + def test_delete_playlist_encodes_id_without_query_or_body + params = {id: 'a/b ?#%+é'} + before = params.dup + stub = stub_request(:delete, MOCK + '/playlists/a%2Fb%20%3F%23%25%2B%C3%A9') + .with(query: {}).with { |req| req.body.nil? || req.body.empty? } + .to_return(body: JSON.generate(id: params[:id], deleted: true), + headers: {'Content-Type' => 'application/json'}) + response = @client.delete_playlist(**params) + assert_kind_of HTTParty::Response, response + assert_equal({'id' => params[:id], 'deleted' => true}, response.parsed_response) + assert_equal before, params + assert_nil response.request.options[:body] + assert_false response.request.options[:headers].key?('Content-Type') + assert_requested stub, times: 1 + end + def test_missing_path_parameters_fail_before_network_access + [{}, {id: nil}, {id: ''}].each do |params| + error = assert_raise(PodcastApi::InvalidRequestError) { @client.delete_playlist(**params) } + assert_equal 'Missing required path parameter: id', error.message + assert_nil error.response + end [{}, {id: nil}, {id: ''}, {id: 'abc'}, {id: 'abc', item_id: nil}, {id: 'abc', item_id: ''}].each do |params| error = assert_raise(PodcastApi::InvalidRequestError) { @client.delete_playlist_item(**params) } assert_match(/Missing required path parameter:/, error.message) @@ -134,14 +155,17 @@ def test_invalid_timeouts 403 => PodcastApi::PermissionDeniedError, 404 => PodcastApi::NotFoundError, 429 => PodcastApi::RateLimitError, 500 => PodcastApi::PodcastApiError}.each do |status, klass| define_method("test_error_#{status}_preserves_server_message_and_response") do - stub = stub_request(:post, MOCK + '/playlists').to_return( - status: status, body: '{"error":"Specific API error"}', headers: {'X-ListenAPI-Usage' => '5'} - ) - error = assert_raise(klass) { @client.create_playlist(name: 'test') } - assert_match(/Specific API error/, error.message) - assert_equal status, error.response.code - assert_equal '5', error.response.headers['X-ListenAPI-Usage'] - assert_requested stub, times: 1 + [[:post, '/playlists', :create_playlist, {name: 'test'}], + [:delete, '/playlists/abc', :delete_playlist, {id: 'abc'}]].each do |method, path, func, params| + stub = stub_request(method, MOCK + path).to_return( + status: status, body: '{"error":"Specific API error"}', headers: {'X-ListenAPI-Usage' => '5'} + ) + error = assert_raise(klass) { @client.public_send(func, **params) } + assert_match(/Specific API error/, error.message) + assert_equal status, error.response.code + assert_equal '5', error.response.headers['X-ListenAPI-Usage'] + assert_requested stub, times: 1 + end end end @@ -164,11 +188,17 @@ def test_all_success_statuses_keep_response_envelope def test_redirect_is_not_followed [301, 302, 303, 307, 308].each do |status| + WebMock.reset! stub_request(:post, MOCK + '/playlists').to_return(status: status, headers: {'Location' => PROD + '/playlists'}) error = assert_raise(PodcastApi::PodcastApiError) { @client.create_playlist(name: 'test') } assert_equal status, error.response.code + stub = stub_request(:delete, MOCK + '/playlists/abc') + .to_return(status: status, headers: {'Location' => PROD + '/playlists/abc'}) + error = assert_raise(PodcastApi::PodcastApiError) { @client.delete_playlist(id: 'abc') } + assert_equal status, error.response.code + assert_requested stub, times: 1 + assert_not_requested :any, /listen-api\.listennotes\.com/ end - assert_not_requested :any, /listen-api\.listennotes\.com/ end def test_connection_failures_are_wrapped_without_retries @@ -178,6 +208,9 @@ def test_connection_failures_are_wrapped_without_retries stub = stub_request(:put, MOCK + '/playlists/abc').to_raise(klass.new('connection failed')) assert_raise(PodcastApi::APIConnectionError) { @client.update_playlist(id: 'abc', description: '') } assert_requested stub, times: 1 + stub = stub_request(:delete, MOCK + '/playlists/abc').to_raise(klass.new('connection failed')) + assert_raise(PodcastApi::APIConnectionError) { @client.delete_playlist(id: 'abc') } + assert_requested stub, times: 1 end end