Skip to content

Commit 060141d

Browse files
committed
Decode meta_data incorrectly sent as an object
1 parent e46322c commit 060141d

File tree

6 files changed

+520
-18
lines changed

6 files changed

+520
-18
lines changed

Modules/Sources/Networking/Mapper/MetaDataMapper.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,33 @@ struct MetaDataMapper: Mapper {
2020
// Filter out metadata if the key is prefixed with an underscore (internal meta keys)
2121
return metadata.filter { !$0.key.hasPrefix("_") }
2222
}
23+
24+
/// Decodes MetaData from a KeyedDecodingContainer with flexible support for both array and dictionary formats
25+
/// - Parameters:
26+
/// - container: The KeyedDecodingContainer to decode from
27+
/// - key: The key for the metadata field
28+
/// - filterInternalKeys: Whether to filter out keys that start with "_" (default: true)
29+
/// - Returns: Array of MetaData objects
30+
static func decodeMetaData<K>(from container: KeyedDecodingContainer<K>,
31+
forKey key: KeyedDecodingContainer<K>.Key,
32+
filterInternalKeys: Bool = true) -> [MetaData] {
33+
let metadata: [MetaData] = {
34+
// Try to decode as array first (standard format)
35+
if let metaDataArray = try? container.decode([MetaData].self, forKey: key) {
36+
return metaDataArray
37+
}
38+
39+
// Try to decode as object keyed by index strings – this may happen when plugins break the response format
40+
if let metaDataDict = try? container.decode([String: MetaData].self, forKey: key) {
41+
return Array(metaDataDict.values)
42+
}
43+
44+
return []
45+
}()
46+
47+
return filterInternalKeys ? metadata.filter { !$0.key.hasPrefix("_") } : metadata
48+
}
49+
2350
}
2451

2552
/// DataEnvelope Entity:
@@ -38,4 +65,9 @@ private struct MetaDataEnvelope: Decodable {
3865
private enum CodingKeys: String, CodingKey {
3966
case metadata = "meta_data"
4067
}
68+
69+
init(from decoder: Decoder) throws {
70+
let container = try decoder.container(keyedBy: CodingKeys.self)
71+
self.metadata = MetaDataMapper.decodeMetaData(from: container, forKey: .metadata, filterInternalKeys: false)
72+
}
4173
}

Modules/Sources/Networking/Model/Product/Product.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,8 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
535535
let menuOrder = try container.decode(Int.self, forKey: .menuOrder)
536536

537537
// Filter out metadata if the key is prefixed with an underscore (internal meta keys)
538-
let customFields = (try? container.decode([MetaData].self, forKey: .metadata).filter({ !$0.key.hasPrefix("_")})) ?? []
538+
// Support both array format and object keyed by index strings via MetaDataMapper
539+
let customFields = MetaDataMapper.decodeMetaData(from: container, forKey: .metadata)
539540

540541
// In some isolated cases, it appears to be some malformed meta-data that causes this line to throw hence the whole product decoding to throw.
541542
// Since add-ons are optional, `try?` will be used to prevent the whole decoding to stop.

Modules/Sources/Networking/Model/Product/ProductMetadataExtractor.swift

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,21 @@ import WordPressShared
2323
///
2424
internal struct ProductMetadataExtractor: Decodable {
2525

26-
private typealias DecodableDictionary = [String: AnyDecodable]
2726
private typealias AnyDictionary = [String: Any?]
2827

2928
/// Internal metadata representation
3029
///
31-
private let metadata: [DecodableDictionary]
30+
private let metadata: [MetaData]
3231

33-
/// Decode main metadata array as an untyped dictionary.
32+
/// Decode main metadata supporting both array and object formats.
3433
///
3534
init(from decoder: Decoder) throws {
36-
let container = try decoder.singleValueContainer()
37-
self.metadata = try container.decode([DecodableDictionary].self)
35+
let container = try decoder.container(keyedBy: MetaDataKeys.self)
36+
self.metadata = MetaDataMapper.decodeMetaData(from: container, forKey: .metadata, filterInternalKeys: false)
37+
}
38+
39+
private enum MetaDataKeys: String, CodingKey {
40+
case metadata = "meta_data"
3841
}
3942

4043
/// Searches product metadata for subscription data and converts it to a `ProductSubscription` if possible.
@@ -62,22 +65,15 @@ internal struct ProductMetadataExtractor: Decodable {
6265

6366
/// Filters product metadata using the provided prefix.
6467
///
65-
private func filterMetadata(with prefix: String) -> [DecodableDictionary] {
66-
metadata.filter { object in
67-
let objectKey = object["key"]?.value as? String ?? ""
68-
return objectKey.hasPrefix(prefix)
69-
}
68+
private func filterMetadata(with prefix: String) -> [MetaData] {
69+
metadata.filter { $0.key.hasPrefix(prefix) }
7070
}
7171

7272
/// Parses provided metadata to return a dictionary with each metadata object's key and value.
7373
///
74-
private func getKeyValueDictionary(from metadata: [DecodableDictionary]) -> AnyDictionary {
75-
metadata.reduce(AnyDictionary()) { (dict, object) in
76-
var newDict = dict
77-
let objectKey = object["key"]?.value as? String ?? ""
78-
let objectValue = object["value"]?.value
79-
newDict.updateValue(objectValue, forKey: objectKey)
80-
return newDict
74+
private func getKeyValueDictionary(from metadata: [MetaData]) -> AnyDictionary {
75+
metadata.reduce(into: AnyDictionary()) { dict, object in
76+
dict[object.key] = object.value
8177
}
8278
}
8379

Modules/Tests/NetworkingTests/Mapper/MetaDataMapperTests.swift

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,115 @@ final class MetaDataMapperTests: XCTestCase {
4949
XCTAssertEqual(metadata[5], MetaData(metadataID: 6, key: "number_field", value: "42"))
5050
XCTAssertEqual(metadata[6], MetaData(metadataID: 7, key: "empty_field", value: ""))
5151
}
52+
53+
/// Tests that MetaDataMapper.decodeMetaData can decode from array format using KeyedDecodingContainer
54+
///
55+
func test_decodeMetaData_from_KeyedContainer_array_format() throws {
56+
// Given - JSON with metadata as array
57+
let jsonString = """
58+
{
59+
"meta_data": [
60+
{
61+
"id": 1001,
62+
"key": "custom_field_1",
63+
"value": "value1"
64+
},
65+
{
66+
"id": 1002,
67+
"key": "_internal_field",
68+
"value": "internal_value"
69+
},
70+
{
71+
"id": 1003,
72+
"key": "custom_field_2",
73+
"value": "value2"
74+
}
75+
]
76+
}
77+
"""
78+
79+
struct TestObject: Decodable {
80+
let metadata: [MetaData]
81+
82+
private enum CodingKeys: String, CodingKey {
83+
case metadata = "meta_data"
84+
}
85+
86+
init(from decoder: Decoder) throws {
87+
let container = try decoder.container(keyedBy: CodingKeys.self)
88+
self.metadata = MetaDataMapper.decodeMetaData(from: container, forKey: .metadata)
89+
}
90+
}
91+
92+
let data = jsonString.data(using: .utf8)!
93+
let decoder = JSONDecoder()
94+
95+
// When
96+
let testObject = try decoder.decode(TestObject.self, from: data)
97+
98+
// Then
99+
XCTAssertEqual(testObject.metadata.count, 2) // Internal field should be filtered out
100+
XCTAssertEqual(testObject.metadata[0].metadataID, 1001)
101+
XCTAssertEqual(testObject.metadata[0].key, "custom_field_1")
102+
XCTAssertEqual(testObject.metadata[0].value.stringValue, "value1")
103+
XCTAssertEqual(testObject.metadata[1].metadataID, 1003)
104+
XCTAssertEqual(testObject.metadata[1].key, "custom_field_2")
105+
XCTAssertEqual(testObject.metadata[1].value.stringValue, "value2")
106+
}
107+
108+
/// Tests that MetaDataMapper.decodeMetaData can decode from dictionary format using KeyedDecodingContainer
109+
///
110+
func test_decodeMetaData_from_KeyedContainer_dictionary_format() throws {
111+
// Given - JSON with metadata as object keyed by index strings
112+
let jsonString = """
113+
{
114+
"meta_data": {
115+
"0": {
116+
"id": 2001,
117+
"key": "dict_field_1",
118+
"value": "dict_value1"
119+
},
120+
"1": {
121+
"id": 2002,
122+
"key": "_internal_dict_field",
123+
"value": "internal_dict_value"
124+
},
125+
"2": {
126+
"id": 2003,
127+
"key": "dict_field_2",
128+
"value": "dict_value2"
129+
}
130+
}
131+
}
132+
"""
133+
134+
struct TestObject: Decodable {
135+
let metadata: [MetaData]
136+
137+
private enum CodingKeys: String, CodingKey {
138+
case metadata = "meta_data"
139+
}
140+
141+
init(from decoder: Decoder) throws {
142+
let container = try decoder.container(keyedBy: CodingKeys.self)
143+
self.metadata = MetaDataMapper.decodeMetaData(from: container, forKey: .metadata)
144+
}
145+
}
146+
147+
let data = jsonString.data(using: .utf8)!
148+
let decoder = JSONDecoder()
149+
150+
// When
151+
let testObject = try decoder.decode(TestObject.self, from: data)
152+
153+
// Then
154+
XCTAssertEqual(testObject.metadata.count, 2) // Internal field should be filtered out
155+
let fieldNames = Set(testObject.metadata.map { $0.key })
156+
XCTAssertTrue(fieldNames.contains("dict_field_1"))
157+
XCTAssertTrue(fieldNames.contains("dict_field_2"))
158+
XCTAssertFalse(fieldNames.contains("_internal_dict_field"))
159+
}
160+
52161
}
53162

54163
// MARK: - Test Helpers

0 commit comments

Comments
 (0)