Repository: TootSDK/TootSDK (swift)

Issue:
Decoding error when getting Instance information from Pixelfed server
Pixelfed server is running latest version (0.12.3 as of this bug).

Attempting to call `client.getInstanceInfo()` throws a decoding error:
> decodingError("Type \'Int\' mismatch:Expected to decode Int but found a string instead.\n codingPath:[CodingKeys(stringValue: \"stats\", intValue: nil), CodingKeys(stringValue: \"statusCount\", intValue: nil)]")

This seems related to a recent change to the Pixelfed API (that's likely a bug, per [this issue](https://github.com/pixelfed/pixelfed/issues/5178), as it deviates from the Mastodon spec).

In a case like this, I don't know if a workaround makes sense in this SDK or wait and see if they address this bug in a future release.

Pull request description:
Fixes #300

Patch:
diff --git a/Examples/swiftyadmin/README.md b/Examples/swiftyadmin/README.md
index 6013e83e..c224bd39 100644
--- a/Examples/swiftyadmin/README.md
+++ b/Examples/swiftyadmin/README.md
@@ -2,6 +2,10 @@
 
 An example command line utility to interact with and control a server using TootSDK.
 
+```shell
+swift build && ./.build/debug/swiftyadmin get-instance -u https://the-url-of-your-server
+```
+
 Since most commands require authentication, you can first login to obtain an access token. The login command demonstrates obtaining a token without a user interface (using the command line).
 
 ```shell
diff --git a/Package.swift b/Package.swift
index bf04d163..94aab77c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -69,6 +69,7 @@ let package = Package(
                 .copy("Resources/instance_iceshrimp_contact_removed.json"),
                 .copy("Resources/instance_iceshrimpnet.json"),
                 .copy("Resources/instance_sharkey_contact_removed.json"),
+                .copy("Resources/instance_pixelfed.json"),
                 .copy("Resources/list.json"),
                 .copy("Resources/post_edited.json"),
                 .copy("Resources/post no emojis.json"),
diff --git a/Sources/TootSDK/Models/Card.swift b/Sources/TootSDK/Models/Card.swift
index 4d881d4b..91eddfcf 100644
--- a/Sources/TootSDK/Models/Card.swift
+++ b/Sources/TootSDK/Models/Card.swift
@@ -45,7 +45,7 @@ public struct Card: Codable, Hashable, Sendable {
         self.blurhash = blurhash
     }
 
-    public enum CardType: String, Codable, Hashable {
+    public enum CardType: String, Codable, Hashable, Sendable {
         case link, photo, video, rich
     }
 
diff --git a/Sources/TootSDK/Models/Instance.swift b/Sources/TootSDK/Models/Instance.swift
index 57b77a61..9d7370be 100644
--- a/Sources/TootSDK/Models/Instance.swift
+++ b/Sources/TootSDK/Models/Instance.swift
@@ -92,6 +92,38 @@ public struct Instance: Codable, Hashable {
             case postCount = "statusCount"
             case domainCount
         }
+
+        public init(from decoder: Decoder) throws {
+            // Custom decoder handles the possibility for count values to be provided as String
+            // e.g. Pixelfed in v0.12.3 https://github.com/TootSDK/TootSDK/issues/300
+
+            let container = try decoder.container(keyedBy: CodingKeys.self)
+            // userCount
+            if let intValue = try? container.decode(Int.self, forKey: .userCount) {
+                self.userCount = intValue
+            } else if let stringValue = try? container.decode(String.self, forKey: .userCount) {
+                self.userCount = Int(stringValue)
+            } else {
+                self.userCount = nil
+            }
+            // postCount
+            if let intValue = try? container.decode(Int.self, forKey: .postCount) {
+                self.postCount = intValue
+            } else if let stringValue = try? container.decode(String.self, forKey: .postCount) {
+                self.postCount = Int(stringValue)
+            } else {
+                self.postCount = nil
+            }
+
+            // domainCount
+            if let intValue = try? container.decode(Int.self, forKey: .domainCount) {
+                self.domainCount = intValue
+            } else if let stringValue = try? container.decode(String.self, forKey: .domainCount) {
+                self.domainCount = Int(stringValue)
+            } else {
+                self.domainCount = nil
+            }
+        }
     }
 
     public struct Configuration: Codable, Hashable {


Test patch:
diff --git a/Tests/TootSDKTests/FlavourTests.swift b/Tests/TootSDKTests/FlavourTests.swift
index f61465bd..69e84a55 100644
--- a/Tests/TootSDKTests/FlavourTests.swift
+++ b/Tests/TootSDKTests/FlavourTests.swift
@@ -24,6 +24,11 @@ final class FlavourTests: XCTestCase {
         XCTAssertEqual(instance.flavour, .pixelfed)
     }
 
+    func testDetectsPixelfed12() throws {
+        let instance = try localObject(Instance.self, "instance_pixelfed")
+        XCTAssertEqual(instance.flavour, .pixelfed)
+    }
+
     func testDetectsFriendica() throws {
         let instance = try localObject(Instance.self, "instance_friendica_nocontact")
         XCTAssertEqual(instance.flavour, .friendica)
diff --git a/Tests/TootSDKTests/InstanceTests.swift b/Tests/TootSDKTests/InstanceTests.swift
index ecbc3b22..dc7e873e 100644
--- a/Tests/TootSDKTests/InstanceTests.swift
+++ b/Tests/TootSDKTests/InstanceTests.swift
@@ -21,4 +21,24 @@ final class InstanceTests: XCTestCase {
         XCTAssertEqual(result.invitesEnabled, false)
         XCTAssertEqual(result.registrations, true)
     }
+
+    func testPixelfed12CountsAreStringsWeHandleGracefully() throws {
+        // arrange
+        let json = localContent("instance_pixelfed")
+        let decoder = TootDecoder()
+
+        // act
+        let result = try decoder.decode(Instance.self, from: json)
+
+        // assert
+        XCTAssertNotNil(result)
+        XCTAssertEqual(result.languages, ["en"])
+        XCTAssertEqual(result.version, "3.5.3 (compatible; Pixelfed 0.12.3)")
+        XCTAssertEqual(result.uri, "pixelfed.social")
+        XCTAssertEqual(result.title, "pixelfed")
+        XCTAssertEqual(result.registrations, true)
+        XCTAssertEqual(result.stats.domainCount, 26576)
+        XCTAssertEqual(result.stats.userCount, 119267)
+        XCTAssertEqual(result.stats.postCount, 29_662_653)
+    }
 }
diff --git a/Tests/TootSDKTests/Resources/instance_pixelfed.json b/Tests/TootSDKTests/Resources/instance_pixelfed.json
new file mode 100644
index 00000000..225b647e
--- /dev/null
+++ b/Tests/TootSDKTests/Resources/instance_pixelfed.json
@@ -0,0 +1,94 @@
+{
+  "description": "The original Pixelfed instance, operated by the main developer @dansup",
+  "languages": ["en"],
+  "stats": {
+    "domain_count": 26576,
+    "user_count": 119267,
+    "status_count": "29662653"
+  },
+  "version": "3.5.3 (compatible; Pixelfed 0.12.3)",
+  "uri": "pixelfed.social",
+  "short_description": "The original Pixelfed instance, operated by the main developer @dansup",
+  "rules": [
+    {
+      "id": "1",
+      "text": "Sexually explicit or violent media must be marked as sensitive when posting"
+    },
+    {
+      "id": "2",
+      "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism, including targeted misgendering, deadnaming, or promotion of so-called \"conversion therapy\""
+    },
+    {
+      "id": "3",
+      "text": "No incitement of violence or promotion of violent ideologies"
+    },
+    {
+      "id": "4",
+      "text": "No harassment, dogpiling or doxxing of other users"
+    },
+    {
+      "id": "5",
+      "text": "No content illegal in United States"
+    },
+    {
+      "id": "6",
+      "text": "While English is our primary language, you are free to use any language here! We will translate reported posts in other languages to English."
+    }
+  ],
+  "configuration": {
+    "media_attachments": {
+      "image_matrix_limit": 16777216,
+      "supported_mime_types": [
+        "image/jpeg",
+        "image/gif",
+        "image/png",
+        "video/mp4"
+      ],
+      "video_frame_rate_limit": 120,
+      "video_matrix_limit": 2304000,
+      "video_size_limit": 15360000,
+      "image_size_limit": 15360000
+    },
+    "polls": {
+      "max_options": 4,
+      "min_expiration": 300,
+      "max_characters_per_option": 50,
+      "max_expiration": 2629746
+    },
+    "statuses": {
+      "characters_reserved_per_url": 23,
+      "max_media_attachments": 10,
+      "max_characters": 2000
+    }
+  },
+  "title": "pixelfed",
+  "urls": {
+    "streaming_api": null
+  },
+  "approval_required": false,
+  "thumbnail": "https://pixelfed.social/storage/headers/Hb2Qs2gfWofB4kEmSRArGqfr0h3DeBgrjLcwZ23r.jpg",
+  "contact_account": {
+    "acct": "dansup",
+    "avatar_static": "https://pixelfed.social/storage/avatars/000/000/000/000/000/000/2/mLZr2R47XEwbmasH2M3P_avatar.jpg?v=57",
+    "locked": false,
+    "statuses_count": 619,
+    "bot": false,
+    "following_count": 180,
+    "url": "https://pixelfed.social/dansup",
+    "emojis": [],
+    "discoverable": true,
+    "last_status_at": null,
+    "id": "2",
+    "header_static": "https://pixelfed.social/storage/headers/missing.png",
+    "followers_count": 12350,
+    "note": "Hi, I'm the developer behind <a class=\"u-url mention\" href=\"https://pixelfed.social/Pixelfed\" rel=\"external nofollow noopener\" target=\"_blank\">@Pixelfed</a>! \n\nAlso <a class=\"u-url list-slug\" href=\"https://pixelfed.social/@dansup@mastodon.social\" rel=\"external nofollow noopener\" target=\"_blank\">@dansup@mastodon.social</a> \n\n<a href=\"https://pixelfed.social/discover/tags/pixelfed?src=hash\" title=\"#pixelfed\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#pixelfed</a> <a href=\"https://pixelfed.social/discover/tags/design?src=hash\" title=\"#design\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#design</a> <a href=\"https://pixelfed.social/discover/tags/webdev?src=hash\" title=\"#webdev\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#webdev</a> <a href=\"https://pixelfed.social/discover/tags/pixelfedApp?src=hash\" title=\"#pixelfedApp\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#pixelfedApp</a> <a href=\"https://pixelfed.social/discover/tags/supApp?src=hash\" title=\"#supApp\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#supApp</a> <a href=\"https://pixelfed.social/discover/tags/loops?src=hash\" title=\"#loops\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#loops</a> <a href=\"https://pixelfed.social/discover/tags/fedi22?src=hash\" title=\"#fedi22\" class=\"u-url hashtag\" rel=\"external nofollow noopener\">#fedi22</a>",
+    "avatar": "https://pixelfed.social/storage/avatars/000/000/000/000/000/000/2/mLZr2R47XEwbmasH2M3P_avatar.jpg?v=57",
+    "display_name": "dansup",
+    "header": "https://pixelfed.social/storage/headers/missing.png",
+    "created_at": "2018-06-01T05:01:59.000000Z",
+    "fields": [],
+    "username": "dansup"
+  },
+  "email": "hello@pixelfed.org",
+  "registrations": true
+}