Skip to content

FXIOS-15132 - Merino Rust client cleanup#7274

Open
adudenamedruby wants to merge 8 commits intomozilla:mainfrom
adudenamedruby:rgb/topsy-turvy-sparkly-tomato
Open

FXIOS-15132 - Merino Rust client cleanup#7274
adudenamedruby wants to merge 8 commits intomozilla:mainfrom
adudenamedruby:rgb/topsy-turvy-sparkly-tomato

Conversation

@adudenamedruby
Copy link

@adudenamedruby adudenamedruby commented Mar 18, 2026

As part of the work to update the Merino Rust client to fetch categories, this first PR does some prep work to make the updates easier, and to improve maintainability for the client.

The commits for this PR are fairly self explanatory for review, but, in general, the PR:

  • adds some documentation
  • improves error handling
  • improves some duplicated code re: locales
  • adds some tests

I've successfully tested the changes in the PR:

  • against a local AS build in Xcode in firefox iOS
  • with the merino-cli

I don't think this PR needs a CHANGELOG, because it's simply a refactor, not really adding/removing feature or capabilities to the client.

Please note: I am a Rust noob, so please let me know what improvements I can make.

Pull Request checklist

  • Breaking changes: This PR follows our breaking change policy
    • This PR follows the breaking change policy:
      • This PR has no breaking API changes, or
      • There are corresponding PRs for our consumer applications that resolve the breaking changes and have been approved
  • Quality: This PR builds and tests run cleanly
    • Note:
      • For changes that need extra cross-platform testing, consider adding [ci full] to the PR title.
      • If this pull request includes a breaking change, consider cutting a new release after merging.
  • Tests: This PR includes thorough tests or an explanation of why it does not
  • Changelog: This PR includes a changelog entry in CHANGELOG.md or an explanation of why it does not need one
    • Any breaking changes to Swift or Kotlin binding APIs are noted explicitly
  • Dependencies: This PR follows our dependency management guidelines
    • Any new dependencies are accompanied by a summary of the due diligence applied in selecting them.

@misaniwere misaniwere requested a review from a team March 18, 2026 20:16
@adudenamedruby adudenamedruby force-pushed the rgb/topsy-turvy-sparkly-tomato branch from a307ee0 to 5138f4f Compare March 18, 2026 20:21
reason: self.to_string(),
})
.report_error("merino-unexpected"),
Self::UrlParse(_) | Self::Json(_) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Previously we were using _ as a universal trap for any unhandled error state. While this change now explicitly handles the two, previously unhandled states, it essentially doesn't raise a problem, but it does complicate future code if we ever introduce a new local Error state.

I'm a bit inclined to return this to the prior universal wrapper.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the counter argument is that adding a new local Error variant should force this to be looked at to determine if the default handler really is appropriate for the new variant.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess as a Swift developer, I often this of Rust's match as an improved kind of switch, and the use of switch usually (though, not always) means, "handle all cases" so that the compiler yells at me when I add new cases. I'm happy to go back to _ but that's why I had made that change. Happy to undo it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really bothered by either approach. Mark raises a good point about forcing inspection. I guess I'm used to an environment where only actionable errors are reported (due to cost reasons) so I've gotten into the habit of having default conditions. If y'all are fine with not having a default, I'm not going to argue.

Copy link
Member

@mhammond mhammond left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit of a drive-by that initially mis-understood the scope and that this was just a refactor - it's probably worth what it cost you, but I'm hitting "submit" anyway :)

#[derive(Debug, Deserialize, PartialEq, uniffi::Record, Serialize)]
pub struct Feeds {
/// High-priority "need to know" recommendations.
#[uniffi(default = None)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi, I think you should be able to omit the = None part on these defaults. Again, this is just a refactor, and when this was written you could not omit = None part, but again, leaving it in-case you ever add new ones :)

///
/// Each field corresponds to a content category or special feed type. Fields are
/// `None` when the category was not requested or has no content available.
#[derive(Debug, Deserialize, PartialEq, uniffi::Record, Serialize)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eek: I just noticed that you did not actually introduce this, just refactored it. So feel free to ignore this entirely, but I'll leave it here as I think it still makes some points worth considering for your future endeavours :)

It's worth being careful deriving both uniffi::Record and serde traits on the same object. The issue is that you are making it such that a change in the "wire" protocol must end up being a breaking change for the mobile clients - this is probably worse for Serialize, and I suspect you don't actually need serialize here - I guess you only deserialize them? But a non-optional field addition (or even deletion!) ends up being seen by the ultimate consumer.
It also means decisions made a long way from the mobile clients ends up deciding the api exposed to these mobile clients - what makes perfect sense for the server code crafting the JSON responses might seem really odd represented as Swift.

Or to make the same point even stronger - what value are these structs providing to the mobile clients? Why not just send them the raw JSON and let them deserialize it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is beyond me, honestly, as I don't much know the workings of Uniffi, and just making most of this refactor was really stretching my Rust knowledge lolololo. I think I'll leave this for now, it seems you're ok with that.

As for the bigger point you're making: "what value are these structs providing to the mobile clients? Why not just send them the raw JSON and let them deserialize it?" Ultimately, if we're passing raw JSON, then what would be the purpose of this client, because it's basically just a network call with extra steps. The original reasoning for implementing it was that, ideally, it acts as a singular interface to Merino that provides objects for the clients to use so that each client isn't manually maintaining its own Merino client - that's three times the work to implement any changes and maintenance vs the one. What the clients do with the objects/data afterwards, that makes sense to be on the clients, because they each handle that info differently.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately, if we're passing raw JSON, then what would be the purpose of this client, because it's basically just a network call with extra steps.

Exactly 😅

that's three times the work to implement any changes and maintenance vs the one

It's not immediately clear we have achieved that in practice - with this model, some changes will still require touching all 3 consumers of this crate - eg, adding a new field to a struct is technically a breaking change, but adding a new field to a json payload need not be.

If a Rust component is literally a wrapper around JSON and trivial network calls, and has no "difficult" logic or anything else (which is where the real burden of multiple impls come from), then yeah, it really shouldn't be in Rust imo. This single shared impl has a real cognitive burden - the author needs to understand uniffi, have at least a passing knowledge of all binding languages expected to be used (eg, kotlin and swift), and know Rust. A trivial JSON/network component written in, say, just swift is far more approachable to far more people.

(But as above, I know you didn't create this, so it's a meta comment rather than anything about this patch)

pub mod response;

// Re-export all model types for use by UniFFI bindings and downstream consumers.
#[allow(unused_imports)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you re-export these if they are unused? models isn't public, so the only possible use for these re-exports is in curated_recommendations, and if this clippy annotation is accurate, they aren't used - so why not just remove the unused re-exports instead of overriding clippy here? (It looks like maybe not all are actually unused though). And again, not clear you actually introduced this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, my understanding of Uniffi is basically zero, but from the snippets of conversation I've had about Uniffi, I gather that Uniffi scaffolding needs types to be publicly accessible, and a flat module hierarchy makes the generated bindings cleaner. So the re-exports exist so that these types are available at the models:: path level, ie. models::Feeds instead of models::feeds::Feeds.

the #[allow(unused_imports)] annotations are there because within the Rust crate itself, nothing currently imports these types through the re-exported path. But I thought the re-exports are still needed for UniFFI's code generation to discover and expose them in the generated bindings - although, arguably, I did NOT test if that is the case or not. I just went off my understanding, and when tests passed and it worked in FXiOS without other changes, I just called it a day lololol

lemme know if that's not good, and I can clean it up

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In short, uniffi just generates Rust code (usually via macros, sometimes an actual .rs) - if that code needed to reference a type, then it's just (hidden to you) Rust code that does, so the clippy warning would not fire. IOW, clippy is already taking uniffi into account when generating its warnings.

reason: self.to_string(),
})
.report_error("merino-unexpected"),
Self::UrlParse(_) | Self::Json(_) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the counter argument is that adding a new local Error variant should force this to be looked at to determine if the default handler really is appropriate for the new variant.

@adudenamedruby adudenamedruby force-pushed the rgb/topsy-turvy-sparkly-tomato branch from 42bf612 to 6c691a3 Compare March 19, 2026 11:50
- **A/B experiment support** — Pass experiment name and branch parameters to support server-side experimentation.
- **Cross-platform** — Rust core with UniFFI-generated bindings for Android (and other platforms).

## Architecture
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather not have this Architecture session here. Once we change or add files, we always have to update the README. Which, in the end, can get rather large.

@@ -0,0 +1,39 @@
# Merino

A cross-platform Rust client library for Mozilla's [Merino](https://merino.services.mozilla.com) curated recommendations service. This powers the curated content recommendations (articles/stories) shown on Firefox's New Tab page.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not get too specific, or we have to add and change the README every time we do a slight addition or change.


The library provides a `CuratedRecommendationsClient` that fetches curated recommendations from the Merino backend via its REST API (`/api/v1/curated-recommendations`). It uses [UniFFI](https://mozilla.github.io/uniffi-rs/) to generate cross-platform bindings for Android (and other targets).

## Features
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this was generated with Claude, but can we make sure to actually include things in the README which are helpful for maintainers/users? :) I don't think we need to mention Cross-platfom etc.

Might be nit picky, but if I read stuff which makes me think this was auto-generated, I don't bother reading much of it at all.

jrconlin
jrconlin previously approved these changes Mar 19, 2026
Co-authored-by: JR Conlin <src+github@jrconlin.com>
@mergify mergify bot dismissed jrconlin’s stale review March 19, 2026 15:58

The pull request has been modified, dismissing previous reviews.

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.

4 participants