fix(shares): treat shares.info's empty-body response as no-share, not an error

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.
This commit is contained in:
2026-08-14 17:49:37 +01:00
parent 43f9d6052f
commit 1476c6c6ba
2 changed files with 65 additions and 5 deletions
@@ -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<Body: Encodable, Response: Decodable>(_ 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<Response>.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<Body: Encodable>(_ path: String, body: Body) async throws {
@@ -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 = """