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
5 changes: 5 additions & 0 deletions .changeset/violet-clocks-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@germ-network/atprototypes": patch
---

add JSONDecoder.atproto (dataDecodingStrategy .atprotoBase64) accepting the data model's unpadded base64, route XRPC response parsing through it, and emit $bytes unpadded
45 changes: 45 additions & 0 deletions Sources/AtprotoTypes/Primitives/AtprotoJSONDecoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// AtprotoJSONDecoder.swift
// AtprotoTypes
//
// Created by Mark @ Germ on 7/15/26.
//

import Foundation

extension JSONDecoder.DataDecodingStrategy {
//The atproto data model serializes bytes as base64 WITHOUT padding — the form
//spec-compliant PDSes emit when re-serializing a record from CBOR — which
//Foundation's default .base64 strategy rejects unless the byte length happens
//to align to a multiple of 3. Accepts both the unpadded (spec) and padded
//(records we previously wrote) forms.
public static var atprotoBase64: JSONDecoder.DataDecodingStrategy {
.custom { decoder in
let container = try decoder.singleValueContainer()
let encoded = try container.decode(String.self)
let remainder = encoded.count % 4
let padded =
remainder == 0
? encoded
: encoded + String(repeating: "=", count: 4 - remainder)
guard let bytes = Data(base64Encoded: padded) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid base64 in bytes field"
)
}
return bytes
}
}
}

extension JSONDecoder {
//A decoder configured for atproto data-model JSON. XRPC response parsing
//reads through this; anything else decoding data-model JSON (records,
//lexicon bytes) should too.
public static var atproto: JSONDecoder {
let decoder = JSONDecoder()
decoder.dataDecodingStrategy = .atprotoBase64
return decoder
}
}
13 changes: 13 additions & 0 deletions Sources/AtprotoTypes/Primitives/Bytes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,18 @@ extension Atproto.Primitive {
public enum CodingKeys: String, CodingKey {
case bytes = "$bytes"
}

//Decoding stays synthesized: whether unpadded base64 is accepted is the
//DECODER's choice — use JSONDecoder.atproto (or .dataDecodingStrategy =
//.atprotoBase64) when reading data-model JSON; a default JSONDecoder keeps
//Foundation's strict padded-only behavior.
//
//Encoding emits the data model's canonical form: base64 without padding.
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var encoded = bytes.base64EncodedString()
while encoded.hasSuffix("=") { encoded.removeLast() }
try container.encode(encoded, forKey: .bytes)
}
}
}
6 changes: 3 additions & 3 deletions Sources/AtprotoTypes/XRPC/ResponseParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ extension Atproto.XRPC.ResponseParsing {
case .ok:
return .ok(try parseSuccess(body: fullResponse.data))
case .badRequest:
let errorObject = try JSONDecoder()
let errorObject = try JSONDecoder.atproto
.decode(
Atproto.XRPC.ErrorResponse.self,
from: fullResponse.data
Expand All @@ -91,7 +91,7 @@ extension Atproto.XRPC.ResponseParsing {
)
}
case let status where Self.recognizedStatuses.contains(status):
let errorObject = try JSONDecoder()
let errorObject = try JSONDecoder.atproto
.decode(
Atproto.XRPC.ErrorResponse.self,
from: fullResponse.data
Expand All @@ -117,7 +117,7 @@ extension Atproto.XRPC.ResponseParsing {
let result = body.isEmpty ? nil : body
return try (result as? Output).tryUnwrap
default:
return try JSONDecoder()
return try JSONDecoder.atproto
.decode(Output.self, from: body)
}
}
Expand Down
77 changes: 77 additions & 0 deletions Tests/AtprotoTypesTests/BytesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// BytesTests.swift
// AtprotoTypes
//
// Created by Mark @ Germ on 7/15/26.
//

import AtprotoTypes
import Foundation
import Testing

struct BytesTests {
//The atproto data model's bytes are base64 without padding. A spec-compliant
//PDS (bsky's own included) re-serializes records that way, so lengths that
//aren't a multiple of 3 bytes arrive needing padding Foundation won't infer.
//33 bytes encodes to exactly 44 chars (no padding — the case that always
//worked); 34 bytes needs "==" (the case the default strategy rejects).
@Test("The atproto decoder accepts unpadded and padded base64", arguments: [32, 33, 34])
func atprotoDecoderAcceptsBothPaddings(count: Int) throws {
let value = Atproto.Primitive.Bytes(bytes: Data(repeating: 0xA5, count: count))

let padded = value.bytes.base64EncodedString()
var unpadded = padded
while unpadded.hasSuffix("=") { unpadded.removeLast() }

for encoded in [padded, unpadded] {
let json = "{\"$bytes\": \"\(encoded)\"}"
let decoded = try JSONDecoder.atproto.decode(
Atproto.Primitive.Bytes.self,
from: json.utf8Data
)
#expect(decoded == value)
}
}

//the tolerance is a decoder option, not a property of Bytes: a default
//JSONDecoder keeps Foundation's strict padded-only behavior
@Test func defaultDecoderStaysStrict() throws {
//34 bytes -> unpadded base64 length isn't a multiple of 4
var unpadded = Data(repeating: 0xA5, count: 34).base64EncodedString()
while unpadded.hasSuffix("=") { unpadded.removeLast() }
let json = "{\"$bytes\": \"\(unpadded)\"}"
#expect(throws: DecodingError.self) {
let _ = try JSONDecoder().decode(
Atproto.Primitive.Bytes.self,
from: json.utf8Data
)
}
}

@Test("Encodes without padding and round-trips through the atproto decoder")
func encodeUnpadded() throws {
//34 bytes -> padded base64 would end in "=="
let value = Atproto.Primitive.Bytes(bytes: Data(repeating: 0x5A, count: 34))

let encoded = try JSONEncoder().encode(value)
let json = try #require(String(data: encoded, encoding: .utf8))
#expect(!json.contains("="))

let decoded = try JSONDecoder.atproto.decode(
Atproto.Primitive.Bytes.self,
from: encoded
)
#expect(decoded == value)
}

@Test("Rejects invalid base64")
func rejectsInvalidBase64() throws {
let json = "{\"$bytes\": \"not*base64!\"}"
#expect(throws: DecodingError.self) {
let _ = try JSONDecoder.atproto.decode(
Atproto.Primitive.Bytes.self,
from: json.utf8Data
)
}
}
}
Loading