From 1476c6c6ba08c62884a4140c3480a668494dcf8f Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 17:49:37 +0100 Subject: [PATCH] fix(shares): treat shares.info's empty-body response as no-share, not an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-shape fix wasn't the actual bug — confirmed live that shares.info returns HTTP 200 with a completely empty body (not a 404, not {data: null}) when no share exists yet for a document. post(_:) assumed any 2xx had a non-empty envelope to decode, so this hit JSONDecoder with zero bytes and threw "not valid JSON" on every first share-sheet open. Added postOptional(_:body:), mirroring post/postForSuccess, that treats an empty body the same as a 404: nil, not a decode failure. shareInfo calls it directly instead of wrapping post() with a notFound-only catch. --- .../OutlineKit/LiveOutlineAPIClient.swift | 52 +++++++++++++++++-- .../LiveOutlineAPIClientTests.swift | 18 +++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 77154c4..39d29be 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -121,11 +121,7 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } public func shareInfo(documentId: String) async throws -> OutlineShare? { - do { - return try await post("shares.info", body: ShareInfoRequest(documentId: documentId)) - } catch OutlineAPIError.notFound { - return nil - } + try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId)) } public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { @@ -251,6 +247,52 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } } + /// Like `post(_:body:)`, but for endpoints where "no result" comes back + /// as a 200 with a completely empty body instead of a real 404 — + /// confirmed against a live server for `shares.info` (no share yet for + /// a given document). A 404 is still treated as nil too. + private func postOptional(_ path: String, body: Body) async throws -> Response? { + guard let token = try? tokenStore.token() else { + throw OutlineAPIError.tokenUnavailable + } + + var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await httpClient.send(request) + } catch let error as OutlineAPIError { + throw error + } catch { + throw OutlineAPIError.transport(error) + } + + guard (200...299).contains(response.statusCode) else { + let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data) + switch response.statusCode { + case 401: + throw OutlineAPIError.unauthorized + case 404: + return nil + default: + throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error) + } + } + + guard !data.isEmpty else { return nil } + + do { + return try decoder.decode(OutlineEnvelope.self, from: data).data + } catch { + throw OutlineAPIError.decoding(error) + } + } + /// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }` /// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit. private func postForSuccess(_ path: String, body: Body) async throws { diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 712683d..eae2b40 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -792,6 +792,24 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertNil(share) } + func testShareInfoReturnsNilOnEmptyBody() async throws { + // Confirmed against a live server: shares.info returns a 200 with a + // completely empty body (not a 404) when no share exists yet for a + // given document. + let httpClient = MockHTTPClient() + httpClient.responseData = Data() + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let share = try await client.shareInfo(documentId: "doc-1") + + XCTAssertNil(share) + } + func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """