From 5eae547e304e523865dce2f6b8049a4b5fed84e6 Mon Sep 17 00:00:00 2001 From: etoledom Date: Mon, 23 Sep 2024 12:29:00 +0200 Subject: [PATCH] Update openapi spec (#382) --- Sources/Gravatar/Network/Errors.swift | 14 +- .../Network/Services/AvatarService.swift | 16 +- .../Network/Services/ProfileService.swift | 63 ++-- .../Services/URLSessionHTTPClient.swift | 14 +- .../AssociatedEmail200Response.swift | 35 +++ .../Gravatar/OpenApi/Generated/Avatar.swift | 79 +++++ .../Gravatar/OpenApi/Generated/Language.swift | 58 ++++ .../OpenApi/Generated/ModelError.swift | 42 +++ .../Gravatar/OpenApi/Generated/Profile.swift | 43 ++- .../Generated/SetEmailAvatarRequest.swift | 25 ++ .../AvatarPicker/AvatarGridModel.swift | 17 +- .../AvatarPicker/AvatarImageModel.swift | 21 +- .../AvatarPicker/AvatarPickerView.swift | 17 +- .../AvatarPicker/AvatarPickerViewModel.swift | 55 ++-- .../AvatarPicker/Views/AvatarGrid.swift | 6 +- .../Views/AvatarPickerAvatarView.swift | 12 +- .../AvatarPicker/Views/DimmingButton.swift | 49 +++ .../Views/DimmingRetryButton.swift | 27 -- .../Views/HorizontalAvatarGrid.swift | 6 +- Sources/TestHelpers/TestURLSession.swift | 2 +- Tests/GravatarTests/AvatarServiceTests.swift | 4 +- .../Resources/avatarUploadResponse.json | 12 +- .../URLSessionHTTPClientTests.swift | 2 +- Tests/GravatarUITests/TestImageFetcher.swift | 2 +- openapi/modelInlineEnumDeclaration.mustache | 7 + openapi/spec.yaml | 282 +++++++++++++++++- 26 files changed, 766 insertions(+), 144 deletions(-) create mode 100644 Sources/Gravatar/OpenApi/Generated/AssociatedEmail200Response.swift create mode 100644 Sources/Gravatar/OpenApi/Generated/Avatar.swift create mode 100644 Sources/Gravatar/OpenApi/Generated/Language.swift create mode 100644 Sources/Gravatar/OpenApi/Generated/ModelError.swift create mode 100644 Sources/Gravatar/OpenApi/Generated/SetEmailAvatarRequest.swift create mode 100644 Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingButton.swift delete mode 100644 Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingRetryButton.swift create mode 100644 openapi/modelInlineEnumDeclaration.mustache diff --git a/Sources/Gravatar/Network/Errors.swift b/Sources/Gravatar/Network/Errors.swift index 27ab237e..b3045a81 100644 --- a/Sources/Gravatar/Network/Errors.swift +++ b/Sources/Gravatar/Network/Errors.swift @@ -6,11 +6,14 @@ public enum ResponseErrorReason: Sendable { case URLSessionError(error: Error) /// The response contains an invalid HTTP status code. By default, status code >= 400 is recognized as invalid. - case invalidHTTPStatusCode(response: HTTPURLResponse) + case invalidHTTPStatusCode(response: HTTPURLResponse, data: Data) /// The response is not a `HTTPURLResponse`. case invalidURLResponse(response: URLResponse) + /// + case invalidRequest(error: ModelError) + /// An unexpected error has occurred. case unexpected(Error) @@ -24,12 +27,19 @@ public enum ResponseErrorReason: Sendable { // If self is a `.invalidHTTPStatusCode` returns the HTTP statusCode from the response. Otherwise returns `nil`. public var httpStatusCode: Int? { - if case .invalidHTTPStatusCode(let response) = self { + if case .invalidHTTPStatusCode(let response, _) = self { return response.statusCode } return nil } + public var errorData: Data? { + if case .invalidHTTPStatusCode(_, let data) = self { + return data + } + return nil + } + public var cancelled: Bool { if case .URLSessionError(let error) = self { return (error as NSError).code == -999 diff --git a/Sources/Gravatar/Network/Services/AvatarService.swift b/Sources/Gravatar/Network/Services/AvatarService.swift index a6029228..2129b38b 100644 --- a/Sources/Gravatar/Network/Services/AvatarService.swift +++ b/Sources/Gravatar/Network/Services/AvatarService.swift @@ -58,11 +58,21 @@ public struct AvatarService: Sendable { /// - Returns: An asynchronously-delivered `AvatarModel` instance, containing data of the newly created avatar. @discardableResult public func upload(_ image: UIImage, accessToken: String) async throws -> Avatar { - let (data, _) = try await imageUploader.uploadImage(image, accessToken: accessToken, additionalHTTPHeaders: [(name: "Client-Type", value: "ios")]) do { - return try data.decode(keyDecodingStrategy: .convertFromSnakeCase) - } catch { + let (data, _) = try await imageUploader.uploadImage(image, accessToken: accessToken, additionalHTTPHeaders: [(name: "Client-Type", value: "ios")]) + return try data.decode() + + } catch ImageUploadError.responseError(reason: let reason) where reason.httpStatusCode == 400 { + guard let data = reason.errorData, let error: ModelError = try? data.decode() else { + throw ImageUploadError.responseError(reason: reason) + } + throw error + } catch let error as DecodingError { throw ImageUploadError.responseError(reason: .unexpected(error)) + } catch { + throw error } } } + +extension ModelError: Error {} diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 6ec5e648..1aaa61df 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -1,11 +1,10 @@ import Foundation private let baseURL = URL(string: "https://api.gravatar.com/v3/profiles/")! -private let avatarsBaseURL = URL(string: "https://api.gravatar.com/v3/me/avatars")! -private let identitiesBaseURL = "https://api.gravatar.com/v3/me/identities/" +private let avatarsBaseURLComponents = URLComponents(string: "https://api.gravatar.com/v3/me/avatars")! -private func selectAvatarBaseURL(with profileID: ProfileIdentifier) -> URL? { - URL(string: "https://api.gravatar.com/v3/me/identities/\(profileID.id)/avatar") +private func selectAvatarBaseURL(with avatarID: String) -> URL? { + URL(string: "https://api.gravatar.com/v3/me/avatars/\(avatarID)/email") } /// A service to perform Profile related tasks. @@ -30,41 +29,30 @@ public struct ProfileService: ProfileFetching, Sendable { return try await fetch(with: request) } - package func fetchAvatars(with token: String) async throws -> [Avatar] { + package func fetchAvatars(with token: String, id: ProfileIdentifier) async throws -> [Avatar] { do { - let url = avatarsBaseURL + guard let url = avatarsBaseURLComponents.settingQueryItems([.init(name: "selected_email", value: id.id)]).url else { + throw APIError.requestError(reason: .urlInitializationFailed) + } let request = URLRequest(url: url).settingAuthorizationHeaderField(with: token) let (data, _) = try await client.fetchData(with: request) - return try data.decode(keyDecodingStrategy: .convertFromSnakeCase) + return try data.decode() } catch { throw error.apiError() } } - package func fetchIdentity(token: String, profileID: ProfileIdentifier) async throws -> ProfileIdentity { - guard let url = URL(string: identitiesBaseURL + profileID.id) else { - throw APIError.requestError(reason: .urlInitializationFailed) - } - do { - let request = URLRequest(url: url).settingAuthorizationHeaderField(with: token) - let (data, _) = try await client.fetchData(with: request) - return try data.decode(keyDecodingStrategy: .convertFromSnakeCase) - } catch { - throw error.apiError() - } - } - - package func selectAvatar(token: String, profileID: ProfileIdentifier, avatarID: String) async throws -> ProfileIdentity { - guard let url = selectAvatarBaseURL(with: profileID) else { + package func selectAvatar(token: String, profileID: ProfileIdentifier, avatarID: String) async throws -> Avatar { + guard let url = selectAvatarBaseURL(with: avatarID) else { throw APIError.requestError(reason: .urlInitializationFailed) } do { var request = URLRequest(url: url).settingAuthorizationHeaderField(with: token) request.httpMethod = "POST" - request.httpBody = try SelectAvatarBody(avatarId: avatarID).data + request.httpBody = try SelectAvatarBody(emailHash: profileID.id).data let (data, _) = try await client.fetchData(with: request) - return try data.decode(keyDecodingStrategy: .convertFromSnakeCase) + return try data.decode() } catch { throw error.apiError() } @@ -97,22 +85,7 @@ extension URLRequest { } } -package struct ProfileIdentity: Decodable, Sendable { - package let emailHash: String - package let rating: String - package let imageId: String - package let imageUrl: String -} - -public struct Avatar: Decodable, Sendable { - private let imageId: String - private let imageUrl: String - - package init(id: String, url: String) { - self.imageId = id - self.imageUrl = url - } - +extension Avatar { public var id: String { imageId } @@ -120,13 +93,17 @@ public struct Avatar: Decodable, Sendable { public var url: String { imageUrl } + + public var isSelected: Bool { + selected == true + } } private struct SelectAvatarBody: Encodable, Sendable { - private let avatarId: String + private let emailHash: String - init(avatarId: String) { - self.avatarId = avatarId + init(emailHash: String) { + self.emailHash = emailHash } var data: Data { diff --git a/Sources/Gravatar/Network/Services/URLSessionHTTPClient.swift b/Sources/Gravatar/Network/Services/URLSessionHTTPClient.swift index 9fc50461..5d1d74f3 100644 --- a/Sources/Gravatar/Network/Services/URLSessionHTTPClient.swift +++ b/Sources/Gravatar/Network/Services/URLSessionHTTPClient.swift @@ -2,7 +2,7 @@ import Foundation /// Common errors for all HTTP operations. enum HTTPClientError: Error { - case invalidHTTPStatusCodeError(HTTPURLResponse) + case invalidHTTPStatusCodeError(HTTPURLResponse, Data) case invalidURLResponseError(URLResponse) case URLSessionError(Error) } @@ -21,7 +21,7 @@ struct URLSessionHTTPClient: HTTPClient { } catch { throw HTTPClientError.URLSessionError(error) } - let httpResponse = try validatedHTTPResponse(result.response) + let httpResponse = try validatedHTTPResponse(result.response, data: result.data) return (result.data, httpResponse) } @@ -32,7 +32,7 @@ struct URLSessionHTTPClient: HTTPClient { } catch { throw HTTPClientError.URLSessionError(error) } - return try (result.data, validatedHTTPResponse(result.response)) + return try (result.data, validatedHTTPResponse(result.response, data: result.data)) } } @@ -44,12 +44,12 @@ extension URLRequest { } } -private func validatedHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse { +private func validatedHTTPResponse(_ response: URLResponse, data: Data) throws -> HTTPURLResponse { guard let httpResponse = response as? HTTPURLResponse else { throw HTTPClientError.invalidURLResponseError(response) } if isErrorResponse(httpResponse) { - throw HTTPClientError.invalidHTTPStatusCodeError(httpResponse) + throw HTTPClientError.invalidHTTPStatusCodeError(httpResponse, data) } return httpResponse } @@ -63,8 +63,8 @@ extension HTTPClientError { switch self { case .URLSessionError(let error): .URLSessionError(error: error) - case .invalidHTTPStatusCodeError(let response): - .invalidHTTPStatusCode(response: response) + case .invalidHTTPStatusCodeError(let response, let data): + .invalidHTTPStatusCode(response: response, data: data) case .invalidURLResponseError(let response): .invalidURLResponse(response: response) } diff --git a/Sources/Gravatar/OpenApi/Generated/AssociatedEmail200Response.swift b/Sources/Gravatar/OpenApi/Generated/AssociatedEmail200Response.swift new file mode 100644 index 00000000..9f57c760 --- /dev/null +++ b/Sources/Gravatar/OpenApi/Generated/AssociatedEmail200Response.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct AssociatedEmail200Response: Codable, Hashable, Sendable { + /// Whether the email is associated with a Gravatar account. + public private(set) var associated: Bool + + @available(*, deprecated, message: "init will become internal on the next release") + public init(associated: Bool) { + self.associated = associated + } + + @available(*, deprecated, message: "CodingKeys will become internal on the next release.") + public enum CodingKeys: String, CodingKey, CaseIterable { + case associated + } + + enum InternalCodingKeys: String, CodingKey, CaseIterable { + case associated + } + + // Encodable protocol methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: InternalCodingKeys.self) + try container.encode(associated, forKey: .associated) + } + + // Decodable protocol methods + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: InternalCodingKeys.self) + + associated = try container.decode(Bool.self, forKey: .associated) + } +} diff --git a/Sources/Gravatar/OpenApi/Generated/Avatar.swift b/Sources/Gravatar/OpenApi/Generated/Avatar.swift new file mode 100644 index 00000000..f74c7f3c --- /dev/null +++ b/Sources/Gravatar/OpenApi/Generated/Avatar.swift @@ -0,0 +1,79 @@ +import Foundation + +/// An avatar that the user has already uploaded to their Gravatar account. +/// +public struct Avatar: Codable, Hashable, Sendable { + public enum Rating: String, Codable, CaseIterable, Sendable { + case g = "G" + case pg = "PG" + case r = "R" + case x = "X" + } + + /// Unique identifier for the image. + public private(set) var imageId: String + /// Image URL + public private(set) var imageUrl: String + /// Rating associated with the image. + public private(set) var rating: Rating + /// Date and time when the image was last updated. + public private(set) var updatedDate: Date + /// Alternative text description of the image. + public private(set) var altText: String + /// Whether the image is currently selected as the provided selected email's avatar. + public private(set) var selected: Bool? + + @available(*, deprecated, message: "init will become internal on the next release") + public init(imageId: String, imageUrl: String, rating: Rating, updatedDate: Date, altText: String, selected: Bool? = nil) { + self.imageId = imageId + self.imageUrl = imageUrl + self.rating = rating + self.updatedDate = updatedDate + self.altText = altText + self.selected = selected + } + + @available(*, deprecated, message: "CodingKeys will become internal on the next release.") + public enum CodingKeys: String, CodingKey, CaseIterable { + case imageId = "image_id" + case imageUrl = "image_url" + case rating + case updatedDate = "updated_date" + case altText = "alt_text" + case selected + } + + enum InternalCodingKeys: String, CodingKey, CaseIterable { + case imageId = "image_id" + case imageUrl = "image_url" + case rating + case updatedDate = "updated_date" + case altText = "alt_text" + case selected + } + + // Encodable protocol methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: InternalCodingKeys.self) + try container.encode(imageId, forKey: .imageId) + try container.encode(imageUrl, forKey: .imageUrl) + try container.encode(rating, forKey: .rating) + try container.encode(updatedDate, forKey: .updatedDate) + try container.encode(altText, forKey: .altText) + try container.encodeIfPresent(selected, forKey: .selected) + } + + // Decodable protocol methods + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: InternalCodingKeys.self) + + imageId = try container.decode(String.self, forKey: .imageId) + imageUrl = try container.decode(String.self, forKey: .imageUrl) + rating = try container.decode(Rating.self, forKey: .rating) + updatedDate = try container.decode(Date.self, forKey: .updatedDate) + altText = try container.decode(String.self, forKey: .altText) + selected = try container.decodeIfPresent(Bool.self, forKey: .selected) + } +} diff --git a/Sources/Gravatar/OpenApi/Generated/Language.swift b/Sources/Gravatar/OpenApi/Generated/Language.swift new file mode 100644 index 00000000..95f70c9f --- /dev/null +++ b/Sources/Gravatar/OpenApi/Generated/Language.swift @@ -0,0 +1,58 @@ +import Foundation + +/// The languages the user knows. This is only provided in authenticated API requests. +/// +public struct Language: Codable, Hashable, Sendable { + /// The language code. + public private(set) var code: String + /// The language name. + public private(set) var name: String + /// Whether the language is the user's primary language. + public private(set) var isPrimary: Bool + /// The order of the language in the user's profile. + public private(set) var order: Int + + @available(*, deprecated, message: "init will become internal on the next release") + public init(code: String, name: String, isPrimary: Bool, order: Int) { + self.code = code + self.name = name + self.isPrimary = isPrimary + self.order = order + } + + @available(*, deprecated, message: "CodingKeys will become internal on the next release.") + public enum CodingKeys: String, CodingKey, CaseIterable { + case code + case name + case isPrimary = "is_primary" + case order + } + + enum InternalCodingKeys: String, CodingKey, CaseIterable { + case code + case name + case isPrimary = "is_primary" + case order + } + + // Encodable protocol methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: InternalCodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(name, forKey: .name) + try container.encode(isPrimary, forKey: .isPrimary) + try container.encode(order, forKey: .order) + } + + // Decodable protocol methods + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: InternalCodingKeys.self) + + code = try container.decode(String.self, forKey: .code) + name = try container.decode(String.self, forKey: .name) + isPrimary = try container.decode(Bool.self, forKey: .isPrimary) + order = try container.decode(Int.self, forKey: .order) + } +} diff --git a/Sources/Gravatar/OpenApi/Generated/ModelError.swift b/Sources/Gravatar/OpenApi/Generated/ModelError.swift new file mode 100644 index 00000000..13562faf --- /dev/null +++ b/Sources/Gravatar/OpenApi/Generated/ModelError.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct ModelError: Codable, Hashable, Sendable { + /// The error message + public private(set) var error: String + /// The error code for the error message + public private(set) var code: String + + @available(*, deprecated, message: "init will become internal on the next release") + public init(error: String, code: String) { + self.error = error + self.code = code + } + + @available(*, deprecated, message: "CodingKeys will become internal on the next release.") + public enum CodingKeys: String, CodingKey, CaseIterable { + case error + case code + } + + enum InternalCodingKeys: String, CodingKey, CaseIterable { + case error + case code + } + + // Encodable protocol methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: InternalCodingKeys.self) + try container.encode(error, forKey: .error) + try container.encode(code, forKey: .code) + } + + // Decodable protocol methods + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: InternalCodingKeys.self) + + error = try container.decode(String.self, forKey: .error) + code = try container.decode(String.self, forKey: .code) + } +} diff --git a/Sources/Gravatar/OpenApi/Generated/Profile.swift b/Sources/Gravatar/OpenApi/Generated/Profile.swift index ccc00ff6..84880eb8 100644 --- a/Sources/Gravatar/OpenApi/Generated/Profile.swift +++ b/Sources/Gravatar/OpenApi/Generated/Profile.swift @@ -27,6 +27,16 @@ public struct Profile: Codable, Hashable, Sendable { public private(set) var pronunciation: String /// The pronouns the user uses. public private(set) var pronouns: String + /// The timezone the user has. This is only provided in authenticated API requests. + public private(set) var timezone: String? + /// The languages the user knows. This is only provided in authenticated API requests. + public private(set) var languages: [Language]? + /// User's first name. This is only provided in authenticated API requests. + public private(set) var firstName: String? + /// User's last name. This is only provided in authenticated API requests. + public private(set) var lastName: String? + /// Whether user is an organization. This is only provided in authenticated API requests. + public private(set) var isOrganization: Bool? /// A list of links the user has added to their profile. This is only provided in authenticated API requests. public private(set) var links: [Link]? /// A list of interests the user has added to their profile. This is only provided in authenticated API requests. @@ -87,7 +97,7 @@ public struct Profile: Codable, Hashable, Sendable { } // NOTE: This init is maintained manually. - // Avoid deleting this init until the deprecation of is applied. + // Avoid deleting this init until the deprecation is applied. init( hash: String, displayName: String, @@ -101,6 +111,11 @@ public struct Profile: Codable, Hashable, Sendable { verifiedAccounts: [VerifiedAccount], pronunciation: String, pronouns: String, + timezone: String? = nil, + languages: [Language]? = nil, + firstName: String? = nil, + lastName: String? = nil, + isOrganization: Bool? = nil, links: [Link]? = nil, interests: [Interest]? = nil, payments: ProfilePayments? = nil, @@ -122,6 +137,11 @@ public struct Profile: Codable, Hashable, Sendable { self.verifiedAccounts = verifiedAccounts self.pronunciation = pronunciation self.pronouns = pronouns + self.timezone = timezone + self.languages = languages + self.firstName = firstName + self.lastName = lastName + self.isOrganization = isOrganization self.links = links self.interests = interests self.payments = payments @@ -146,7 +166,13 @@ public struct Profile: Codable, Hashable, Sendable { case verifiedAccounts = "verified_accounts" case pronunciation case pronouns + case timezone + case languages + case firstName = "first_name" + case lastName = "last_name" + case isOrganization = "is_organization" case links + case interests case payments case contactInfo = "contact_info" case gallery @@ -168,6 +194,11 @@ public struct Profile: Codable, Hashable, Sendable { case verifiedAccounts = "verified_accounts" case pronunciation case pronouns + case timezone + case languages + case firstName = "first_name" + case lastName = "last_name" + case isOrganization = "is_organization" case links case interests case payments @@ -194,6 +225,11 @@ public struct Profile: Codable, Hashable, Sendable { try container.encode(verifiedAccounts, forKey: .verifiedAccounts) try container.encode(pronunciation, forKey: .pronunciation) try container.encode(pronouns, forKey: .pronouns) + try container.encodeIfPresent(timezone, forKey: .timezone) + try container.encodeIfPresent(languages, forKey: .languages) + try container.encodeIfPresent(firstName, forKey: .firstName) + try container.encodeIfPresent(lastName, forKey: .lastName) + try container.encodeIfPresent(isOrganization, forKey: .isOrganization) try container.encodeIfPresent(links, forKey: .links) try container.encodeIfPresent(interests, forKey: .interests) try container.encodeIfPresent(payments, forKey: .payments) @@ -221,6 +257,11 @@ public struct Profile: Codable, Hashable, Sendable { verifiedAccounts = try container.decode([VerifiedAccount].self, forKey: .verifiedAccounts) pronunciation = try container.decode(String.self, forKey: .pronunciation) pronouns = try container.decode(String.self, forKey: .pronouns) + timezone = try container.decodeIfPresent(String.self, forKey: .timezone) + languages = try container.decodeIfPresent([Language].self, forKey: .languages) + firstName = try container.decodeIfPresent(String.self, forKey: .firstName) + lastName = try container.decodeIfPresent(String.self, forKey: .lastName) + isOrganization = try container.decodeIfPresent(Bool.self, forKey: .isOrganization) links = try container.decodeIfPresent([Link].self, forKey: .links) interests = try container.decodeIfPresent([Interest].self, forKey: .interests) payments = try container.decodeIfPresent(ProfilePayments.self, forKey: .payments) diff --git a/Sources/Gravatar/OpenApi/Generated/SetEmailAvatarRequest.swift b/Sources/Gravatar/OpenApi/Generated/SetEmailAvatarRequest.swift new file mode 100644 index 00000000..787730a7 --- /dev/null +++ b/Sources/Gravatar/OpenApi/Generated/SetEmailAvatarRequest.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct SetEmailAvatarRequest: Codable, Hashable, Sendable { + /// The email SHA256 hash to set the avatar for. + public private(set) var emailHash: String + + enum InternalCodingKeys: String, CodingKey, CaseIterable { + case emailHash = "email_hash" + } + + // Encodable protocol methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: InternalCodingKeys.self) + try container.encode(emailHash, forKey: .emailHash) + } + + // Decodable protocol methods + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: InternalCodingKeys.self) + + emailHash = try container.decode(String.self, forKey: .emailHash) + } +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift index 76de603c..a8470672 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift @@ -2,7 +2,7 @@ import Foundation /// Describes and manages a grid of avatars. class AvatarGridModel: ObservableObject { - @Published var avatars: [AvatarImageModel] + @Published private(set) var avatars: [AvatarImageModel] @Published var selectedAvatar: AvatarImageModel? var isEmpty: Bool { @@ -31,9 +31,9 @@ class AvatarGridModel: ObservableObject { avatars.removeAll { $0.id == id } } - func setLoading(to isLoading: Bool, onAvatarWithID id: String) { + func setState(to state: AvatarImageModel.State, onAvatarWithID id: String) { guard let imageModel = model(with: id) else { return } - let toggledModel = imageModel.settingLoading(to: isLoading) + let toggledModel = imageModel.settingStatus(to: state) replaceModel(withID: id, with: toggledModel) } @@ -52,4 +52,15 @@ class AvatarGridModel: ObservableObject { } selectedAvatar = model(with: selectedID) } + + func setAvatars(_ avatars: [AvatarImageModel]) { + self.avatars = avatars + if let selected = avatars.first(where: { $0.isSelected }) { + selectAvatar(selected) + } + } + + func deleteModel(_ avatar: AvatarImageModel) { + avatars.removeAll { $0 == avatar } + } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift index dcd456cb..46051f7d 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift @@ -7,10 +7,17 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { case local(image: UIImage) } + enum State { + case loaded + case loading + case retry + case error + } + let id: String - let isLoading: Bool - let uploadHasFailed: Bool let source: Source + let isSelected: Bool + let state: State var url: URL? { guard case .remote(let url) = source else { @@ -33,14 +40,14 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { return image } - init(id: String, source: Source, isLoading: Bool = false, uploadHasFailed: Bool = false) { + init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false) { self.id = id self.source = source - self.isLoading = isLoading - self.uploadHasFailed = uploadHasFailed + self.state = state + self.isSelected = isSelected } - func settingLoading(to newLoadingStatus: Bool) -> AvatarImageModel { - AvatarImageModel(id: id, source: source, isLoading: newLoadingStatus, uploadHasFailed: uploadHasFailed) + func settingStatus(to newStatus: State) -> AvatarImageModel { + AvatarImageModel(id: id, source: source, state: newStatus) } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index 373a9748..7a102759 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -186,6 +186,12 @@ struct AvatarPickerView: View { } } + private func deleteFailedUpload(_ avatar: AvatarImageModel) { + withAnimation { + model.deleteFailed(avatar) + } + } + @ViewBuilder private func avatarGrid() -> some View { // Even if the contentLayout is set to horizontal, we show vertical grid for large devices. @@ -202,6 +208,9 @@ struct AvatarPickerView: View { }, onRetryUpload: { avatar in retryUpload(avatar) + }, + onDeleteFailed: { avatar in + deleteFailedUpload(avatar) } ) .padding(.horizontal, Constants.horizontalPadding) @@ -214,6 +223,9 @@ struct AvatarPickerView: View { }, onRetryUpload: { avatar in retryUpload(avatar) + }, + onDeleteFailed: { avatar in + deleteFailedUpload(avatar) } ) .padding(.top, .DS.Padding.medium) @@ -435,14 +447,15 @@ private enum AvatarPicker { let model = AvatarPickerViewModel( avatarImageModels: [ - .init(id: "0", source: .local(image: UIImage()), isLoading: true), + .init(id: "0", source: .local(image: UIImage()), state: .loading), .init(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")), .init(id: "2", source: .remote(url: "https://gravatar.com/userimage/110207384/db73834576b01b69dd8da1e29877ca07.jpeg?size=256")), .init(id: "3", source: .remote(url: "https://gravatar.com/userimage/110207384/3f7095bf2580265d1801d128c6410016.jpeg?size=256")), .init(id: "4", source: .remote(url: "https://gravatar.com/userimage/110207384/fbbd335e57862e19267679f19b4f9db8.jpeg?size=256")), .init(id: "5", source: .remote(url: "https://gravatar.com/userimage/110207384/96c6950d6d8ce8dd1177a77fe738101e.jpeg?size=256")), .init(id: "6", source: .remote(url: "https://gravatar.com/userimage/110207384/4a4f9385b0a6fa5c00342557a098f480.jpeg?size=256")), - .init(id: "7", source: .local(image: UIImage()), uploadHasFailed: true), + .init(id: "7", source: .local(image: UIImage()), state: .retry), + .init(id: "8", source: .local(image: UIImage()), state: .error), ], selectedImageID: "5", profileModel: PreviewModel() diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 25d77479..3004b8e4 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -59,7 +59,7 @@ class AvatarPickerViewModel: ObservableObject { self.selectedAvatarResult = .success(selectedImageID) } - grid.avatars = avatarImageModels + grid.setAvatars(avatarImageModels) grid.selectAvatar(withID: selectedImageID) gridResponseStatus = .success(()) @@ -79,7 +79,8 @@ class AvatarPickerViewModel: ObservableObject { guard let email, let authToken, - grid.selectedAvatar?.id != id + grid.selectedAvatar?.id != id, + grid.model(with: id)?.state == .loaded else { return } avatarSelectionTask?.cancel() @@ -91,12 +92,12 @@ class AvatarPickerViewModel: ObservableObject { func postAvatarSelection(with avatarID: String, authToken: String, identifier: ProfileIdentifier) async { defer { - grid.setLoading(to: false, onAvatarWithID: avatarID) + grid.setState(to: .loaded, onAvatarWithID: avatarID) } grid.selectAvatar(withID: avatarID) + grid.setState(to: .loading, onAvatarWithID: avatarID) do { - grid.setLoading(to: true, onAvatarWithID: avatarID) let response = try await profileService.selectAvatar(token: authToken, profileID: identifier, avatarID: avatarID) toastManager.showToast("Avatar updated! It may take a few minutes to appear everywhere.", type: .info) selectedAvatarResult = .success(response.imageId) @@ -109,14 +110,16 @@ class AvatarPickerViewModel: ObservableObject { } func fetchAvatars() async { - guard let authToken else { return } + guard let authToken, let email else { return } do { isAvatarsLoading = true - let images = try await profileService.fetchAvatars(with: authToken) - - grid.avatars = images.map(AvatarImageModel.init) - updateSelectedAvatarURL() + let images = try await profileService.fetchAvatars(with: authToken, id: .email(email)) + grid.setAvatars(images.map(AvatarImageModel.init)) + if let selectedAvatar = grid.selectedAvatar { + selectedAvatarURL = selectedAvatar.url + selectedAvatarResult = .success(selectedAvatar.id) + } isAvatarsLoading = false gridResponseStatus = .success(()) } catch { @@ -138,25 +141,13 @@ class AvatarPickerViewModel: ObservableObject { } } - func fetchIdentity() async { - guard let authToken, let email else { return } - - do { - let identity = try await profileService.fetchIdentity(token: authToken, profileID: .email(email)) - selectedAvatarResult = .success(identity.imageId) - grid.selectAvatar(withID: identity.imageId) - } catch { - selectedAvatarResult = .failure(error) - } - } - func upload(_ image: UIImage, shouldSquareImage: Bool) async { guard let authToken else { return } let squareImage = shouldSquareImage ? image.squared() : image let localID = UUID().uuidString - let localImageModel = AvatarImageModel(id: localID, source: .local(image: squareImage), isLoading: true) + let localImageModel = AvatarImageModel(id: localID, source: .local(image: squareImage), state: .loading) grid.append(localImageModel) await doUpload(squareImage: squareImage, localID: localID, accessToken: authToken) @@ -169,10 +160,14 @@ class AvatarPickerViewModel: ObservableObject { else { return } - grid.setLoading(to: true, onAvatarWithID: localID) + grid.setState(to: .loading, onAvatarWithID: localID) await doUpload(squareImage: localImage, localID: localID, accessToken: authToken) } + func deleteFailed(_ avatar: AvatarImageModel) { + grid.deleteModel(avatar) + } + private func doUpload(squareImage: UIImage, localID: String, accessToken: String) async { let service = AvatarService() do { @@ -181,8 +176,12 @@ class AvatarPickerViewModel: ObservableObject { let newModel = AvatarImageModel(id: avatar.id, source: .remote(url: avatar.url)) grid.replaceModel(withID: localID, with: newModel) + } catch let error as ModelError { + let newModel = AvatarImageModel(id: localID, source: .local(image: squareImage), state: .error) + grid.replaceModel(withID: localID, with: newModel) + toastManager.showToast(error.error, type: .error) } catch { - let newModel = AvatarImageModel(id: localID, source: .local(image: squareImage), uploadHasFailed: true) + let newModel = AvatarImageModel(id: localID, source: .local(image: squareImage), state: .retry) grid.replaceModel(withID: localID, with: newModel) toastManager.showToast(Localized.toastError, type: .error) } @@ -198,10 +197,8 @@ class AvatarPickerViewModel: ObservableObject { self.email = .init(email) Task { // parallel child tasks - async let identity: () = fetchIdentity() async let profile: () = fetchProfile() - await identity await profile } } @@ -215,12 +212,10 @@ class AvatarPickerViewModel: ObservableObject { Task { // We want them to be parallel child tasks so they don't wait each other. async let avatars: () = fetchAvatars() - async let identity: () = fetchIdentity() async let profile: () = fetchProfile() // We need to await them otherwise network requests can be cancelled. await avatars - await identity await profile } } @@ -280,7 +275,7 @@ extension AvatarImageModel { init(with avatar: Avatar) { id = avatar.id source = .remote(url: avatar.url) - isLoading = false - uploadHasFailed = false + state = .loaded + isSelected = avatar.isSelected } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift index f942395a..73178060 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift @@ -23,6 +23,7 @@ struct AvatarGrid: View { let onAvatarTap: (AvatarImageModel) -> Void let onImagePickerDidPickImage: (UIImage) -> Void let onRetryUpload: (AvatarImageModel) -> Void + let onDeleteFailed: (AvatarImageModel) -> Void var body: some View { LazyVGrid(columns: gridItems, spacing: AvatarGridConstants.avatarSpacing) { @@ -45,7 +46,8 @@ struct AvatarGrid: View { grid.selectedAvatar?.id == avatar.id }, onAvatarTap: onAvatarTap, - onRetryUpload: onRetryUpload + onRetryUpload: onRetryUpload, + onDeleteFailed: onDeleteFailed ) } } @@ -68,6 +70,8 @@ struct AvatarGrid: View { grid.append(newAvatarModel(image)) } onRetryUpload: { _ in // No op. inside the preview. + } onDeleteFailed: { _ in + // No op. inside the preview. } .padding() Button("Add avatar cell") { diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift index d0e0e6fa..e3294c2e 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift @@ -7,6 +7,7 @@ struct AvatarPickerAvatarView: View { let shouldSelect: () -> Bool let onAvatarTap: (AvatarImageModel) -> Void let onRetryUpload: (AvatarImageModel) -> Void + let onDeleteFailed: (AvatarImageModel) -> Void var body: some View { AvatarView( @@ -32,14 +33,19 @@ struct AvatarPickerAvatarView: View { borderWidth: shouldSelect() ? AvatarGridConstants.selectedBorderWidth : 0 ) .overlay { - if avatar.isLoading { + if avatar.state == .loading { DimmingActivityIndicator() .cornerRadius(AvatarGridConstants.avatarCornerRadius) - } else if avatar.uploadHasFailed { + } else if avatar.state == .retry { DimmingRetryButton { onRetryUpload(avatar) } .cornerRadius(AvatarGridConstants.avatarCornerRadius) + } else if avatar.state == .error { + DimmingErrorButton { + onDeleteFailed(avatar) + } + .cornerRadius(AvatarGridConstants.avatarCornerRadius) } }.onTapGesture { onAvatarTap(avatar) @@ -52,7 +58,7 @@ struct AvatarPickerAvatarView: View { return AvatarPickerAvatarView(avatar: avatar, maxLength: AvatarGridConstants.maxAvatarWidth, minLength: AvatarGridConstants.minAvatarWidth) { false } onAvatarTap: { _ in - } onRetryUpload: { _ in + } onDeleteFailed: { _ in } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingButton.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingButton.swift new file mode 100644 index 00000000..01961f74 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingButton.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Dims the parent and puts a retry button on it. +struct DimmingButton: View { + let imageName: String + let action: () -> Void + + var body: some View { + Button( + action: action, + label: { + ZStack { + Rectangle() + .fill(.black.opacity(0.3)) + Image(systemName: imageName) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 20, height: 20) + } + } + ) + .foregroundColor(Color.white) + } +} + +/// Dims the parent and puts a retry button on it. +struct DimmingRetryButton: View { + let action: () -> Void + + var body: some View { + DimmingButton(imageName: "arrow.clockwise", action: action) + } +} + +/// Dims the parent and puts a retry button on it. +struct DimmingErrorButton: View { + let action: () -> Void + + var body: some View { + DimmingButton(imageName: "xmark", action: action) + } +} + +#Preview { + VStack { + DimmingRetryButton {}.frame(width: 100, height: 100) + DimmingErrorButton {}.frame(width: 100, height: 100) + } +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingRetryButton.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingRetryButton.swift deleted file mode 100644 index 2aaaf5a4..00000000 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/DimmingRetryButton.swift +++ /dev/null @@ -1,27 +0,0 @@ -import SwiftUI - -/// Dims the parent and puts a retry button on it. -struct DimmingRetryButton: View { - let action: () -> Void - - var body: some View { - Button( - action: action, - label: { - ZStack { - Rectangle() - .fill(.black.opacity(0.3)) - Image(systemName: "arrow.clockwise") - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 20, height: 20) - } - } - ) - .foregroundColor(Color.white) - } -} - -#Preview { - DimmingRetryButton {} -} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift index 78422f46..7d0bff77 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift @@ -9,6 +9,7 @@ struct HorizontalAvatarGrid: View { let onAvatarTap: (AvatarImageModel) -> Void let onRetryUpload: (AvatarImageModel) -> Void + let onDeleteFailed: (AvatarImageModel) -> Void var body: some View { ScrollView(.horizontal) { @@ -22,7 +23,8 @@ struct HorizontalAvatarGrid: View { grid.selectedAvatar?.id == avatar.id }, onAvatarTap: onAvatarTap, - onRetryUpload: onRetryUpload + onRetryUpload: onRetryUpload, + onDeleteFailed: onDeleteFailed ) } } @@ -48,5 +50,7 @@ struct HorizontalAvatarGrid: View { grid.selectAvatar(withID: avatar.id) } onRetryUpload: { _ in // No op. Inside the preview. + } onDeleteFailed: { _ in + // No op. Inside the preview. } } diff --git a/Sources/TestHelpers/TestURLSession.swift b/Sources/TestHelpers/TestURLSession.swift index f10fad4a..5b7f246c 100644 --- a/Sources/TestHelpers/TestURLSession.swift +++ b/Sources/TestHelpers/TestURLSession.swift @@ -71,7 +71,7 @@ extension ImageFetchingError: Equatable { extension ResponseErrorReason: Equatable { public static func == (lhs: ResponseErrorReason, rhs: ResponseErrorReason) -> Bool { switch (lhs, rhs) { - case (.invalidHTTPStatusCode(let response1), .invalidHTTPStatusCode(let response2)): + case (.invalidHTTPStatusCode(let response1, _), .invalidHTTPStatusCode(let response2, _)): response1.statusCode == response2.statusCode case (.URLSessionError, .URLSessionError): true diff --git a/Tests/GravatarTests/AvatarServiceTests.swift b/Tests/GravatarTests/AvatarServiceTests.swift index 611d7e61..ca1c1462 100644 --- a/Tests/GravatarTests/AvatarServiceTests.swift +++ b/Tests/GravatarTests/AvatarServiceTests.swift @@ -40,8 +40,8 @@ final class AvatarServiceTests: XCTestCase { func testUploadImageError() async throws { let responseCode = 408 - let successResponse = HTTPURLResponse.errorResponse(code: responseCode) - let sessionMock = URLSessionMock(returnData: "Error".data(using: .utf8)!, response: successResponse) + let errorResponse = HTTPURLResponse.errorResponse(code: responseCode) + let sessionMock = URLSessionMock(returnData: "Error".data(using: .utf8)!, response: errorResponse) let service = avatarService(with: sessionMock) do { diff --git a/Tests/GravatarTests/Resources/avatarUploadResponse.json b/Tests/GravatarTests/Resources/avatarUploadResponse.json index 825158db..798b12ae 100644 --- a/Tests/GravatarTests/Resources/avatarUploadResponse.json +++ b/Tests/GravatarTests/Resources/avatarUploadResponse.json @@ -1,9 +1,7 @@ { - "image_id": "6f3eac1c67f970f2a0c2ea8", - "image_url": "/userimage/000000001/6f3eac1c67f970f2a0c2ea8.jpeg", - "is_cropped": false, - "format": 0, - "rating": "G", - "updated_date": "", - "altText": "John Appleseed's avatar" + "image_id": "6f3eac1c67f970f2a0c2ea8", + "image_url": "https://2.gravatar.com/userimage/133992/9862792c5653946c?size=512", + "rating": "G", + "updated_date": "2024-09-19T11:46:04Z", + "alt_text": "John Appleseed's avatar" } diff --git a/Tests/GravatarTests/URLSessionHTTPClientTests.swift b/Tests/GravatarTests/URLSessionHTTPClientTests.swift index 22ad611a..cfcb331d 100644 --- a/Tests/GravatarTests/URLSessionHTTPClientTests.swift +++ b/Tests/GravatarTests/URLSessionHTTPClientTests.swift @@ -44,7 +44,7 @@ final class URLSessionHTTPClientTests: XCTestCase { do { let _ = try await client.fetchData(with: mockURLRequest) XCTFail("This should throw") - } catch HTTPClientError.invalidHTTPStatusCodeError(let response) { + } catch HTTPClientError.invalidHTTPStatusCodeError(let response, _) { XCTAssertEqual(response.statusCode, invalidStatusCode) } catch { XCTFail() diff --git a/Tests/GravatarUITests/TestImageFetcher.swift b/Tests/GravatarUITests/TestImageFetcher.swift index 86891ebe..210b7149 100644 --- a/Tests/GravatarUITests/TestImageFetcher.swift +++ b/Tests/GravatarUITests/TestImageFetcher.swift @@ -19,7 +19,7 @@ actor TestImageFetcher: ImageDownloader { switch result { case .fail: let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! - throw ImageFetchingError.responseError(reason: .invalidHTTPStatusCode(response: response)) + throw ImageFetchingError.responseError(reason: .invalidHTTPStatusCode(response: response, data: "".data(using: .utf8)!)) case .success: return ImageDownloadResult(image: ImageHelper.testImage, sourceURL: URL(string: url.absoluteString)!) } diff --git a/openapi/modelInlineEnumDeclaration.mustache b/openapi/modelInlineEnumDeclaration.mustache new file mode 100644 index 00000000..e120033a --- /dev/null +++ b/openapi/modelInlineEnumDeclaration.mustache @@ -0,0 +1,7 @@ + {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} enum {{enumName}}: {{^isContainer}}{{dataType}}{{/isContainer}}{{#isContainer}}String{{/isContainer}}, {{#useVapor}}Content, Hashable{{/useVapor}}{{^useVapor}}Codable{{^isContainer}}{{^isString}}{{^isInteger}}{{^isFloat}}{{^isDouble}}, JSONEncodable{{/isDouble}}{{/isFloat}}{{/isInteger}}{{/isString}}{{/isContainer}}{{/useVapor}}, CaseIterable{{#enumUnknownDefaultCase}}{{#isInteger}}, CaseIterableDefaultsLast{{/isInteger}}{{#isFloat}}, CaseIterableDefaultsLast{{/isFloat}}{{#isDouble}}, CaseIterableDefaultsLast{{/isDouble}}{{#isString}}, CaseIterableDefaultsLast{{/isString}}{{#isContainer}}, CaseIterableDefaultsLast{{/isContainer}}{{/enumUnknownDefaultCase}}, Sendable { + {{#allowableValues}} + {{#enumVars}} + case {{{name}}} = {{{value}}} + {{/enumVars}} + {{/allowableValues}} + } diff --git a/openapi/spec.yaml b/openapi/spec.yaml index 4265baf7..8296e914 100644 --- a/openapi/spec.yaml +++ b/openapi/spec.yaml @@ -12,6 +12,8 @@ servers: tags: - name: profiles description: Operations about user profiles + - name: avatars + description: Operations about user avatars components: headers: X-RateLimit-Limit: @@ -29,6 +31,56 @@ components: schema: type: integer schemas: + Avatar: + type: object + description: An avatar that the user has already uploaded to their Gravatar account. + required: + - image_id + - image_url + - rating + - updated_date + - alt_text + properties: + image_id: + type: string + description: Unique identifier for the image. + examples: + - 38be15a98a2bbc40df69172a2a8349 + image_url: + type: string + description: Image URL + examples: + - >- + https://gravatar.com/userimage/252014526/d38bele5a98a2bbc40df69172a2a8348.jpeg + rating: + type: string + description: Rating associated with the image. + enum: + - G + - PG + - R + - X + updated_date: + type: string + format: date-time + description: Date and time when the image was last updated. + pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ + examples: + - '2021-10-01T12:00:00Z' + alt_text: + type: string + description: Alternative text description of the image. + examples: + - >- + Gravatar's avatar image. Gravatar is a service for providing + globally unique avatars. + selected: + type: boolean + description: >- + Whether the image is currently selected as the provided selected + email's avatar. + examples: + - true Link: type: object description: A link the user has added to their profile. @@ -130,6 +182,37 @@ components: description: The image alt text. examples: - A beautiful sunset + Language: + type: object + description: >- + The languages the user knows. This is only provided in authenticated API + requests. + required: + - code + - name + - is_primary + - order + properties: + code: + type: string + description: The language code. + examples: + - en + name: + type: string + description: The language name. + examples: + - English + is_primary: + type: boolean + description: Whether the language is the user's primary language. + examples: + - true + order: + type: integer + description: The order of the language in the user's profile. + examples: + - 1 Profile: type: object description: A user's profile information. @@ -216,6 +299,41 @@ components: description: The pronouns the user uses. examples: - She/They + timezone: + type: string + description: >- + The timezone the user has. This is only provided in authenticated + API requests. + examples: + - Europe/Bratislava + languages: + type: array + description: >- + The languages the user knows. This is only provided in authenticated + API requests. + items: + $ref: '#/components/schemas/Language' + first_name: + type: string + description: >- + User's first name. This is only provided in authenticated API + requests. + examples: + - Alex + last_name: + type: string + description: >- + User's last name. This is only provided in authenticated API + requests. + examples: + - Morgan + is_organization: + type: boolean + description: >- + Whether user is an organization. This is only provided in + authenticated API requests. + examples: + - false links: type: array description: >- @@ -330,6 +448,18 @@ components: pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ examples: - '2021-10-01T12:00:00Z' + Error: + type: object + properties: + error: + type: string + description: The error message + code: + type: string + description: The error code for the error message + required: + - error + - code securitySchemes: apiKey: type: http @@ -337,6 +467,14 @@ components: description: >- Bearer token to authenticate the request. Full profile information is only available in authenticated requests. + oauth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://public-api.wordpress.com/oauth2/authorize + tokenUrl: https://public-api.wordpress.com/oauth2/token + scopes: {} + description: WordPress OAuth token to authenticate the request. paths: /profiles/{profileIdentifier}: get: @@ -354,6 +492,8 @@ paths: slug. schema: type: string + security: + - apiKey: [] responses: '200': description: Successful response @@ -388,5 +528,143 @@ paths: $ref: '#/components/headers/X-RateLimit-Reset' '500': description: Internal server error -security: - - apiKey: [] + /me/associated-email: + get: + summary: Check if the email is associated with the authenticated user + description: >- + Checks if the provided email address is associated with the + authenticated user. + tags: + - profiles + operationId: associatedEmail + security: + - oauth: [] + parameters: + - name: email_hash + in: query + required: true + description: The hash of the email address to check. + schema: + type: string + responses: + '200': + description: The email is associated with the authenticated user + content: + application/json: + schema: + type: object + required: + - associated + properties: + associated: + type: boolean + description: Whether the email is associated with a Gravatar account. + examples: + - true + /me/avatars: + get: + summary: List avatars + description: Retrieves a list of available avatars for the authenticated user. + tags: + - avatars + operationId: getAvatars + security: + - oauth: [] + parameters: + - name: selected_email + in: query + description: >- + The email address used to determine which avatar is selected. The + 'selected' attribute in the avatar list will be set to 'true' for + the avatar associated with this email. + schema: + type: string + default: null + responses: + '200': + description: Successful retrieval of avatars + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Avatar' + post: + summary: Upload new avatar image + description: Uploads a new avatar image for the authenticated user. + tags: + - avatars + operationId: uploadAvatar + security: + - oauth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + data: + type: string + format: binary + description: The avatar image file + required: + - data + responses: + '200': + description: Avatar uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Avatar' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + uncropped_image: + value: + error: Only square images are accepted + code: uncropped_image + unsupported_image: + value: + error: Unsupported image type + code: unsupported_image + /me/avatars/{imageId}/email: + post: + summary: Set avatar for the hashed email + description: Sets the avatar for the provided email hash. + tags: + - avatars + parameters: + - name: imageId + in: path + description: Image ID of the avatar to set as the provided hashed email avatar. + required: true + schema: + type: string + operationId: setEmailAvatar + requestBody: + description: Avatar selection details + required: true + content: + application/json: + schema: + type: object + properties: + email_hash: + type: string + description: The email SHA256 hash to set the avatar for. + examples: + - >- + 31c5543c1734d25c7206f5fd591525d0295bec6fe84ff82f946a34fe970a1e66 + required: + - email_hash + security: + - oauth: [] + responses: + '204': + description: Avatar successfully set +