feat: add MediaUploader, for a host that owns the whole upload - #628
Open
jkmassel wants to merge 2 commits into
Open
feat: add MediaUploader, for a host that owns the whole upload#628jkmassel wants to merge 2 commits into
jkmassel wants to merge 2 commits into
Conversation
This was referenced Sep 5, 2026
XCFramework BuildThis PR's XCFramework is available for testing. Add the following to your .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/628")Built from 549b518 |
jkmassel
force-pushed
the
feat/media-uploader-protocol
branch
from
September 8, 2026 16:12
0ab9ee5 to
fadbd45
Compare
jkmassel
force-pushed
the
feat/media-uploader-protocol
branch
from
September 9, 2026 00:18
fadbd45 to
589f32f
Compare
jkmassel
force-pushed
the
feat/media-uploader-protocol
branch
from
September 9, 2026 00:34
589f32f to
039cbed
Compare
Performing a media upload — and retrying it — should be a single, all-or-nothing responsibility: either GutenbergKit performs the upload and owns its retries, or the host does. Both go to the same configured site; the only difference is who executes the requests. `MediaUploadDelegate.uploadFile` doesn't offer that. A host performs the `POST /wp/v2/media` and returns the raw response it received — then the editor, reading that response, drives the `post-process` retries and the orphan cleanup behind it, through the WebView rather than the host's stack. A host that took over uploads to run them through its own networking still didn't own the retries. It also receives no form fields, so an attachment it uploads lands unattached to its post. Add `MediaUploader`, which owns the upload end to end: - `upload(_:)` returns the finished attachment or throws. There is no raw response left for the editor to retry behind it, so the host drives its own post-process recovery and force-deletes its own orphan on terminal failure. - It receives a `MediaUpload` carrying the file, its metadata, the editor's non-file form fields (`post`, additionalData) and the request query (`?_embed`) — everything needed to reproduce a native request. - Fields are a `MediaUploadField` list rather than a dictionary, so repeated names (a `field[]` array) survive verbatim and in order. Additive for hosts: `uploadFile` still works and is marked deprecated, pointing them at the replacement, and an uploader takes precedence when both are set. Internally the upload server's startup gate widens to admit an uploader as well as a delegate. GutenbergKit's own build keeps one deprecation warning at the call site that supports the old hook — the marker exists to tell hosts to migrate, and supporting the hook until it is removed means calling it. With an uploader set, the delegate's metadata gate can no longer decline a file: the gate exists to skip a temp copy for a file the delegate won't touch, but an uploader takes over delivery for *every* file, so passing through would silently bypass it. Covered on both platforms. `MediaUploadServerTest` crosses Detekt's LargeClass threshold; baselined rather than split, which is its own change.
…ound it
Follow-ups to the MediaUploader commit: a behavior bug in the metadata gate, a
cross-platform divergence, a missing cancellation check on Android, four
documentation defects, two test gaps, and a shadowed local.
## Correctness
- `processFile` ran on a file the delegate's metadata gate had declined. Widening
the gate to `uploader != nil || delegateWantsFile` left `processFile` called
unconditionally, so an image-only delegate paired with an uploader was handed
the `.mov` it had just said it won't touch — breaking the contract
`handlesFile` documents. `delegateWantsFile` is now carried into
`processAndUpload` and gates `processFile`. With an uploader set the file is
still delivered; it just skips processing on the way.
- iOS evaluated `handlesFile` eagerly while Android's `&&` short-circuited past
it, so the same host saw one callback per upload on iOS and zero on Android.
Android now binds it eagerly too: asked exactly once per upload on both.
- Android had no pre-flight cancellation check before handing work to the host
uploader, where iOS has `Task.checkCancellation()`. Added
`currentCoroutineContext().ensureActive()`, so a torn-down editor no longer
starts an upload whose attachment nobody would clean up.
## Documentation
- The recovery recipe omitted `post-process`'s required `action` parameter. Core
registers `action` as required, so a host following the doc verbatim would 400
five times and then run the doc's *other* instruction —
`DELETE /wp/v2/media/<id>?force=true` — destroying an attachment
`wp_update_image_subsizes()` would have recovered.
- `mediaUploader`'s doc had been appended to `mediaUploadDelegate`'s `///` block,
merging the two: `mediaUploadDelegate` shipped with no documentation and
`mediaUploader` opened by describing a delegate. Confirmed with
`swiftc -emit-symbol-graph` (`mediaUploadDelegate => None`); both now bind
their own 11 lines.
- `formFields` and `deprecatedUploadFile` were inserted between
`attachmentId(fromPath:)`'s doc and its declaration — merging into it on iOS,
dropping it outright on Android — costing the "deliberately narrow, not a
general REST proxy" rationale. Moved below their only caller, per AGENTS.md's
call-order rule.
- `ReplaceWith("MediaUploader")` takes a replacement *expression*; applying the
quick-fix drops all three arguments and leaves a type name where a
`MediaUploadResponse?` was expected. Removed, with a note so it doesn't return.
## Tests
- Nothing pinned the Android gate or the uploader/deprecated-hook precedence:
deleting `&& mediaUploader == null` or reordering the two delivery paths left
the suite green. Three tests added; both mutations now fail.
- `DecliningDelegate` duplicated the pre-existing `DeclineByMetadataDelegate`
minus its `processFileCalled` recorder — the one probe that catches the
`processFile` bug above. Merged, and the declined-file test now asserts it.
## Hygiene
- Two locals named `uploader` shadowed the new `MediaUploader` property, silently
(kotlinc has no diagnostic for it, detekt no rule). Renamed to `client`.
- `RecordingUploader`'s fixture carried no `title`, so the repo's only worked
example of an uploader result was a body that trips `transformAttachment`.
jkmassel
force-pushed
the
feat/media-uploader-protocol
branch
from
September 9, 2026 00:48
039cbed to
549b518
Compare
jkmassel
marked this pull request as ready for review
September 9, 2026 14:30
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.
Stacked on #627. Fifth of ten PRs splitting #621, and the heart of it.
A host that doesn't adopt
MediaUploadersees no behavior change.MediaUploadDelegate.uploadFilestill works and still returns what it returned; it gains a deprecation warning pointing at the replacement, and #629 removes it. What changes shape is internal: the upload server's startup gate now admits an uploader as well as a delegate, and the pipeline threads the delegate's metadata answer through to processing.What?
A
MediaUploaderprotocol that takes over performing a media upload on the host's own stack, and owns its whole lifecycle: retries, recovery, and cleanup.Why?
Performing a media upload — and retrying it — should be a single, all-or-nothing responsibility: either GutenbergKit performs the upload and owns its retries, or the host does. Both go to the same configured site; the only difference is who executes the requests.
MediaUploadDelegate.uploadFiledoesn't offer that. A host performs thePOST /wp/v2/mediaand returns the raw response it received — then the editor, reading that response, drives thepost-processretries and the orphan cleanup behind it, through the WebView rather than the host's stack. A host that took over uploads to run them through its own networking still didn't own the retries. It also receives no form fields, so an attachment it uploads lands unattached to its post.Neither is fixable while the hook returns a raw response, which is what the replacement changes.
How?
MediaUploaderupload(_:)returns the finished attachment or throws. There is no raw response left for the editor to retry behind it, so the host drives its ownpost-processrecovery and force-deletes its own orphan on terminal failure.MediaUploadCarries the file, its metadata, the editor's non-file form fields (
post, additionalData) and the request query (?_embed) — everything needed to reproduce a native request.MediaUploadFieldFields are an ordered list rather than a dictionary, so repeated names (a
field[]array) survive verbatim and in order. A named type rather than a tuple: tuples are not nominal, so a tuple-typed property would permanently blockEquatable/Hashable/Codablesynthesis onMediaUpload, and that is not fixable later without a source break for every host.Precedence
uploadFilestill works and is marked deprecated, pointing hosts at the replacement; an uploader takes precedence when both are set. #629 removes the old hook, so hosts get a migration window rather than a flag day.This deliberately leaves one deprecation warning in GutenbergKit's own build, at the call site that supports the old hook (
MediaUploadServer.swift:397). The marker exists to tell hosts to migrate, and supporting the hook until it is removed means calling it. It goes away with #629.What the metadata gate decides
handlesFileis the delegate's cheap, type-only veto, consulted before the upload is copied to a temp file. With an uploader set it no longer decides whether the upload happens — an uploader takes over delivery for every file, so there is no passthrough left to decline to.It still decides whether
processFileruns. A file the delegate declined by type is delivered to the uploader unprocessed, rather than handed to a delegate that just said it won't touch a file like that — an image-only delegate never sees a.mov.handlesFileis consulted exactly once per upload, on both platforms.Cancellation before delivery
Android now checks for cancellation immediately before handing the file to the uploader, matching the iOS check #626 added. A host uploader that isn't cancellation-cooperative — a background service, a work queue — would otherwise finish a
POSTfor an editor that is already gone, leaving an attachment that, by this protocol's own contract, nobody cleans up.Recovery is the host's, and it needs one parameter that isn't obvious
When
POST /wp/v2/mediafatals in server-side post-processing it returns a 5xx carrying the attachment ID inx-wp-upload-attachment-id— the attachment exists but is unfinished. GutenbergKit is out of the network for an uploader host, so the editor-side middleware never sees that 5xx and cannot recover it.The protocol doc spells out the recipe, including the part that bites:
POST /wp/v2/media/<id>/post-processneeds a body of{"action": "create-image-subsizes"}. Core registersactionas required, so a recovery loop that omits it returns 400 on every attempt and then force-deletes an attachmentwp_update_image_subsizes()would have finished.Testing Instructions
Five tests on iOS and six on Android. Both platforms cover delivery, form fields and query, precedence over
uploadFile, the declined-file case, and a terminal throw surfacing without GutenbergKit re-delivering. Android adds two for the startup gate — thatmediaUploaderalone brings the server up, and that assigning it after load traps — which iOS cannot cover from a test target, becauseEditorViewControlleris behind#if canImport(UIKit).swift-ios-simulator-tests,android-test-android-library, and both E2E suitesswift test— 974 tests, 0 failures:Gutenberg:testDebugUnitTest— 672 tests, 0 failuresxcodebuild— compiles the UIKit-gatedEditorViewController, which the host test target does not:Gutenberg:compileDebugUnitTestKotlinemits no warningsTo exercise the recovery path against a real fatal,
make wp-env-media-failure MODE=alwaysforceswp_generate_attachment_metadata()to fail — seedocs/code/local-wordpress.md. Note that aMediaUploaderhost is responsible for the retry loop itself, so this exercises the host's implementation, not GutenbergKit's.MediaUploadServerTestcrosses Detekt'sLargeClassthreshold; baselined rather than split, which is its own change.