Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.
Merged
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ let package = Package(
targets: [
.binaryTarget(
name: "WordPressKit",
url: "https://github.com/user-attachments/files/19949939/WordPressKit.zip",
checksum: "ba06ff0716595023dd6c98b6a5bc74d4abb35bfb668e24026ffd041460f59137"
url: "https://github.com/user-attachments/files/20127687/WordPressKit.zip",
checksum: "bbc81f893eb080a176d018f53d85e6747e529799309c0245c9e204053e75e138"
),
]
)
23 changes: 0 additions & 23 deletions Sources/WordPressKit/Models/RemoteSubscriber.swift

This file was deleted.

85 changes: 0 additions & 85 deletions Sources/WordPressKit/Services/PeopleServiceRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,91 +173,6 @@ public class PeopleServiceRemote: ServiceRemoteWordPressComREST {
})
}

public struct SubscribersParameters {
public var sortField: SortField?
public var sortOrder: SortOrder?
public var filters: [Filter]

public enum SortField: String {
case dateSubscribed = "date_subscribed"
case email = "email"
case name = "name"
case plan = "plan"
case subscriptionStatus = "subscription_status"
}

public enum SortOrder: String {
case ascending = "asc"
case descending = "dsc"
}

public protocol Filter: CustomStringConvertible {}

public enum FilterSubscriptionType: String, Filter {
case email = "email_subscriber"
case reader = "reader_subscriber"
case unconfirmed = "unconfirmed_subscriber"
case blocked = "blocked_subscriber"

public var description: String { rawValue }
}

public enum FilterPaymentType: String, Filter {
case free
case paid

public var description: String { rawValue }
}

public init(sortField: SortField? = nil, sortOrder: SortOrder? = nil, filters: [Filter] = []) {
self.sortField = sortField
self.sortOrder = sortOrder
self.filters = filters
}
}

public struct SubscribersResponse: Decodable {
public var total: Int
public var pages: Int
public var page: Int
public var subscribers: [RemoteSubscriber]
}

public func getSubscribers(
siteID: Int,
page: Int? = nil,
perPage: Int? = 25,
parameters: SubscribersParameters = .init()
) async throws -> SubscribersResponse {
let url = self.path(forEndpoint: "sites/\(siteID)/subscribers", withVersion: ._2_0)
var query: [String: Any] = [:]
if let page {
query["page"] = page
}
if let perPage {
query["per_page"] = perPage
}
if let sortField = parameters.sortField {
query["sort"] = sortField.rawValue
}
if let sortOrder = parameters.sortOrder {
query["sort_order"] = sortOrder.rawValue
}
if !parameters.filters.isEmpty {
query["filters"] = parameters.filters.map { $0.description }
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats

return try await wordPressComRestApi.perform(
.get,
URLString: url,
jsonDecoder: decoder,
type: SubscribersResponse.self
).get().body
}

/// Updates a specified User's Role
///
/// - Parameters:
Expand Down
257 changes: 257 additions & 0 deletions Sources/WordPressKit/Services/SubscribersServiceRemote.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import Foundation

public class SubscribersServiceRemote: ServiceRemoteWordPressComREST {

// MARK: GET Subscribers (Paginated List)

public struct GetSubscribersParameters: Hashable {
public var sortField: SortField?
public var sortOrder: SortOrder?
public var subscriptionTypeFilter: FilterSubscriptionType?
public var paymentTypeFilter: FilterPaymentType?

@frozen public enum SortField: String, CaseIterable {
case dateSubscribed = "date_subscribed"
case email = "email"
case name = "name"
case plan = "plan"
case subscriptionStatus = "subscription_status"
}

@frozen public enum SortOrder: String, CaseIterable {
case ascending = "asc"
case descending = "dsc"
}

@frozen public enum FilterSubscriptionType: String, CaseIterable {
case email = "email_subscriber"
case reader = "reader_subscriber"
case unconfirmed = "unconfirmed_subscriber"
case blocked = "blocked_subscriber"
}

@frozen public enum FilterPaymentType: String, CaseIterable {
case free
case paid
}

public var filters: [String] {
[subscriptionTypeFilter?.rawValue, paymentTypeFilter?.rawValue].compactMap { $0 }
}

public init(sortField: SortField? = nil, sortOrder: SortOrder? = nil, subscriptionTypeFilter: FilterSubscriptionType? = nil, paymentTypeFilter: FilterPaymentType? = nil) {
self.sortField = sortField
self.sortOrder = sortOrder
self.subscriptionTypeFilter = subscriptionTypeFilter
self.paymentTypeFilter = paymentTypeFilter
}
}

public struct GetSubscribersResponse: Decodable {
public var total: Int
public var pages: Int
public var page: Int
public var subscribers: [Subscriber]

public struct Subscriber: Decodable, SubsciberBasicInfoResponse {
public let subscriberID: Int
public let dotComUserID: Int
public let displayName: String?
public let avatar: String?
public let emailAddress: String?
public let dateSubscribed: Date
public let isEmailSubscriptionEnabled: Bool
public let subscriptionStatus: String?

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
subscriberID = try container.decode(Int.self, forKey: "subscription_id")
dotComUserID = try container.decode(Int.self, forKey: "user_id")
displayName = try? container.decodeIfPresent(String.self, forKey: "display_name")
avatar = try? container.decodeIfPresent(String.self, forKey: "avatar")
emailAddress = try? container.decodeIfPresent(String.self, forKey: "email_address")
dateSubscribed = try container.decode(Date.self, forKey: "date_subscribed")
isEmailSubscriptionEnabled = try container.decode(Bool.self, forKey: "is_email_subscriber")
subscriptionStatus = try? container.decodeIfPresent(String.self, forKey: "subscription_status")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I missed anything, but it looks like this part can be simplified by having a CodingKey-conforming enum, and let Swift synthesis the Decodable implementation for us?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swift synthesis the Decodable implementation for us?

I started with it, until I discovered that a bunch of these String fields can contain false when empty, e.g. country: false. So I replaced parsing for these with try? container.decodeIfPresent(String.self. I'm not sure if there is a generalized solution in the framework – couldn't find it after a brief search.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Yeah, that caused some headaches in wordpress-rs too, which has been solved. We can potentially start using the WP.com API client in wordpress-rs, once that's implemented.

}
}
}

/// Gets the list of the site subscribers, including WordPress.com users and
/// email subscribers.
public func getSubscribers(
siteID: Int,
page: Int? = nil,
perPage: Int? = 25,
parameters: GetSubscribersParameters = .init(),
search: String? = nil,
) async throws -> GetSubscribersResponse {
let url = self.path(forEndpoint: "sites/\(siteID)/subscribers", withVersion: ._2_0)
var query: [String: Any] = [:]
if let page {
query["page"] = page
}
if let perPage {
query["per_page"] = perPage
}
if let sortField = parameters.sortField {
query["sort"] = sortField.rawValue
}
if let sortOrder = parameters.sortOrder {
query["sort_order"] = sortOrder.rawValue
}
if !parameters.filters.isEmpty {
query["filters"] = parameters.filters
}
if let search, !search.isEmpty {
query["search"] = search
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats

return try await wordPressComRestApi.perform(
.get,
URLString: url,
parameters: query,
jsonDecoder: decoder,
type: GetSubscribersResponse.self
).get().body
}

// MARK: GET Subscriber (Individual Details)

public protocol SubsciberBasicInfoResponse {
var dotComUserID: Int { get }
var subscriberID: Int { get }
var displayName: String? { get }
var emailAddress: String? { get }
var avatar: String? { get }
var dateSubscribed: Date { get }
}

public final class GetSubscriberDetailsResponse: Decodable, SubsciberBasicInfoResponse {
public let subscriberID: Int
public let dotComUserID: Int
public let displayName: String?
public let avatar: String?
public let emailAddress: String?
public let siteURL: String?
public let dateSubscribed: Date
public let isEmailSubscriptionEnabled: Bool
public let subscriptionStatus: String?
public let country: Country?
public let plans: [Plan]?

public struct Country: Decodable {
public var code: String?
public var name: String?
}

public struct Plan: Decodable {
public let isGift: Bool
public let giftId: Int?
public let paidSubscriptionId: String?
public let status: String
public let title: String
public let currency: String?
public let renewInterval: String?
public let inactiveRenewInterval: String?
public let renewalPrice: Decimal
public let startDate: Date
public let endDate: Date

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
isGift = try container.decode(Bool.self, forKey: "is_gift")
giftId = try container.decodeIfPresent(Int.self, forKey: "gift_id")
paidSubscriptionId = try container.decodeIfPresent(String.self, forKey: "paid_subscription_id")
status = try container.decode(String.self, forKey: "status")
title = try container.decode(String.self, forKey: "title")
currency = try container.decodeIfPresent(String.self, forKey: "currency")
renewInterval = try? container.decodeIfPresent(String.self, forKey: "renew_interval")
inactiveRenewInterval = try? container.decodeIfPresent(String.self, forKey: "inactive_renew_interval")
renewalPrice = try container.decode(Decimal.self, forKey: "renewal_price")
startDate = try container.decode(Date.self, forKey: "start_date")
endDate = try container.decode(Date.self, forKey: "end_date")
}
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
subscriberID = try container.decode(Int.self, forKey: "subscription_id")
dotComUserID = try container.decode(Int.self, forKey: "user_id")
displayName = try? container.decodeIfPresent(String.self, forKey: "display_name")
avatar = try? container.decodeIfPresent(String.self, forKey: "avatar")
emailAddress = try? container.decodeIfPresent(String.self, forKey: "email_address")
siteURL = try? container.decodeIfPresent(String.self, forKey: "url")
dateSubscribed = try container.decode(Date.self, forKey: "date_subscribed")
isEmailSubscriptionEnabled = try container.decode(Bool.self, forKey: "is_email_subscriber")
subscriptionStatus = try? container.decodeIfPresent(String.self, forKey: "subscription_status")
country = try? container.decodeIfPresent(Country.self, forKey: "country")
plans = try container.decodeIfPresent([Plan].self, forKey: "plans")
}
}

/// Gets stats for the given subscriber.
///
/// Example: https://public-api.wordpress.com/wpcom/v2/sites/239619264/subscribers/individual?subscription_id=907116368
public func getSubsciberDetails(
siteID: Int,
subscriberID: Int,
type: String = "email"
) async throws -> GetSubscriberDetailsResponse {
let url = self.path(forEndpoint: "sites/\(siteID)/subscribers/individual", withVersion: ._2_0)
let query: [String: Any] = [
"subscription_id": subscriberID,
"type": type
]

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats

return try await wordPressComRestApi.perform(
.get,
URLString: url,
parameters: query,
jsonDecoder: decoder,
type: GetSubscriberDetailsResponse.self
).get().body
}

public struct GetSubscriberStatsResponse: Decodable {
public var emailsSent: Int
public var uniqueOpens: Int
public var uniqueClicks: Int
}

/// Gets stats for the given subscriber.
///
/// Example: https://public-api.wordpress.com/wpcom/v2/sites/239619264/individual-subscriber-stats?subscription_id=907116368
public func getSubsciberStats(
siteID: Int,
subscriberID: Int
) async throws -> GetSubscriberStatsResponse {
let url = self.path(forEndpoint: "sites/\(siteID)/individual-subscriber-stats", withVersion: ._2_0)
let query: [String: Any] = [
"subscription_id": subscriberID
]
return try await wordPressComRestApi.perform(
.get,
URLString: url,
parameters: query,
jsonDecoder: JSONDecoder.apiDecoder,
type: GetSubscriberStatsResponse.self
).get().body
}
}

extension SubscribersServiceRemote.SubsciberBasicInfoResponse {
public var avatarURL: URL? {
avatar.flatMap(URL.init)
}

public var isDotComUser: Bool {
dotComUserID > 0
}
}
Loading