Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Modules/Sources/Networking/Mapper/SingleItemMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import Foundation

/// SingleItemMapper: Maps generic REST API requests for a single item
///
struct SingleItemMapper<Output: Decodable>: Mapper {
public struct SingleItemMapper<Output: Decodable>: Mapper {
/// Site Identifier associated to the items that will be parsed.
///
/// We're injecting this field via `JSONDecoder.userInfo` because SiteID is not returned by our endpoints.
///
let siteID: Int64

public init(siteID: Int64) {
self.siteID = siteID
}

/// (Attempts) to convert a dictionary into Output.
///
func map(response: Data) throws -> Output {
public func map(response: Data) throws -> Output {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter)
decoder.userInfo = [
Expand Down
94 changes: 72 additions & 22 deletions Modules/Sources/Networking/Remote/SystemStatusRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import Foundation
/// System Status: Remote Endpoint
///
public class SystemStatusRemote: Remote {
/// Fields that can be requested in the app from the system status endpoint.
/// Reference of all supported fields: https://woocommerce.github.io/woocommerce-rest-api-docs/#system-status-properties
public enum Field {
case activePlugins
case inactivePlugins
case environment
case settings
}

/// Retrieves information from the system status that belongs to the current site.
/// Currently fetching:
Expand All @@ -16,19 +24,17 @@ public class SystemStatusRemote: Remote {
///
public func loadSystemInformation(for siteID: Int64,
completion: @escaping (Result<SystemStatus, Error>) -> Void) {
let path = Constants.systemStatusPath
let parameters = [
ParameterKeys.fields: [ParameterValues.environment, ParameterValues.activePlugins, ParameterValues.inactivePlugins]
]
let request = JetpackRequest(wooApiVersion: .mark3,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true)
let mapper = SystemStatusMapper(siteID: siteID)

enqueue(request, mapper: mapper, completion: completion)
Task { @MainActor in
do {
let mapper = SystemStatusMapper(siteID: siteID)
let systemStatus = try await loadSystemStatus(for: siteID,
fields: [Field.environment, Field.activePlugins, Field.inactivePlugins],
Copy link
Contributor

Choose a reason for hiding this comment

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

Currently unused, but I think we're missing the settings field here?

Suggested change
fields: [Field.environment, Field.activePlugins, Field.inactivePlugins],
fields: [Field.environment, Field.activePlugins, Field.inactivePlugins, Field.settings],

Copy link
Contributor Author

@jaclync jaclync Jul 14, 2025

Choose a reason for hiding this comment

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

SystemStatusMapper/SystemStatus currently does not need the settings field for the use case of this remote method (as in the fields in the deleted line 21), thus I don't think we need to fetch it for performance reason.

mapper: mapper)
completion(.success(systemStatus))
} catch {
completion(.failure(error))
}
}
}

/// Fetch details about system status for a given site.
Expand All @@ -39,15 +45,48 @@ public class SystemStatusRemote: Remote {
///
public func fetchSystemStatusReport(for siteID: Int64,
completion: @escaping (Result<SystemStatusReport, Error>) -> Void) {
Task { @MainActor in
do {
let mapper = SystemStatusReportMapper(siteID: siteID)
let systemStatus = try await loadSystemStatus(for: siteID,
fields: nil,
mapper: mapper)
completion(.success(systemStatus))
} catch {
completion(.failure(error))
}
}
}

/// Loads system status information with configurable fields for a given site.
///
/// - Parameters:
/// - siteID: Site for which the system status is fetched from.
/// - fields: Optional array of fields to fetch. If nil or empty, it fetches all available fields.
/// - mapper: Mapper to transform the response data.
/// - Returns: Mapped object based on the mapper output type.
/// - Throws: Network or parsing errors.
///
public func loadSystemStatus<T, M: Mapper>(for siteID: Int64,
fields: [Field]? = nil,
mapper: M) async throws -> T where M.Output == T {
let path = Constants.systemStatusPath
let parameters: [String: Any]? = {
if let fields, !fields.isEmpty {
return [
ParameterKeys.fields: fields.map(\.rawValue)
]
} else {
return nil
}
}()
let request = JetpackRequest(wooApiVersion: .mark3,
method: .get,
siteID: siteID,
path: path,
parameters: nil,
parameters: parameters,
availableAsRESTRequest: true)
let mapper = SystemStatusReportMapper(siteID: siteID)
enqueue(request, mapper: mapper, completion: completion)
return try await enqueue(request, mapper: mapper)
}
}

Expand All @@ -58,13 +97,24 @@ private extension SystemStatusRemote {
static let systemStatusPath: String = "system_status"
}

enum ParameterValues {
static let activePlugins: String = "active_plugins"
static let inactivePlugins: String = "inactive_plugins"
static let environment: String = "environment"
}

enum ParameterKeys {
static let fields: String = "_fields"
}
}

// MARK: - Field Raw Values
//
private extension SystemStatusRemote.Field {
var rawValue: String {
switch self {
case .activePlugins:
return "active_plugins"
case .inactivePlugins:
return "inactive_plugins"
case .environment:
return "environment"
case .settings:
return "settings"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation
import Networking

public protocol POSSystemStatusServiceProtocol {
/// Loads WooCommerce plugin and POS feature switch value remotely for eligibility checks.
/// - Parameter siteID: The site ID to fetch information for.
/// - Returns: POSPluginAndFeatureInfo containing plugin and feature data.
/// - Throws: Network or parsing errors.
func loadWooCommercePluginAndPOSFeatureSwitch(siteID: Int64) async throws -> POSPluginAndFeatureInfo
}

/// Contains WooCommerce plugin information and POS feature switch value.
public struct POSPluginAndFeatureInfo {
public let wcPlugin: SystemPlugin?
public let featureValue: Bool?

public init(wcPlugin: SystemPlugin?, featureValue: Bool?) {
self.wcPlugin = wcPlugin
self.featureValue = featureValue
}
}

/// Service for fetching POS-related system status information.
public final class POSSystemStatusService: POSSystemStatusServiceProtocol {
private let remote: SystemStatusRemote

public init(credentials: Credentials?) {
let network = AlamofireNetwork(credentials: credentials)
remote = SystemStatusRemote(network: network)
}

/// Test-friendly initializer that accepts a network implementation.
init(network: Network) {
remote = SystemStatusRemote(network: network)
}

public func loadWooCommercePluginAndPOSFeatureSwitch(siteID: Int64) async throws -> POSPluginAndFeatureInfo {
let mapper = SingleItemMapper<POSPluginEligibilitySystemStatus>(siteID: siteID)
let systemStatus: POSPluginEligibilitySystemStatus = try await remote.loadSystemStatus(
for: siteID,
fields: [.activePlugins, .settings],
mapper: mapper
)

// Finds WooCommerce plugin from active plugins response.
guard let wcPlugin = systemStatus.activePlugins.first(where: { $0.plugin == Constants.wcPluginPath }) else {
return POSPluginAndFeatureInfo(wcPlugin: nil, featureValue: nil)
Copy link
Contributor

Choose a reason for hiding this comment

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

If no WooCommerce plugin path is found at this point of execution, should we throw here rather than returning a POSPluginAndFeatureInfo with nil values?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question - I thought about it before, and decided to keep this service simple and just return data if available and throw errors when the app fails to retrieve the data. The use case determines how to handle when the plugin / feature value isn't available.

Ideally, all the plugin related checks can be in Yosemite. However, right now VersionHelpers exists in the app layer and it'd require some refactoring to move it to Yosemite. If I have some time before the i2 release next Friday, I will tackle this.

}

// Extracts POS feature value from settings response.
let featureValue = systemStatus.settings.enabledFeatures?.contains(SiteSettingsFeature.pointOfSale.rawValue) == true ? true : nil
return POSPluginAndFeatureInfo(wcPlugin: wcPlugin, featureValue: featureValue)
}
}

private extension POSSystemStatusService {
enum Constants {
static let wcPluginPath = "woocommerce/woocommerce.php"
}
}

// MARK: - Network Response Structs

private struct POSPluginEligibilitySystemStatus: Decodable {
let activePlugins: [SystemPlugin]
let settings: POSEligibilitySystemStatusSettings

enum CodingKeys: String, CodingKey {
case activePlugins = "active_plugins"
case settings
}
}

private struct POSEligibilitySystemStatusSettings: Decodable {
// As `settings.enable_features` was introduced in WC version 9.9.0, this field is optional.
// Ref: https://github.com/woocommerce/woocommerce/pull/57168
Comment on lines +75 to +76
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice 💯

let enabledFeatures: [String]?

enum CodingKeys: String, CodingKey {
case enabledFeatures = "enabled_features"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"data": {
"active_plugins":[
{
"plugin": "woocommerce/woocommerce.php",
"name": "WooCommerce",
"version": "10.0.0-dev",
"version_latest": "10.0.0-dev",
"url": "https://woocommerce.com/",
"author_name": "Automattic",
"author_url": "https://woocommerce.com",
"network_activated": false
}
],
"settings": {
"api_enabled": false,
"force_ssl": false,
"currency": "USD",
"currency_symbol": "&#36;",
"currency_position": "left",
"thousand_separator": ",",
"decimal_separator": ".",
"number_of_decimals": 2,
"geolocation_enabled": false,
"taxonomies": {
"external": "external",
"grouped": "grouped",
"simple": "simple",
"variable": "variable"
},
"product_visibility_terms": {
"exclude-from-catalog": "exclude-from-catalog",
"exclude-from-search": "exclude-from-search",
"featured": "featured",
"outofstock": "outofstock",
"rated-1": "rated-1",
"rated-2": "rated-2",
"rated-3": "rated-3",
"rated-4": "rated-4",
"rated-5": "rated-5"
},
"woocommerce_com_connected": "no",
"enforce_approved_download_dirs": true,
"order_datastore": "Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore",
"HPOS_enabled": true,
"HPOS_sync_enabled": false,
"enabled_features": [
"analytics",
"marketplace",
"order_attribution",
"site_visibility_badge",
"remote_logging",
"email_improvements",
"custom_order_tables"
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"data": {
"active_plugins":[
{
"plugin": "woocommerce/woocommerce.php",
"name": "WooCommerce",
"version": "10.0.0-dev",
"version_latest": "10.0.0-dev",
"url": "https://woocommerce.com/",
"author_name": "Automattic",
"author_url": "https://woocommerce.com",
"network_activated": false
}
],
"settings": {
"api_enabled": false,
"force_ssl": false,
"currency": "USD",
"currency_symbol": "&#36;",
"currency_position": "left",
"thousand_separator": ",",
"decimal_separator": ".",
"number_of_decimals": 2,
"geolocation_enabled": false,
"taxonomies": {
"external": "external",
"grouped": "grouped",
"simple": "simple",
"variable": "variable"
},
"product_visibility_terms": {
"exclude-from-catalog": "exclude-from-catalog",
"exclude-from-search": "exclude-from-search",
"featured": "featured",
"outofstock": "outofstock",
"rated-1": "rated-1",
"rated-2": "rated-2",
"rated-3": "rated-3",
"rated-4": "rated-4",
"rated-5": "rated-5"
},
"woocommerce_com_connected": "no",
"enforce_approved_download_dirs": true,
"order_datastore": "Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore",
"HPOS_enabled": true,
"HPOS_sync_enabled": false,
"enabled_features": [
"analytics",
"marketplace",
"order_attribution",
"site_visibility_badge",
"remote_logging",
"email_improvements",
"point_of_sale",
"custom_order_tables"
]
}
}
}
Loading