mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 21:52:51 +00:00
Let an account change the playlists it owns
Reading a server-side playlist was all the app could do: the write half of both APIs was missing, and the only thing that knew which credential to use was the subscription layer. So playlists arrive here as an account concern — hand AccountService an account and it finds the instance, reads the secret, refuses what the backend cannot do, and records what the answer implies about the login. Capabilities decide, never the instance type: a privacy setting sent to a backend that has none is dropped rather than refused, because the playlist is still perfectly creatable there. Every call captures the credential it used and re-reads it before recording an outcome, so a late 401 cannot reject the login the user just fixed, nor a late success clear a rejection it never earned. Removal is where the backends stop being alike. Invidious names the row itself with an indexId that survives refetches, so it removes directly. Piped removes by position, and a position is only true of the fetch that minted it — so the playlist is refetched and the position checked to still hold the same video before anything is deleted. That narrows the window; it cannot close it, and only the refetch afterwards shows what really happened. The cache exists so that refetch cannot be forgotten. Every mutation goes through it and republishes what it touched — on failure as much as on success, because a write that reported failure may still have landed — and nothing is ever edited optimistically. It is in memory alone: the data belongs to a login rather than to this device, and a stored Piped entry id would be aimed at whatever occupies that slot next launch. A per-account generation fences results that come back after the account was cleared, since clearing has never cancelled the request that was filling it. Along the way, clients minted per instance sent no User-Agent at all — instances that whitelist the Yattee prefix answered them with a 401 the shared client never earned. The factory now bakes the app's User-Agent in at construction, where no request can outrun it.
This commit is contained in:
@@ -243,6 +243,232 @@ actor ContentService: ContentServiceProtocol {
|
||||
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
|
||||
}
|
||||
|
||||
// MARK: - Authenticated Playlists
|
||||
|
||||
/// Fetches the playlists the credential's owner has on the instance.
|
||||
func userPlaylists(instance: Instance, credential: String) async throws -> [Playlist] {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return try await invidiousAPI(for: instance).userPlaylists(instance: instance, sid: credential)
|
||||
case .piped:
|
||||
return try await pipedAPI(for: instance).userPlaylists(instance: instance, authToken: credential)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches one of the credential owner's playlists along with the per-entry
|
||||
/// identities its rows can be removed by.
|
||||
func userPlaylistSnapshot(
|
||||
id: String,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws -> RemotePlaylistSnapshot {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return try await invidiousAPI(for: instance).userPlaylistSnapshot(
|
||||
id: id,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
case .piped:
|
||||
return try await pipedAPI(for: instance).userPlaylistSnapshot(
|
||||
id: id,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a playlist for the credential's owner.
|
||||
///
|
||||
/// The returned playlist is the little the server says about what it just
|
||||
/// made — an id and a title. Nothing else is invented here: callers refetch
|
||||
/// the list, which is the only description that is actually the server's.
|
||||
/// - Parameters:
|
||||
/// - privacy: Ignored by backends without a visibility concept (Piped),
|
||||
/// and defaulted to private on those that have one when not given.
|
||||
func createPlaylist(
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws -> Playlist {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let api = await invidiousAPI(for: instance)
|
||||
let playlist = try await api.createPlaylist(
|
||||
title: title,
|
||||
privacy: privacy ?? .private,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
// Creation takes no description, so a wanted one is a second call.
|
||||
// Its failure is the caller's to see: a playlist that exists with
|
||||
// the wrong description is not a silent success.
|
||||
if let description, !description.isEmpty {
|
||||
try await api.updatePlaylist(
|
||||
id: playlist.id.playlistID,
|
||||
title: title,
|
||||
description: description,
|
||||
privacy: privacy ?? .private,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
}
|
||||
return playlist
|
||||
|
||||
case .piped:
|
||||
let api = await pipedAPI(for: instance)
|
||||
let playlistID = try await api.createPlaylist(
|
||||
name: title,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
if let description, !description.isEmpty {
|
||||
try await api.updatePlaylistDescription(
|
||||
id: playlistID,
|
||||
description: description,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
}
|
||||
return Playlist(id: .global(playlistID), title: title, description: description)
|
||||
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits one of the credential owner's playlists.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - description: `nil` leaves whatever the playlist has.
|
||||
/// - privacy: `nil` keeps the private default on backends that have
|
||||
/// visibility; ignored entirely on those that do not.
|
||||
func updatePlaylist(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
try await invidiousAPI(for: instance).updatePlaylist(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
privacy: privacy ?? .private,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
|
||||
case .piped:
|
||||
// Two calls, no transaction: Piped edits the name and the
|
||||
// description through separate endpoints. The rename goes first so
|
||||
// a failure of the second leaves the visible half applied, and the
|
||||
// caller's refetch shows exactly what landed.
|
||||
let api = await pipedAPI(for: instance)
|
||||
try await api.renamePlaylist(
|
||||
id: id,
|
||||
newName: title,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
if let description {
|
||||
try await api.updatePlaylistDescription(
|
||||
id: id,
|
||||
description: description,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes one of the credential owner's playlists.
|
||||
func deletePlaylist(id: String, instance: Instance, credential: String) async throws {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
try await invidiousAPI(for: instance).deletePlaylist(id: id, instance: instance, sid: credential)
|
||||
case .piped:
|
||||
try await pipedAPI(for: instance).deletePlaylist(id: id, instance: instance, authToken: credential)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a video to one of the credential owner's playlists.
|
||||
func addVideoToPlaylist(
|
||||
playlistID: String,
|
||||
videoID: String,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
try await invidiousAPI(for: instance).addVideoToPlaylist(
|
||||
playlistID: playlistID,
|
||||
videoID: videoID,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
case .piped:
|
||||
try await pipedAPI(for: instance).addToPlaylist(
|
||||
id: playlistID,
|
||||
videoID: videoID,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes one row from one of the credential owner's playlists.
|
||||
///
|
||||
/// The entry id says which backend minted it, and it has to be the one this
|
||||
/// instance runs: an Invidious `indexId` means nothing to Piped, and a
|
||||
/// Piped position sent to Invidious would be a *different row* rather than
|
||||
/// an error, so a mismatch is refused rather than translated.
|
||||
func removeEntry(
|
||||
_ entryID: RemotePlaylistEntryID,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
switch (entryID, instance.type) {
|
||||
case (.invidious(let playlistID, let indexID), .invidious):
|
||||
try await invidiousAPI(for: instance).removeVideoFromPlaylist(
|
||||
playlistID: playlistID,
|
||||
indexID: indexID,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
|
||||
case (.piped(let playlistID, let position), .piped):
|
||||
try await pipedAPI(for: instance).removeFromPlaylist(
|
||||
id: playlistID,
|
||||
index: position,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
|
||||
case (_, .invidious), (_, .piped):
|
||||
throw APIError.invalidRequest
|
||||
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/
|
||||
/// so the server caches the file on disk; other backends apply Invidious URL rewriting).
|
||||
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
@@ -362,6 +588,11 @@ actor ContentService: ContentServiceProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// The real transport behind ``AccountService``. Declared here rather than on
|
||||
/// the protocol's own file so the conformance is checked next to the methods
|
||||
/// that satisfy it; the protocol exists so tests can script a server.
|
||||
extension ContentService: RemotePlaylistTransport {}
|
||||
|
||||
// MARK: - Search Result
|
||||
|
||||
enum OrderedSearchItem: Sendable {
|
||||
|
||||
@@ -648,6 +648,168 @@ actor InvidiousAPI: InstanceAPI {
|
||||
customHeaders: ["Cookie": "SID=\(sid)"]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Playlist Management
|
||||
|
||||
/// Creates a playlist owned by the logged-in user.
|
||||
///
|
||||
/// Invidious answers 201 with `{"title", "playlistId"}` — not the whole
|
||||
/// playlist — so the returned value carries the new id and the title that
|
||||
/// was asked for, and nothing else. Callers that need the rest refetch.
|
||||
/// - Parameters:
|
||||
/// - title: The playlist title
|
||||
/// - privacy: Who may see it. Required by the server, which rejects the
|
||||
/// request outright when it is missing.
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
/// - Returns: The created playlist, with no videos
|
||||
func createPlaylist(
|
||||
title: String,
|
||||
privacy: PlaylistPrivacy,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws -> Playlist {
|
||||
struct CreateRequest: Encodable, Sendable {
|
||||
let title: String
|
||||
let privacy: String
|
||||
}
|
||||
|
||||
struct CreateResponse: Decodable, Sendable {
|
||||
let playlistId: String
|
||||
let title: String?
|
||||
}
|
||||
|
||||
let body = try JSONEncoder().encode(CreateRequest(title: title, privacy: privacy.rawValue))
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists",
|
||||
method: .post,
|
||||
headers: authHeaders(sid: sid),
|
||||
body: body
|
||||
)
|
||||
|
||||
let response: CreateResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return Playlist(id: .global(response.playlistId), title: response.title ?? title)
|
||||
}
|
||||
|
||||
/// Edits a playlist's title, description or privacy.
|
||||
///
|
||||
/// Title and privacy are sent even when unchanged: Invidious reads a missing
|
||||
/// field as "leave it alone" for the description but validates the other two
|
||||
/// on every request, so a partial body is rejected rather than merged.
|
||||
/// Answers 204 with no body.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - title: The title to store
|
||||
/// - description: The description to store, or `nil` to leave it alone
|
||||
/// - privacy: The visibility to store
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func updatePlaylist(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws {
|
||||
struct UpdateRequest: Encodable, Sendable {
|
||||
let title: String
|
||||
let privacy: String
|
||||
/// Omitted from the JSON when nil, which is how the server is told
|
||||
/// to keep the description it already has.
|
||||
let description: String?
|
||||
}
|
||||
|
||||
let body = try JSONEncoder().encode(
|
||||
UpdateRequest(title: title, privacy: privacy.rawValue, description: description)
|
||||
)
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(id)",
|
||||
method: .patch,
|
||||
headers: authHeaders(sid: sid),
|
||||
body: body
|
||||
)
|
||||
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Deletes one of the user's playlists. Answers 204 with no body.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func deletePlaylist(id: String, instance: Instance, sid: String) async throws {
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(id)",
|
||||
method: .delete,
|
||||
headers: authHeaders(sid: sid)
|
||||
)
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Appends a video to one of the user's playlists.
|
||||
///
|
||||
/// The 201 response carries the row the server minted, including the
|
||||
/// `indexId` it can later be removed by. It is deliberately not decoded:
|
||||
/// the whole playlist is refetched after every mutation, and a row parsed
|
||||
/// from a response is one more thing that can disagree with the server.
|
||||
/// - Parameters:
|
||||
/// - playlistID: The playlist to add to
|
||||
/// - videoID: The video to add
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func addVideoToPlaylist(
|
||||
playlistID: String,
|
||||
videoID: String,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws {
|
||||
struct AddRequest: Encodable, Sendable {
|
||||
let videoId: String
|
||||
}
|
||||
|
||||
let body = try JSONEncoder().encode(AddRequest(videoId: videoID))
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(playlistID)/videos",
|
||||
method: .post,
|
||||
headers: authHeaders(sid: sid),
|
||||
body: body
|
||||
)
|
||||
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Removes one row from one of the user's playlists.
|
||||
///
|
||||
/// Addressed by `indexId`, the per-entry token the fetch handed out — never
|
||||
/// by position. The server names the same row by it across refetches, so a
|
||||
/// removal cannot land on whatever moved into that position meanwhile.
|
||||
/// Answers 204 with no body.
|
||||
/// - Parameters:
|
||||
/// - playlistID: The playlist to remove from
|
||||
/// - indexID: The entry's `indexId` from ``userPlaylistSnapshot(id:instance:sid:)``
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func removeVideoFromPlaylist(
|
||||
playlistID: String,
|
||||
indexID: String,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws {
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(playlistID)/videos/\(indexID)",
|
||||
method: .delete,
|
||||
headers: authHeaders(sid: sid)
|
||||
)
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// The headers every authenticated write carries. `Content-Type` is set for
|
||||
/// all of them, including the bodiless ones, so a proxy in front of the
|
||||
/// instance cannot guess something else.
|
||||
private nonisolated func authHeaders(sid: String) -> [String: String] {
|
||||
["Cookie": "SID=\(sid)", "Content-Type": "application/json"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Proxy
|
||||
|
||||
@@ -487,6 +487,177 @@ actor PipedAPI: InstanceAPI {
|
||||
instanceURL: instanceURL
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Playlist Management
|
||||
|
||||
/// Creates a playlist owned by the logged-in user.
|
||||
///
|
||||
/// Piped answers with `{"playlistId"}` alone — there is no privacy setting
|
||||
/// to pass and no playlist body to read back.
|
||||
/// - Parameters:
|
||||
/// - name: The playlist name
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
/// - Returns: The new playlist's ID
|
||||
func createPlaylist(name: String, instance: Instance, authToken: String) async throws -> String {
|
||||
struct CreateRequest: Encodable, Sendable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct CreateResponse: Decodable, Sendable {
|
||||
let playlistId: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/create",
|
||||
authToken: authToken,
|
||||
body: CreateRequest(name: name)
|
||||
)
|
||||
|
||||
let response: CreateResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.playlistId
|
||||
}
|
||||
|
||||
/// Renames one of the user's playlists.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - newName: The name to store
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func renamePlaylist(id: String, newName: String, instance: Instance, authToken: String) async throws {
|
||||
struct RenameRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let newName: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/rename",
|
||||
authToken: authToken,
|
||||
body: RenameRequest(playlistId: id, newName: newName)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Replaces the description of one of the user's playlists.
|
||||
///
|
||||
/// PATCH, unlike every other playlist write here. Upstream shipped this as
|
||||
/// `POST /user/playlists/description/change` and replaced it with
|
||||
/// `PATCH /user/playlists/description` the same afternoon, so no instance
|
||||
/// worth supporting is still on the POST spelling.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - description: The description to store
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func updatePlaylistDescription(
|
||||
id: String,
|
||||
description: String,
|
||||
instance: Instance,
|
||||
authToken: String
|
||||
) async throws {
|
||||
struct DescriptionRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/description",
|
||||
method: .patch,
|
||||
authToken: authToken,
|
||||
body: DescriptionRequest(playlistId: id, description: description)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Deletes one of the user's playlists.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func deletePlaylist(id: String, instance: Instance, authToken: String) async throws {
|
||||
struct DeleteRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/delete",
|
||||
authToken: authToken,
|
||||
body: DeleteRequest(playlistId: id)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Appends a video to one of the user's playlists.
|
||||
///
|
||||
/// Sent as `videoIds`, the plural form. Upstream reads both spellings — the
|
||||
/// singular `videoId` is kept only for backwards compatibility, and the
|
||||
/// plural has been accepted since the batching change — so the plural is
|
||||
/// the one that will still be there.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist to add to
|
||||
/// - videoID: The video to add
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func addToPlaylist(id: String, videoID: String, instance: Instance, authToken: String) async throws {
|
||||
struct AddRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let videoIds: [String]
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/add",
|
||||
authToken: authToken,
|
||||
body: AddRequest(playlistId: id, videoIds: [videoID])
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Removes the row at a position from one of the user's playlists.
|
||||
///
|
||||
/// Positional, because Piped offers nothing else: the index is only
|
||||
/// meaningful against the fetch it came from, and whoever calls this owes
|
||||
/// the check that the position still holds what it did. That check is
|
||||
/// deliberately not here — this layer is stateless and has no snapshot to
|
||||
/// compare against.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist to remove from
|
||||
/// - index: Zero-based position of the row to remove
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func removeFromPlaylist(id: String, index: Int, instance: Instance, authToken: String) async throws {
|
||||
struct RemoveRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let index: Int
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/remove",
|
||||
authToken: authToken,
|
||||
body: RemoveRequest(playlistId: id, index: index)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Builds an authenticated JSON write request. Piped wants the token bare in
|
||||
/// `Authorization` — no `Bearer` — on every one of these.
|
||||
private nonisolated func writeEndpoint(
|
||||
path: String,
|
||||
method: HTTPMethod = .post,
|
||||
authToken: String,
|
||||
body: some Encodable
|
||||
) throws -> GenericEndpoint {
|
||||
GenericEndpoint(
|
||||
path: path,
|
||||
method: method,
|
||||
headers: ["Authorization": authToken, "Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(body)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Piped Authentication Response Models
|
||||
|
||||
Reference in New Issue
Block a user