Let mods expose APIs for other mods
Pathoschild opened this issue ยท 11 comments
Mod integrations are still pretty awkward. Let mods expose APIs for other mods to use.
I feel those Cons are kinda ... things we need to expect from an API interface like that. We should stress if we do this in documentation that you should not change exposed APIs.
That's true. We could maybe handle breaking changes more gracefully by specifying the expected API version:
var api = helper.ModRegistry.GetApi("Speeder.BetterSprinklers", "2.3.1");
If the player has Better Sprinklers 3.0 with breaking API changes, that could return null
with a warning like this in the console:
Data Maps tried to access Better Sprinklers API 2.3.1, which is no longer available. It must specify version 3.0 or later. Make sure you have the latest version of Data Maps.
Proposal A
Example
A mod implements IModAPI
(optionally with a constructor that accepts an IModHelper
):
/// <summary>An API exposed to other mods through the SMAPI mod registry.</summary>
public class BetterSprinklersApi : IModApi
{
/// <summary>Construct an instance.</summary>
/// <param name="helper">The mod helper.</param>
public BetterSprinklersApi(IModHelper helper) { ... }
/// <summary>Get a lookup of sprinkler coverage by each sprinkler's <see cref="StardewValley.Object.parentSheetIndex"/>, represented as an array of tile coordinates relative to the sprinkler tile.</summary>
public IDictionary<int, Vector2[]> GetSprinklerRadius(IModHelper helper) { ... }
}
Then mods with a hard dependency can call it like this:
BetterSprinklersApi api = helper.ModRegistry.GetApi<BetterSprinklersApi>("Speeder.BetterSprinklers");
if (api != null)
var customSprinklers = api.GetSprinklerRadius();
And mods without a hard dependency can call it like this:
object api = helper.ModRegistry.GetApi("Speeder.BetterSprinklers");
if (api != null)
var customSprinklers = helper.Reflection.GetMethod(api, "GetSprinklerRadius").Invoke<IDictionary<int, Vector2[]>>();
Pros & cons
- Pros:
- Simplifies mod integrations.
- Supports sending commands.
- Easy to use with and without a hard dependency.
- Easy to implement.
- Cons:
- Potential for versioning issues (if a mod makes a breaking change to its API, mods that use it will show cryptic low-level exceptions).
- Not discoverable (mods need to implement a separate interface, not available through the mod helper).
There's no way to determine in advance which future version of a mod might have breaking API changes, a more graceful means of handling it would need to be able to offer any of the SemanticVersion comparison methods. A more elegant form of this, for example:
if (helper.ModRegistry.IsLoaded("Speeder.BetterSprinklers") && helper.ModRegistry.Get("Speeder.BetterSprinklers").Version.IsBetween("2.3.1", "3.0.0"))
{
object api = helper.ModRegistry.GetApi("Speeder.BetterSprinklers");
}
or
if (helper.ModRegistry.IsLoaded("Speeder.BetterSprinklers") && helper.ModRegistry.Get("Speeder.BetterSprinklers").Version.Equals("2.3.1"))
{
object api = helper.ModRegistry.GetApi("Speeder.BetterSprinklers");
}
EF has a (experimental) implementation that makes soft-dependencies incredibly easy:
https://github.com/Entoarox/StardewMods/blob/master/Framework/Experimental/WrapperBuilder.cs
This design is not only easy, but outside of first construction, there is no reflection overhead.
@Pathoschild I think I misunderstood you the first time, but I like this approach. Those who expose an API will be forced to implement the interface member, and, hopefully, will update it appropriately and according to convention.
@AdvizeGH You can check the mod version, but that depends on knowing in advance when the API will change. For example, if my mod uses Better Sprinklers API 2.3.1, how can I know which future versions will work too?
My suggestion is that you specify which mod version you coded against instead. When you expose an API, you tell SMAPI when a breaking change occurred:
/// <summary>An API exposed to other mods through the SMAPI mod registry.</summary>
public interface IModAPI
{
/// <summary>The last mod version which broke this API (i.e. removed or renamed something, or changed a method signature). When a mod uses an older version of the API, it'll get a graceful update-needed error.</summary>
ISemanticVersion LastBreakingChange { get; }
}
When you request an API, SMAPI will make sure that lastBreakingChange == null || requestedVersion >= lastBreakingChange
; if not, a graceful update-needed error will be shown instead.
What do you think of that approach?
Thanks @Entoarox! That's a really interesting approach. I'd probably have the consumer specify the mod ID instead, and SMAPI would find the mod's API automatically:
var api = helper.ModRegistry.GetApi<IBetterSprinklers>("Speeder.BetterSprinklers");
I'll implement a prototype and see how well it works crossplatform in practice.
I have a prototype of the low-level code in develop
(not the interface wrapping yet), but I'm not satisfied with the approach.
Mods can implement IModProvidedAPI
, and SMAPI will use reflection to construct an instance (using the best constructor with 0+ arguments of type IModHelper
or IMonitor
):
public class SomeModApi : IModProvidedApi
{
public SomeModApi(IModHelper modHelper, IMonitor monitor) { /* ... */ }
public void DoThing() { /* ... */ }
}
This works but it's complex, not discoverable, and error-prone (e.g. invalid constructor or multiple implementations).
I'm leaning towards mods optionally overriding a method in their Entry
class instead:
public override object GetApi()
{
return new ModApi(this.Helper, this.Monitor);
}
This is much simpler (e.g. SMAPI doesn't need any extra reflection), discoverable, easier to understand, and eliminates many error cases (e.g. you can't have an invalid constructor or multiple implementations).
I'll implement the second approach if there are no objections.
I have a prototype in feature/mod-provided-apis
which works fine on Linux and Windows. (Working on Mac compatibility.) I may change how it's implemented under the hood, but I'm pretty settled on the usage. Let me know if you disagree with any of the following.
Prototype
Providing an API
You can create an arbitrary class and return it from your entry class:
public class SomeMod : Mod
{
public override void Entry() { }
public override object GetApi() => new SomeModApi(this.Helper, this.Monitor);
}
public class SomeModApi
{
public SomeModApi(IModHelper modHelper, IMonitor monitor) { /*...*/ }
public void DoThing(/*...*/) { }
}
GetApi
is always called after Entry
, so it can use the mod's initialised fields.
You can optionally create an interface for the API, which lets mods with a direct assembly reference use it without defining their own.
Consuming an API
You can consume a mod-provided API by mapping it to an interface with the properties & methods you want to access. If you have a direct assembly reference, you can use an interface provided by the API's mod (if provided).
public interface ISomeModApi
{
void DoThing();
}
// somewhere in the code
ISomeModApi api = helper.ModRegistry.GetApi<ISomeModApi>("some-mod-id");
if (api != null)
api.DoThing();
For a quick integration, you can also use reflection instead of an interface:
object api = helper.ModRegistry.GetApi("some-mod-id");
if (api != null)
helper.Reflection.GetMethod(api, "DoThing").Invoke();
A few caveats:
- You must consume an API through a public interface. You can't map it onto a class or access the API class directly (e.g.
GetApi<SomeModApi>
). - You can't call
GetApi
until all mods are initialised and theirEntry
methods called. To simplify mod integrations, SMAPI 2.3 re-introducesGameEvents.FirstUpdateTick
which is guaranteed to happen after all mods are initialised. - You should always null-check APIs you consume.
GetApi
will returnnull
if the other mod isn't installed or doesn't provide an API, or if an error occurs fetching the API. If an error occurs (except for mod-not-installed), SMAPI will log a relevant error like this:ModX tried to access a mod-provided API before all mods were initialised.
Known issues
(To be fixed before release.)
- Not compatible with Mac yet.
- If an interface property or method doesn't match the real API, an error will be raised when you call it instead of when you map it.
Done in develop
for the upcoming SMAPI 2.3 release, and documented on the wiki. The final version is fully crossplatform, no longer requires the interface be public, and validates the API mapping immediately.