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? {
|
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||||
do {
|
try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
|
||||||
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
|
|
||||||
} catch OutlineAPIError.notFound {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
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": ... }`
|
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
||||||
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
||||||
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
||||||
|
|||||||
@@ -792,6 +792,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertNil(share)
|
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 {
|
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
Reference in New Issue
Block a user