feat(materialize): :core:materialize — POST /api/materialize plumbing + domain model (#11) - #100
Merged
Merged
Conversation
…ialize
"Create from…" is invoked from :feature:messages, :feature:lists and
:feature:documents, and no feature module in this repo depends on another,
so the shared foundation lands in a new :core:materialize module. It owns
its DTOs and its own Retrofit API built from the shared authed Retrofit,
like every other module here; it has no Room cache, because the endpoint
is a one-shot write whose result is authoritative.
Modelled from the published API reference (/help/api/create-from) rather
than the OpenAPI spec, which types `source` as a bare string:
- MaterializeRequest is one sealed type over the whole matrix — four
destinations x five source kinds. Each destination carries only the
config the API accepts for it, so a doc title cannot ride along on a
list-only conversion. A list title is mandatory in the type because the
server refuses without one ("A list title is required").
- MaterializeSource is id-only by construction: the server re-fetches and
re-authorizes every id and rebuilds from its own data, so there is
nowhere for client cell values to be smuggled through.
- To Message is a server call that returns a DRAFT and creates nothing;
MaterializeTarget.createsContent records the difference so it is not
discovered at runtime.
- A user-added column sends `sourceKey: null` explicitly (the shared Json
runs with explicitNulls = false, which would otherwise drop the key and
lose its meaning).
Errors map onto the shared AppError so the feature modules keep their
existing toUserMessage()/isSubscriptionGate handling. Unlike safeApiCall,
a 403 here is the subscriber gate whatever the wording, except for
`account_*` codes, which no subscription would lift.
MaterializeGate enforces the subscriber check before anything is sent: a
free account confirming a creating target fails with SubscriptionRequired
and issues no request at all. It fails open — an unreadable customerStatus
leaves the server as the real gate rather than locking out a subscriber
whose /api/user call happened to fail.
No entry points and no UI: those are the sibling issues. :app depends on
the module only so its Hilt modules join the component.
Tests: 44 new (MockWebServer round-trip per destination asserting the
exact body, all five source kinds, every column type, explicit-null
sourceKey, free-account confirm with zero requests, error-code mapping).
Whole repo green: :app:assembleDebug plus 816 unit tests, 0 failures.
Closes #11
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.
Closes #11. Part of epic #10. Foundation for #12, #13, #14, #15, #16.
The contract came from documentation, not guesses
https://interlinedlist.com/help/api/create-from— a human-written API reference the originalparity review did not have — gives the real shape, and the OpenAPI
MaterializeResultschema isricher than the bare
source: stringthe epic warned about. Also pulled/help/create-from(behaviour) and
/help/api/lists-dsl(the twelve column types, corroborated by the server's own400 message).
{ "target": "list | doc | both | message", "source": { "kind": "messages", "messageIds": ["…"] }, "listConfig": {…}, "docConfig": {…}, "messageConfig": {…} }Two corrections to the issue text, both load-bearing:
source.kind, notsource.type.documentanddocElementsareseparate.
Module home:
:core:materializeRegistered in
settings.gradle.kts, built like the other:core:*Android library modules, noCompose and no Room. It is called from three feature modules, and no feature module in this repo
depends on another, so a shared module is the only correct home. I found no reason to prefer a
:feature:module.Design
docConfigon a list-only conversion will not compile.MaterializeTarget.createsContentisfalse only for
MESSAGE, becausetarget: "message"returns a draft and writes nothing —which is exactly what Create from…: "To Message" prefills the composer instead of creating #16 is about.
authorizes every id and that "client-supplied cell values or body text are not trusted", so the
type cannot carry content. Empty selections are rejected in
init. There is a test on arows-source body proving no cell values travel.
ListConfig.titleis non-nullable — the server answersA list title is required(confirmedlive).
sourceKey: nullis sent explicitly. The shared Json runsexplicitNulls = false, which wouldsilently drop a null
sourceKey— but an omitted key does not mean "user-added empty column".The DTO holds a
JsonElementand writesJsonNull, with a test asserting the key is present andnull.
unreadable
/api/userleaves the server as the real gate rather than locking out a subscriberwhose status read happened to fail.
On not using
safeApiCallError mapping reuses the shared
AppError(so #12–#16 gettoUserMessage()/isSubscriptionGatefor free) but goes through its own mapper rather than
safeApiCall, which dropscodeand decidesthe upsell by looking for the word "subscription" in the message. Here a 403 is the subscriber
gate whatever the wording — except
account_*codes, which no subscription would lift and which mapto
Forbidden.bad_request/validation_failedmap toUnknown(message)so the server's own words("A list title is required") reach the user. This is the same underlying problem as #94.
Two things to confirm with the owner
target: "message"subscriber-gated? The two docs contradict each other — the APIreference says "Subscriber only: yes" for the whole endpoint; the help centre says "To Message
posts through the normal composer… posting is free". Resolved defensively: the client does not
pre-block the message target (it creates nothing, and blocking it would contradict a
documented-free behaviour), but a server 403 still maps to
SubscriptionRequired, so a free userreaches the upsell either way and no write occurs. Worst case a free user spends one wasted
403 instead of getting an instant upsell. Not verifiable from here — the only live account
available is a subscriber.
messageConfig.crossPostTargets. The reference gives display labels("Bluesky, Mastodon, LinkedIn, X/Twitter") without pinning the strings. Used the lowercase values
the API documents for
crossPosts[].platform. Worst case this mis-sizes a draft'scharLimit;nothing is created.
Also noted:
messages → messageis accepted by the API but hidden in the web UI. Leftrepresentable — the type should encode what the API forbids, not what a UI chooses not to offer.
Verification
./gradlew :app:assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL, 816 tests repo-wide, 0failures (44 new). MockWebServer round-trip per destination asserting the exact body; all five
source kinds asserting only that kind's keys appear; every column type; the explicit-null
sourceKey; free-account confirm →SubscriptionRequiredwithserver.requestCount == 0; gateresolution from
/api/user; a 403 folded back into the gate so a second confirm issues nothing;full error-code mapping; and a 201 that omitted what the target promised treated as a failure rather
than an empty success.
Scope: only
settings.gradle.kts, one dependency line inapp/build.gradle.kts, and the new module.Note for #15
#15's preview/confirm window will need the Compose plugin added tocore/materialize/build.gradle.ktsif that window lands here rather than in each surface.MaterializeOutcomeis deliberately a plain sealed type rather than generic over the request,because that window switches destination dynamically and a generic would force star-projections.