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:
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user