diff --git a/PodcastAPI.podspec b/PodcastAPI.podspec index 7e7f5f2..bfeb22a 100644 --- a/PodcastAPI.podspec +++ b/PodcastAPI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "PodcastAPI" - s.version = "3.0.0" + s.version = "3.1.0" s.summary = "The Official Swift Library for the Listen Notes Podcast API." s.homepage = "https://www.listennotes.com/api/" s.license = { :type => "MIT", :file => "LICENSE" } diff --git a/README.md b/README.md index f0e49b2..512590c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ If you have any questions, please contact [hello@listennotes.com](hello@listenno ## Installation -PodcastAPI 3.0.0 requires Swift 6.0+, iOS 16+ or macOS 13+. Swift Package Manager +PodcastAPI 3.1.0 requires Swift 6.0+, iOS 16+ or macOS 13+. Swift Package Manager also supports Linux. Both distribution methods expose the same `PodcastAPI` module. There are no external runtime package dependencies. The bundled SwiftyJSON source is based on upstream 5.0.2 with a Linux number-comparison compatibility fix and @@ -26,12 +26,12 @@ Find releases and compatibility information on In Xcode, choose **File > Add Package Dependency**, enter `https://github.com/ListenNotes/podcast-api-swift.git`, and select **Up to Next Major -Version** starting at **3.0.0**. Add the **PodcastAPI** library product to your app target. +Version** starting at **3.1.0**. Add the **PodcastAPI** library product to your app target. For a `Package.swift` manifest, add this to the package's `dependencies`: ```swift -.package(url: "https://github.com/ListenNotes/podcast-api-swift.git", from: "3.0.0") +.package(url: "https://github.com/ListenNotes/podcast-api-swift.git", from: "3.1.0") ``` Add `.product(name: "PodcastAPI", package: "podcast-api-swift")` to your target's @@ -40,7 +40,7 @@ Add `.product(name: "PodcastAPI", package: "podcast-api-swift")` to your target' ### CocoaPods ```ruby -pod 'PodcastAPI', '~> 3.0' +pod 'PodcastAPI', '~> 3.1' ``` ## Usage @@ -163,6 +163,7 @@ The SDK builds and tests independently of that repository. - [`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}` @@ -861,6 +862,34 @@ struct Example { [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. + +```swift +import Foundation +import PodcastAPI + +@main +struct Example { + static func main() async throws { + let client = Client(apiKey: ProcessInfo.processInfo.environment["LISTEN_API_KEY", default: ""]) + let response = try await client.deletePlaylist(parameters: ["id": "m1pe7z60bsw"]) + print(response.toJson()?.description ?? "") + } +} +``` + +[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/Sources/ExampleIOSApp/Podfile.lock b/Sources/ExampleIOSApp/Podfile.lock index 3903eb6..f83593d 100644 --- a/Sources/ExampleIOSApp/Podfile.lock +++ b/Sources/ExampleIOSApp/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - PodcastAPI (3.0.0) + - PodcastAPI (3.1.0) DEPENDENCIES: - PodcastAPI (from `../..`) @@ -9,7 +9,7 @@ EXTERNAL SOURCES: :path: "../.." SPEC CHECKSUMS: - PodcastAPI: 59ee436372898e7428234a3364d8ed7120df585d + PodcastAPI: 7623bb0ec28b47456b410a248793db35cb46bb1f PODFILE CHECKSUM: a0f3ac29f2e50f06e13ba6997a45f525dbae37c7 diff --git a/Sources/PodcastAPI/ApiMethods.swift b/Sources/PodcastAPI/ApiMethods.swift index b55e20b..9c984a9 100644 --- a/Sources/PodcastAPI/ApiMethods.swift +++ b/Sources/PodcastAPI/ApiMethods.swift @@ -383,6 +383,20 @@ extension Client { pathNames: ["id"], queryNames: [], parameters: parameters, completion: completion) } + /// Delete a playlist. (DELETE /playlists/{id}) + public func deletePlaylist(parameters: [String: String] = [:]) async throws -> ApiResponse { + try await request(path: "/playlists/{id}", method: "DELETE", + pathNames: ["id"], queryNames: [], parameters: parameters) + } + + /// Delete a playlist. (DELETE /playlists/{id}) + @discardableResult + public func deletePlaylist(parameters: [String: String] = [:], + completion: @escaping @Sendable (ApiResponse) -> Void) -> URLSessionDataTask? { + request(path: "/playlists/{id}", method: "DELETE", + pathNames: ["id"], queryNames: [], parameters: parameters, completion: completion) + } + /// Add an episode or podcast to a playlist. (POST /playlists/{id}/items) public func addPlaylistItem(parameters: [String: String] = [:]) async throws -> ApiResponse { try await request(path: "/playlists/{id}/items", method: "POST", diff --git a/Sources/PodcastAPI/PodcastAPI.swift b/Sources/PodcastAPI/PodcastAPI.swift index 3ff0faa..f6292e6 100644 --- a/Sources/PodcastAPI/PodcastAPI.swift +++ b/Sources/PodcastAPI/PodcastAPI.swift @@ -10,7 +10,7 @@ let DEFAULT_USER_AGENT = "podcast-api-swift \(Client.version)" /// A reusable Listen API client. An empty API key selects the public mock service. /// Configuration setters are synchronized; each request uses a configuration snapshot. public final class Client: @unchecked Sendable { - public static let version = "3.0.0" + public static let version = "3.1.0" private let apiKey: String private let baseURL: URL private let session: URLSession diff --git a/Tests/PodcastAPITests/MethodDispatch.swift b/Tests/PodcastAPITests/MethodDispatch.swift index 77a3c7c..7b064cd 100644 --- a/Tests/PodcastAPITests/MethodDispatch.swift +++ b/Tests/PodcastAPITests/MethodDispatch.swift @@ -61,6 +61,8 @@ func callMethod(_ operation: String, client: Client, parameters: [String: String return try await client.createPlaylist(parameters: parameters) case "updatePlaylist": return try await client.updatePlaylist(parameters: parameters) + case "deletePlaylist": + return try await client.deletePlaylist(parameters: parameters) case "addPlaylistItem": return try await client.addPlaylistItem(parameters: parameters) case "deletePlaylistItem": @@ -129,6 +131,8 @@ func callMethod(_ operation: String, client: Client, parameters: [String: String return client.createPlaylist(parameters: parameters, completion: completion) case "updatePlaylist": return client.updatePlaylist(parameters: parameters, completion: completion) + case "deletePlaylist": + return client.deletePlaylist(parameters: parameters, completion: completion) case "addPlaylistItem": return client.addPlaylistItem(parameters: parameters, completion: completion) case "deletePlaylistItem": diff --git a/Tests/PodcastAPITests/MockIntegrationTests.swift b/Tests/PodcastAPITests/MockIntegrationTests.swift index ad447c5..f1fb783 100644 --- a/Tests/PodcastAPITests/MockIntegrationTests.swift +++ b/Tests/PodcastAPITests/MockIntegrationTests.swift @@ -31,6 +31,16 @@ final class MockIntegrationTests: XCTestCase, @unchecked Sendable { let response = try await callMethod(op.id, client: client, parameters: op.example) XCTAssertTrue((200..<300).contains(response.statusCode ?? 0), op.id) XCTAssertNotNil(response.toJson(), op.id) + if op.id == "deletePlaylist" { + XCTAssertEqual(response.statusCode, 200) + XCTAssertEqual(response.toJson()?["id"].string, op.example["id"]) + XCTAssertEqual(response.toJson()?["deleted"].bool, true) + XCTAssertEqual(response.request?.httpMethod, "DELETE") + XCTAssertEqual(response.request?.url?.path, "/api/v2/playlists/m1pe7z60bsw") + XCTAssertNil(response.request?.url?.query) + XCTAssertNil(response.request?.httpBody) + XCTAssertNil(response.request?.value(forHTTPHeaderField: "Content-Type")) + } } } diff --git a/Tests/PodcastAPITests/PodcastAPITests.swift b/Tests/PodcastAPITests/PodcastAPITests.swift index 1076ca3..3ae50c9 100644 --- a/Tests/PodcastAPITests/PodcastAPITests.swift +++ b/Tests/PodcastAPITests/PodcastAPITests.swift @@ -117,7 +117,7 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable { func testEveryAsyncMethodMatchesContract() async throws { let operations = try contract() - XCTAssertEqual(operations.count, 30) + XCTAssertEqual(operations.count, 31) for op in operations { let fixture = Fixture { request in XCTAssertEqual(request.httpMethod, op.method, op.id) @@ -157,6 +157,35 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable { } } + func testDeletePlaylistEncodesIDWithoutQueryOrBody() async throws { + let requests = expectation(description: "one request per deletion call") + requests.expectedFulfillmentCount = 2 + requests.assertForOverFulfill = true + let fixture = Fixture { request in + requests.fulfill() + let components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)! + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertEqual(components.percentEncodedPath, "/api/v2/playlists/a%2Fb%3F%23%25%C3%A9") + XCTAssertNil(components.query) + XCTAssertNil(requestBody(request)) + XCTAssertNil(request.value(forHTTPHeaderField: "Content-Type")) + return .response(200, ["X-ListenAPI-Usage": "12"], Data(#"{"id":"a/b?#%é","deleted":true}"#.utf8)) + } + let parameters = ["id": "a/b?#%é"] + let asyncResponse = try await fixture.client.deletePlaylist(parameters: parameters) + let callbackResponse: ApiResponse = await withCheckedContinuation { continuation in + fixture.client.deletePlaylist(parameters: parameters) { continuation.resume(returning: $0) } + } + for response in [asyncResponse, callbackResponse] { + XCTAssertNil(response.error) + XCTAssertEqual(response.statusCode, 200) + XCTAssertEqual(response.getUsage(), 12) + XCTAssertEqual(response.toJson()?["id"].string, parameters["id"]) + XCTAssertEqual(response.toJson()?["deleted"].bool, true) + } + await fulfillment(of: [requests], timeout: 3) + } + func testNestedIdentifiersAndEmptyFieldsAreEncodedOnce() async throws { let fixture = Fixture { request in let components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)! @@ -195,7 +224,7 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable { XCTAssertEqual(a.value(forHTTPHeaderField: "X-ListenAPI-Key"), "first") XCTAssertEqual(b.value(forHTTPHeaderField: "X-ListenAPI-Key"), "second") XCTAssertEqual(a.value(forHTTPHeaderField: "User-Agent"), "custom") - XCTAssertEqual(b.value(forHTTPHeaderField: "User-Agent"), "podcast-api-swift 3.0.0") + XCTAssertEqual(b.value(forHTTPHeaderField: "User-Agent"), "podcast-api-swift \(Client.version)") XCTAssertEqual(a.timeoutInterval, 7) XCTAssertEqual(b.timeoutInterval, 30) let mock = Client(apiKey: " \n") @@ -214,6 +243,18 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable { func testInvalidIdentifiersFailBeforeNetwork() async throws { let fixture = Fixture { _ in XCTFail("Must not send an invalid request"); return .failure(URLError(.badURL)) } + for values in [[:], ["id": ""], ["id": "."], ["id": ".."]] { + do { + _ = try await fixture.client.deletePlaylist(parameters: values) + XCTFail("Expected invalid playlist identifier") + } catch { XCTAssertEqual(error as? PodcastApiError, .invalidRequestError) } + let response: ApiResponse = await withCheckedContinuation { continuation in + let task = fixture.client.deletePlaylist(parameters: values) { continuation.resume(returning: $0) } + XCTAssertNil(task) + } + XCTAssertEqual(response.error, .invalidRequestError) + XCTAssertNil(response.request) + } for values in [[:], ["id": ""], ["id": "."], ["id": ".."], ["id": "ok"]] { do { _ = try await fixture.client.deletePlaylistItem(parameters: values) @@ -228,21 +269,38 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable { } func testHTTPFailuresPreserveStatusHeadersAndBody() async throws { - let statuses: [(Int, PodcastApiError)] = [(302, .unexpectedResponseError), (400, .invalidRequestError), + let statuses: [(Int, PodcastApiError)] = [(302, .unexpectedResponseError), (307, .unexpectedResponseError), + (308, .unexpectedResponseError), (400, .invalidRequestError), (401, .authenticationError), (403, .permissionDeniedError), (404, .notFoundError), (422, .invalidRequestError), (429, .tooManyRequestsError), (500, .serverError), (503, .serverError)] for (status, kind) in statuses { - let fixture = Fixture { _ in .response(status, ["X-ListenAPI-Usage": "123"], Data("{\"error\":\"Exact reason\"}".utf8)) } - do { - _ = try await fixture.client.fetchMyPlaylists() - XCTFail("Expected HTTP error") - } catch let error as ApiRequestError { - XCTAssertEqual(error.response.error, kind) - XCTAssertEqual(error.response.statusCode, status) - XCTAssertEqual(error.response.getUsage(), 123) - XCTAssertEqual(error.localizedDescription, "Exact reason") - XCTAssertNotNil(error.response.data) + let requests = expectation(description: "failed requests are not retried") + requests.expectedFulfillmentCount = 3 + requests.assertForOverFulfill = true + let fixture = Fixture { _ in + requests.fulfill() + return .response(status, ["X-ListenAPI-Usage": "123"], Data("{\"error\":\"Exact reason\"}".utf8)) + } + for operation in ["getPlaylists", "deletePlaylist"] { + do { + _ = try await callMethod(operation, client: fixture.client, parameters: ["id": "list"]) + XCTFail("Expected HTTP error") + } catch let error as ApiRequestError { + XCTAssertEqual(error.response.error, kind) + XCTAssertEqual(error.response.statusCode, status) + XCTAssertEqual(error.response.getUsage(), 123) + XCTAssertEqual(error.localizedDescription, "Exact reason") + XCTAssertNotNil(error.response.data) + } + } + let response: ApiResponse = await withCheckedContinuation { continuation in + fixture.client.deletePlaylist(parameters: ["id": "list"]) { continuation.resume(returning: $0) } } + XCTAssertEqual(response.error, kind) + XCTAssertEqual(response.statusCode, status) + XCTAssertEqual(response.getUsage(), 123) + XCTAssertEqual(response.toJson()?["error"].string, "Exact reason") + await fulfillment(of: [requests], timeout: 3) } } @@ -286,26 +344,30 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable { } func testAsyncCancellation() async throws { - let started = expectation(description: "started") - let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation } - let task = Task { try await fixture.client.search() } - await fulfillment(of: [started], timeout: 3) - task.cancel() - do { _ = try await task.value; XCTFail("Expected cancellation") } - catch { XCTAssertTrue(error is CancellationError) } + for operation in ["search", "deletePlaylist"] { + let started = expectation(description: "started") + let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation } + let task = Task { try await callMethod(operation, client: fixture.client, parameters: ["id": "list"]) } + await fulfillment(of: [started], timeout: 3) + task.cancel() + do { _ = try await task.value; XCTFail("Expected cancellation") } + catch { XCTAssertTrue(error is CancellationError) } + } } func testCallbackCancellation() async throws { - let started = expectation(description: "started") - let completed = expectation(description: "cancelled") - let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation } - let task = fixture.client.search { response in - XCTAssertEqual((response.httpError as? URLError)?.code, .cancelled) - completed.fulfill() + for operation in ["search", "deletePlaylist"] { + let started = expectation(description: "started") + let completed = expectation(description: "cancelled") + let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation } + let task = callMethod(operation, client: fixture.client, parameters: ["id": "list"]) { response in + XCTAssertEqual((response.httpError as? URLError)?.code, .cancelled) + completed.fulfill() + } + await fulfillment(of: [started], timeout: 3) + task?.cancel() + await fulfillment(of: [completed], timeout: 3) } - await fulfillment(of: [started], timeout: 3) - task?.cancel() - await fulfillment(of: [completed], timeout: 3) } func testSynchronousCallbackFinishesBeforeReturn() { diff --git a/Tests/PodcastAPITests/api-contract.json b/Tests/PodcastAPITests/api-contract.json index 6399c18..d2f568a 100644 --- a/Tests/PodcastAPITests/api-contract.json +++ b/Tests/PodcastAPITests/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",