Skip to content

fix(swift): add contentful.swift Entry adapter for OptimizedEntry [NT-3808] - #393

Merged
David Nalchevanidze (nalchevanidze) merged 22 commits into
mainfrom
fix/nt-3808-ios-optimized-entry-contentful-mapping
Aug 3, 2026
Merged

fix(swift): add contentful.swift Entry adapter for OptimizedEntry [NT-3808]#393
David Nalchevanidze (nalchevanidze) merged 22 commits into
mainfrom
fix/nt-3808-ios-optimized-entry-contentful-mapping

Conversation

@nalchevanidze

@nalchevanidze David Nalchevanidze (nalchevanidze) commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem (NT-3808)

contentful.swift's typed Entry cannot be fed into OptimizedEntry without 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 from Entry. Concretely:

  • The resolver's guard requires a metadata object, but Entry keeps metadata off fields. 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.
  • A single non-JSON-representable field value fails serialization for the entire entry, with only a generic "failed to parse" log.
  • With no SDK-provided mapping, every integrator hand-writes one against an untyped surface that gives them nothing to check at compile time.

Every customer integrating against contentful.swift was forced to write this mapping themselves and had no way to know if they got it wrong.

What this PR does

  • Ships an Entry -> OptimizedEntry conversion path: OptimizationEntryMapping.toOptimizationEntry(_:), a new OptimizedEntry(entry: Contentful.Entry, ...) initializer, and ResolvedEntry for reading resolved output — so integrators stop hand-writing the mapping.
  • Always emits 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.
  • Extends asset mapping 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 — both previously silently dropped.
  • ResolvedEntry mirrors Contentful.Entry's own readable surface (id, localeCode, createdAt, updatedAt, getField, String/Int subscripts) instead of exposing only id/getField, so a resolved variant reads like a fetched Entry. type, currentlySelectedLocale, metadata, and setLocale are deliberately not mirrored — documented on the type, since contentful.swift gives no way to reconstruct them from a resolved map.
  • Adds test coverage mirroring 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 like Asset.FileMetadata, and the new ResolvedEntry accessors.
  • Records the new types and the mirroring boundary in the iOS SDK knowledge base (documentation/internal/sdk-knowledge/native/ios.md).

Mapping completeness was independently verified against the actual contentful.swift 5.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)

  • Per-field error surfacing for the JSONSerialization failure in resolveOptimizedEntry (still logs generically rather than naming the offending field).
  • SwiftUI/UIKit integration guide updates documenting the metadata requirement and the new Entry-based initializer.

Test plan

  • swift test — 166/166 passing
  • swift build — clean
  • pnpm knowledge:check — passes
  • Real simulator run resolving to a non-zero variantIndex (manual verification, not yet performed)

🤖 Generated with Claude Code

…-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>
@nalchevanidze
David Nalchevanidze (nalchevanidze) force-pushed the fix/nt-3808-ios-optimized-entry-contentful-mapping branch from 856f81a to bb10403 Compare July 31, 2026 09:37
…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>
@nalchevanidze
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).
@nalchevanidze
David Nalchevanidze (nalchevanidze) merged commit a2e9f86 into main Aug 3, 2026
39 checks passed
@nalchevanidze
David Nalchevanidze (nalchevanidze) deleted the fix/nt-3808-ios-optimized-entry-contentful-mapping branch August 3, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants