fix(swift): add contentful.swift Entry adapter for OptimizedEntry [NT-3808] - #393
Merged
David Nalchevanidze (nalchevanidze) merged 22 commits intoAug 3, 2026
Conversation
…-3808]
Ships OptimizationEntryMapping and a Contentful.Entry-based OptimizedEntry
initializer so integrators stop hand-writing the Entry -> {sys, fields,
metadata} mapping. Always emits metadata (tags/concepts) so the resolver's
entry guard can no longer silently fall back to baseline, and adds
ResolvedEntry for typed field reads on the resolved output.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-3808] Extends OptimizationEntryMapping's asset handling to surface description, contentType, and file details (size/image dimensions) instead of only title/file.url, and adds a case for Asset.FileMetadata decoded directly as a field value (a custom Object field shaped like a file metadata blob). Both were previously silently dropped. Adds coverage for the asset-with-no-file fallback path (select query / still-processing upload) that was previously untested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ry [NT-3808] Carries sys.createdAt/updatedAt/revision/locale through OptimizationEntryMapping when present, and adds matching ResolvedEntry accessors (localeCode, createdAt, updatedAt) plus String/Int subscripts, so a resolved variant reads like a fetched Entry rather than exposing only id/getField. type, currentlySelectedLocale, metadata, and setLocale are deliberately not mirrored — documented on ResolvedEntry, since contentful.swift gives no way to reconstruct them from a resolved map. Also records these types and the mirroring boundary in the iOS SDK knowledge base. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…3808] Existing coverage only proved the ancestor-cycle guard terminates a 2-node cycle (parent <-> child) and expansion holds through 3 levels. Adds a 3-node cycle (a -> b -> c -> a), which a guard that only compared against the immediate parent (instead of the full ancestors path) would still pass the 2-node case but loop forever on, and a 5-level linear chain to rule out a hidden depth cap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…NT-3808] Extends the imperative UIKit path with the same Contentful.Entry support OptimizedEntry already has for SwiftUI: OptimizationClient.resolveOptimizedEntry now overloads on baseline type, mapping a Contentful.Entry through OptimizationEntryMapping once and delegating to the existing dict-based overload, returning ResolvedContentfulOptimizedEntry (entry: ResolvedEntry) instead of a raw dict. Inherits the dict overload's fail-soft behavior exactly. Covers the not-initialized fallback, that the fallback actually routes through OptimizationEntryMapping, a real round trip through the initialized JS bridge, and that this is genuinely resolved by Swift overload resolution rather than a differently-named method. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…CTEntry [NT-3808] Consolidate the Contentful.Entry <-> JSON mapping and the resolved-entry reader into a single CTEntry type backed by JSONValue, with all Contentful-type dispatch/encoding moved into small Codable envelope structs under a private CDA namespace. Also merges ResolvedContentfulOptimizedEntry into ResolvedOptimizedEntry (entry is now CTEntry for both the dict and Contentful.Entry overloads of resolveOptimizedEntry) and consolidates the test files that covered these types (CTEntryTests, OptimizationClientTests) accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ry [NT-3808] Store OptimizedEntry.entry as CTEntry instead of [String: Any] so the non-optimized rendering path reads through the same getField/id surface as the optimized path, rather than a raw dict. Also fixes a real bug this surfaced: getField<T> with T inferred as Any returns a non-nil Optional(nil) for a missing field (nil as? Any always succeeds), so isOptimized now checks presence via toFoundation() instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace CTEntry's private let json: JSONValue storage with
CDA.EntryEnvelope, so the type is backed by the same typed
{sys, fields, metadata} contract CDA.EntryEnvelope.from already builds
from a Contentful.Entry, instead of an untyped JSON tree.
Sys/EntryEnvelope decode sys/fields/metadata (and each of Sys's own
properties) independently via try?, so a caller-supplied baseline that's
missing or has a wrong-typed key degrades only that piece to nil rather
than throwing and losing the whole entry - matching this type's existing
"lose a field, not the entry" policy and every behavior the prior
JSONValue-backed tests already pinned.
init(any:) keeps its parseValue validation walk rather than calling
JSONSerialization.data(withJSONObject:) directly on unvalidated input:
that API raises an uncaught NSException (not a catchable Error) on a
type it can't serialize, e.g. Date, which would crash the process instead
of throwing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dation checks Replace the hand-written parseValue recursive JSONValue walk with JSONSerialization.isValidJSONObject as a pre-check, then decode straight off JSONSerialization.data(withJSONObject:) - same safety property (throws a catchable error rather than crashing on an unsupported type like Date), less code to maintain. Also drop toJSON's unreachable String(data:encoding:.utf8) failure path: JSONEncoder's output is always valid UTF-8, so String(decoding:as:) (which cannot fail) is the correct call here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pe to CDA.Entry Cuts multi-paragraph docstrings down to the load-bearing why (Int/Double JSONValue quirk, NSException guard, ancestor-cycle handling, the metadata requirement) and drops restated "what" and provenance narration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Matches CTEntry's own init(_ entry:) and standard Swift construction idiom, rather than mixing static factory methods with initializers within the same private CDA enum. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
David Nalchevanidze (nalchevanidze)
force-pushed
the
fix/nt-3808-ios-optimized-entry-contentful-mapping
branch
from
July 31, 2026 09:37
856f81a to
bb10403
Compare
…Optimized check isOptimized previously round-tripped the whole entry through toFoundation() (JSONEncoder/JSONSerialization) just to check one field's presence, silently swallowing an encode failure as "not optimized." envelope.fields is already the decoded dictionary, so presence is a direct, infallible lookup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ResolvedOptimizedEntry.entry became CTEntry for the dict-based resolveOptimizedEntry overload too, which broke implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift (entirely [String: Any]-based) since toFoundation() was internal to the package module. CI's "Build iOS UI Test Bundles" job caught this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t CTEntry(any:) fallback try? CTEntry(any: x) ?? fallback was duplicated at four call sites, two of which swallowed a parse failure with no log signal at all, and two of which used try! CTEntry(any: [:]) instead of a proper .empty case. Consolidates into one static factory that logs via DiagnosticLogger before returning an explicit fallback (.empty by default). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion back to internal toFoundation() as? [String: Any] ?? x was duplicated at 4 call sites (3 in packages/ios, 1 in the UIKit reference implementation), one of which used a different fallback than the rest. toDictionary(fallback:) consolidates the cast-and-fallback, matching parseWithFallback's naming. toFoundation() no longer needs to be public now that every external caller goes through toDictionary() instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
David Nalchevanidze (nalchevanidze)
marked this pull request as ready for review
July 31, 2026 10:26
David Nalchevanidze (nalchevanidze)
marked this pull request as draft
July 31, 2026 10:26
David Nalchevanidze (nalchevanidze)
marked this pull request as ready for review
August 3, 2026 06:37
…rim comments Reuse shared instances instead of allocating per call in the resolve hot path, and cut doc comments that only restated the code, per PR review.
…ecoder Only CTEntry's CDA mapping used it, so it doesn't belong on the general-purpose JSONValue type. Reuses the shared encoder/decoder instead of allocating fresh JSONEncoder/JSONDecoder instances.
…one initializer init(any:) had no caller that used its throw separately from parseWithFallback's catch-and-log, so collapse them into a single non-throwing init(any:fallback:). Also fold CTEntry.toFoundation() into toDictionary(fallback:) since every caller immediately cast its Any result to [String: Any] anyway.
LinkValue, AssetEnvelope, LocationEnvelope, FileMetadataEnvelope, and RichTextNodeEnvelope become Link, Asset, Location, FileMetadata, and RichTextNode, matching the existing bare-name convention CDA.Sys/ CDA.Entry/CDA.Metadata already use to shadow their Contentful.* counterparts.
…A.Sys An entry with no content type ID previously encoded contentType.sys.id as "", which still satisfies the JS resolver's contentTypeSys.id !== undefined guard check and could make a malformed entry spuriously pass. Omit the whole contentType key instead when the ID is unknown.
…DA.encoded() Bindings still used pre-rename names from when these cases held *Envelope/LinkValue types. Match them to the current case/type names (entry, asset, stub, link, richText, fileMetadata, location).
Felipe Mamud (fmamud)
approved these changes
Aug 3, 2026
Charles Hudson (phobetron)
approved these changes
Aug 3, 2026
David Nalchevanidze (nalchevanidze)
deleted the
fix/nt-3808-ios-optimized-entry-contentful-mapping
branch
August 3, 2026 12:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem (NT-3808)
contentful.swift's typedEntrycannot be fed intoOptimizedEntrywithout a hand-written adapter — the SDK's own surface (OptimizedEntry,resolveOptimizedEntry, the resolver's entry guard) is built entirely around an untyped[String: Any]map, with no shipped conversion fromEntry. Concretely:metadataobject, butEntrykeepsmetadataofffields. A naive mapping omits it and the entry silently falls back to baseline — with no warning or notification, indistinguishable from an entry that genuinely has no experience configured.Every customer integrating against
contentful.swiftwas forced to write this mapping themselves and had no way to know if they got it wrong.What this PR does
Entry -> OptimizedEntryconversion path:OptimizationEntryMapping.toOptimizationEntry(_:), a newOptimizedEntry(entry: Contentful.Entry, ...)initializer, andResolvedEntryfor reading resolved output — so integrators stop hand-writing the mapping.metadata: {tags, concepts}, even for an entry with zero tags, so the resolver's entry guard can no longer silently fall back to baseline with no signal.description,contentType, and filedetails(size/image dimensions) instead of onlytitle/file.url, and adds a case forAsset.FileMetadatadecoded directly as a field value — both previously silently dropped.ResolvedEntrymirrorsContentful.Entry's own readable surface (id,localeCode,createdAt,updatedAt,getField,String/Intsubscripts) instead of exposing onlyid/getField, so a resolved variant reads like a fetchedEntry.type,currentlySelectedLocale,metadata, andsetLocaleare deliberately not mirrored — documented on the type, sincecontentful.swiftgives no way to reconstruct them from a resolved map.personalization-website-agent-benchmarks's reference adapter (examples/apps/travel-guide-ios/Sources/OptimizationAdapter.swift): link/rich-text expansion, ancestor-cycle guarding, diamonds, asset edge cases (no file, no description, non-image), a raw field shaped likeAsset.FileMetadata, and the newResolvedEntryaccessors.documentation/internal/sdk-knowledge/native/ios.md).Mapping completeness was independently verified against the actual
contentful.swift5.5.15 source (metadata, rich text node types, field value types, link cases) and against the reference adapter — no remaining gaps beyond what's covered here.Not addressed by this PR (from NT-3808, tracked as remaining work)
JSONSerializationfailure inresolveOptimizedEntry(still logs generically rather than naming the offending field).Entry-based initializer.Test plan
swift test— 166/166 passingswift build— cleanpnpm knowledge:check— passesvariantIndex(manual verification, not yet performed)🤖 Generated with Claude Code