bifrosted 0.12.1
bifrosted: ^0.12.1 copied to clipboard
The rainbow bridge connecting your app to APIs. A lightweight REST API client and repository pattern with caching, offline support, and error handling.
Changelog #
0.12.1 #
- Fixed the serialized cache-mutation queue retaining its completed
Futureacross Flutter's per-testFakeAsynczones. A first widget test could cache successfully, while the next received its response and then waited forever on a completion owned by the previous test's inactive zone. The queue now clears its tail when the last mutation completes while preserving ordering for concurrent mutations.
0.12.0 #
-
Breaking: replaced the shipped
BifrostTestEnvservice graph withBifrostTestRuntime. Test setup now resets only Bifrost's transport, preferences, clock, decoder, performance tracker, fake mode, notifications, and internal diagnostics. It never replacesbifrostServiceLocator; apps rebuild their own production container throughinitializeApplication.BifrostTestRuntime.install( responseFactory: (_) => const <Object>[], notifications: notifications, diagnostics: diagnostics, initializeApplication: AppBinding.reinitialize, );responseFactory,shouldFail, andrespondernow share the same per-request responder seam, so overrides reach HTTP clients retained by vendor SDKs such as Supabase. Notification tests record outcomes without replacing the app's realSystemNotifierregistration. -
Added non-recoverable
FailureReason.internal. Unexpected defects thrown by source adapters, REST hooks, or repository callbacks no longer masquerade asnetworkand get rescued by stale cache.fetchandmutatereturn an internal failure; the source-compatible booleansendreturnsfalseand records an internal diagnostic. Internal failures never invokeSystemNotifier. -
RestAPInow bounds header resolution, each HTTP attempt, and unauthorized refresh. OverriderequestTimeoutorrefreshTimeoutfor API-specific budgets. OperationalClientExceptionand timeout failures still returnnull; unexpected hook/client defects preserve their original error and stack inBifrostInternalExceptionfor repository containment. Timed-out refreshes remain single-flight until the underlying refresh actually ends. -
Serialized the complete cache save, per-key clear, and clear-all mutations behind one failure-resilient queue shared by every repository instance. Two concurrent reads can no longer lose a registry entry and leave cached payloads that
clearAllCache()cannot enumerate. -
Added
postgrestErrorResponsetopackage:bifrosted/testing.dart. It builds a complete PostgREST error envelope for tests that exercise a real retained Supabase/PostgREST client; production SQLSTATE/PGRST mapping remains app-owned. -
Simplified Bifrost's private package test fixture: its locator is installed once per test file while its mutable services still reset before every test.
0.11.0 #
-
Breaking:
BifrostTestEnv.reset()no longer assignsbifrostServiceLocator. It was a second dependency-injection system running against the app's own. An app's bindings already point the locator at its container, soreset()and those bindings overwrote each other and which one won depended on call order — a repository test that ranreset()last silently stopped resolving the services its production code uses, while a widget test resolved them normally. Tests should exercise the same injection production does.Register the doubles through the app's own container instead:
bifrostTestEnv.reset(); Bind.delete<SystemNotifier>(force: true); // GetX keeps the first permanent registration Bind.put<SystemNotifier>(bifrostTestEnv.notifier, permanent: true);A package with no container of its own — bifrosted included — can call
bifrostTestEnv.installDoubles(), which does whatreset()used to. It is documented as exactly that: a shim for the no-DI case, not something an app should reach for.Migration: if your tests broke with
"…" not foundafter upgrading, addinstallDoubles()afterreset()to restore the old behaviour, then move to container registration when convenient.
0.10.5 #
BifrostTestEnv.reset()now installs the mock transport and the mock preference store, not just the service doubles. It already owned the mutable globals; the client and the store were the two pieces every app still had to remember in its ownsetUp, and forgetting either fails far from the cause — a realhttp.Clientreaching the network, orMissingPluginExceptionfromSharedPreferences.getInstance().useMockStoragein particular has to repeat per test, because the mock store is a static platform instance and a value one test writes is readable in the next until it is cleared. A test harness should be able to callreset()and nothing else.- Added
jsonResponse(body, {status, headers}). Hand-writinghttp.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'})in every test is how the header gets forgotten, and a client that decodes on it then fails in a way that points at the parser rather than the fixture.bifrostMockResponder = (request) async => jsonResponse([row, row]); bifrostMockResponder = (request) async => jsonResponse({'message': 'denied'}, status: 403);
0.10.4 #
-
Added
bifrostMockResponder, and aresponder:argument touseMockClient.responseFactoryreturns a body that is always wrapped in a200, so a test could not drive a status code, set a header, or change its answer partway through — every app hit this and grew its ownMockClienton the side, which is exactly the drift this package exists to prevent.A responder returns the whole
http.Response:useMockClient(responder: (request) async => http.Response('[]', 200)); // ...later, in one test: bifrostMockResponder = (request) async => http.Response('denied', 403);It is read per request rather than captured when the client is built. That is what makes it swappable after a vendor SDK has already resolved its
http.Clientand kept it — reinstalling a factory never reaches such an SDK, but reassigning this does.BifrostTestEnv.resetclears it, so one test's responder cannot leak into the next. -
Mocked responses are now stamped with their request.
MockClientpopulatesresponse.requestfrom the response the handler returns, so it stayed null unless the handler set it — and a client that dereferencesresponse.request!while parsing throws a null-check error instead of returning data. PostgREST does exactly that, on both its success and error paths, so every mocked Supabase call failed and was swallowed as a transport error.useMockClientnow fills the field in whenever a responder left it empty.
0.10.3 #
-
Added
fetchSource, a read path for data that does not arrive as anhttp.Response.fetchwas hard-wired toFuture<http.Response?>, so a repository backed by a vendor SDK — Supabase, Firestore, a GraphQL client — could not use it, and lost disk caching,CachePolicy, offline fallback, andSystemNotifieralong with it. The alternative was re-implementing that path per app, where it drifts.No new types. A source returns the same
BifrostResulta repository does, carrying already-decoded JSON:Future<BifrostResult<List<Article>>> latest() => fetchSource<List<Article>, Article>( source: () => newsService.selectApproved(), // BifrostResult<Object?> model: const ArticleModel(), cacheKey: 'articles_latest', );The seam is one function boundary wide:
_deserializealready decoded and then branched onList/Map, and everything after the decode was source-agnostic — so it splits there andfetchSourcefeeds the decoded half directly. Cache writes re-encode, leaving cache reads on the existing string path.Translate vendor errors in the service, not the repository and not here, so this package accumulates no backend-specific knowledge. A
sourcethat throws is caught and reported asFailureReason.network, so the "repositories never throw" contract holds even when an SDK misbehaves.With a
cacheKeyset, the decoded value must be JSON-encodable, since it is re-encoded to be stored. -
Added
statusForFailureReason, the inverse offailureReasonForStatus.SystemNotifier.onRequestFailedrequires a non-null status, but a source reports a reason and may have none — a Supabase row-level-security denial arrives as SQLSTATE42501, though PostgREST answers 403 for it over HTTP. Synthesizing the canonical status restores information the SDK dropped rather than inventing one, and stops aforbiddendegrading silently into "network error". Null fornetwork,offlineNoCache, andparse, none of which describe a response.parsenotifies nothing, matchingfetch, which consults the notifier before deserializing and so has never been able to. -
Added
bifrostHttpClient().setClientFactoryinstalled the factory but nothing could read it, so code that must hand anhttp.Clientto a vendor SDK could not receive the oneuseMockClient()installs — which forced constructor injection purely to test SDK-backed repositories, defeating the point of mocking at the HTTP layer. -
Added
bifrostClock, aDateTime Function()global defaulting toDateTime.now, mirroringbifrostJsonDecodeandbifrostFakeSeed. Cache expiry readDateTime.now()directly, so expiry behaviour could only be tested by waiting, and any app with time-dependent UI had goldens that changed by the second.BifrostTestEnv.resetrestores it.
0.10.2 #
-
Fixed:
RestAPI.hostrejected any value containing a colon._buildUripassedhoststraight intoUri(scheme: 'https', host: host), andUri'shostparameter reads a colon as an IPv6 literal — so a scheme-qualified URL, a port, and therefore any local dev server threwFormatException: Illegal IPv6 address, invalid character (at character 1), an error naming none of the actual causes.hostnow accepts what you'd reasonably put in a config value:hostRequest URL api.example.comhttps://api.example.com/...https://api.example.comas given http://localhost:8080as given — scheme and port preserved 10.0.2.2:3000https://10.0.2.2:3000/...https://example.com/v1base path preserved, not dropped Backward compatible: a bare hostname still resolves to
https. -
An empty
hostnow throwsArgumentErrornaming the likely cause — an app launched without--dart-define-from-file— instead of silently buildinghttps:///pathand failing at the transport layer.
0.10.1 #
- Widened the
loggerre-export.LoggerandLevelalone were not enough to reconfigurebifrostLogger: its defaultDevelopmentFilterdrops every log in release builds and ignoreslevel, so keeping warnings in release meant namingProductionFilter— which required adding a directloggerdependency just to reassign our own global. Now also exportsProductionFilter,DevelopmentFilter,LogFilter,LogPrinter,LogOutput,PrettyPrinter, andSimplePrinter.bifrostLogger = Logger( level: kReleaseMode ? Level.warning : Level.debug, filter: ProductionFilter(), // required; DevelopmentFilter ignores `level` );
0.10.0 #
- Fixed: nothing persisted across app launches.
SharedPrefService.init()calledSharedPreferences.setMockInitialValues({}), which replaces the platform store with an empty in-memory map (SharedPreferencesStorePlatform.instance = InMemorySharedPreferencesStore.withData(...)). Becauseinit()runs on every launch, no preference, token, onboarding flag, or cached response survived a cold start.init()now only reads. If your app appeared to "forget everything," this was why. - Breaking: removed
SharedPrefService.updateInitialMock. It was test scaffolding on a production class, and it set the mock store as a side effect. Replace it withuseMockStorage. - Added
useMockStorage({values})topackage:bifrosted/testing.dart— the storage counterpart touseMockClient. Tests need it for two reasons: there is no SharedPreferences platform plugin in a test process, sogetInstance()otherwise throwsMissingPluginException; and the mock store is a static platform instance, so a value written by one test is still readable in the next unless it is reset. Call it insetUp(or once per file, beforeinitServices):useMockStorage(); // clean slate useMockStorage(values: {'onboarding_done': true}); // seeded state - Added [OnboardingController], a reusable multi-step flow controller. Subclass it, implement
buildResult, and inheritnext/back/skip, answer collection, completion, and the analytics funnel. - It is a plain
ChangeNotifier— no state-management dependency — matching the documented exception for one-time flows that run before the app's main state exists. - Emits the events a funnel is computed from:
started,step_viewed,answered,skipped,back,completed(with duration, reach and skip count), andabandonedon dispose-before-completion, naming the step the user quit on. The event prefix is configurable. step_viewedfires once per step per session, on first arrival only. Re-counting a revisit afterback()would inflate early steps and overstate the funnel's health.- The controller reports events, not rates: a completion rate is a population statistic that one session cannot know.
completed ÷ startedis a query in your analytics tool. back()never clears answers — losing input on a back tap is the most reliable way to cause abandonment.
0.9.0 #
- Breaking:
fetchandmutatereturnBifrostResult<T>instead ofT?. A barenulltold callers that something failed but never what, so every screen could only show generic copy.BifrostFailurecarries aFailureReason, an optional status code, and diagnostic detail. Migrate call sites from a null check to an exhaustive switch:// Before final user = await repo.getUser(id); if (user != null) { print(user.name); } else { // Error was already handled by SystemNotifier } // After switch (await repo.getUser(id)) { case BifrostSuccess(:final data): print(data.name); case BifrostFailure(:final reason): // Error was already handled by SystemNotifier; render an error state. } - Breaking:
mutatetakes a requiredmodel:instead of an optionalfromJson:. This is not a rename — every existing body-lessmutate(apiRequest: ..., invalidateKeys: [...])call (the patternfromJsonbeing optional made possible) is now a compile error. Switch those call sites tosend(), which already returnsbooland takes the sameinvalidateKeys:. - Breaking (behavior): a 2xx response with an empty body through
mutatenow returnsBifrostFailure(FailureReason.parse)instead ofnull—mutatehas nothing to deserialize intomodel:when there's no body. Cache invalidation still runs. Usesend()for writes that don't return a body. - Added
silent:tofetch,mutate, andsend— a background refresh no longer firesSystemNotifier. - Added
CachePolicy(networkFirst,cacheFirst,networkOnly). Cache fallback now keys on request outcome rather thanConnectionChecker.isConnected: online-but-failing previously returned nothing while holding usable cached data. 4xx is excluded from fallback, since a definitive server answer must not be masked by stale data. cacheDurationnow means something undercacheFirst; previously it only bounded the offline fallback.- Added
RestAPI.resolveHeaders()andRestAPI.onUnauthorized()for one-shot 401 refresh, with a shared in-flight future so concurrent 401s cause one refresh, not five. Defaults preserve prior behaviour. - Removed
DeserializationException, which was defined, exported, and never thrown.
0.8.0 #
- Added app-facing service interfaces: [AnalyticsService], [EntitlementService], [PaywallPresenter], and [AttributionService], each with a
NoOpdefault. bifrosted does not call these — they live here so one package covers every project's service shapes. No vendor SDK is added; the app supplies implementations. - [EntitlementService] exposes entitlements as a
Stream, not a one-shot check, because they change mid-session on purchase, restore, and expiry. - [PaywallPresenter] is separate from [EntitlementService] so Superwall, RevenueCat, and custom Flutter paywalls swap without touching call sites.
- [AttributionService] models the platform asymmetry deliberately: Android's Play Install Referrer yields a per-install code, iOS has no runtime equivalent and
captureReferral()returningnullthere is the expected result. - The entitlement and paywall no-ops warn through
bifrostLoggerwhen they swallow a user-initiated action; the analytics and attribution no-ops stay silent, since shipping without analytics and returning null on iOS are both legitimate steady states. bifrostTestEnvgainsanalytics,entitlements,paywalls, andattributiondoubles, all resolvable throughbifrostServiceLocator.
0.7.0 #
- Breaking: Removed the
firebase_performancedependency. bifrosted now defines [PerformanceTracker] / [HttpTrace] / [BifrostHttpMethod] and ships [NoOpPerformanceTracker]; the app supplies the SDK-specific implementation and assignsbifrostPerformanceTracker. - Breaking: Removed
bifrostPerformanceEnabled. Assignconst NoOpPerformanceTracker()to disable metrics. - Breaking:
useMockClientmoved frompackage:bifrosted/bifrosted.darttopackage:bifrosted/testing.dart.package:http/testing.dartis no longer imported by core. - Breaking: Fake data is now seeded by default (
kDefaultFakeSeed), makingModel.fake()reproducible across runs so golden tests are stable. SetbifrostFakeSeed = nullfor the previous random behaviour. - Traces complete without
awaiton the request path, so a slow monitoring SDK no longer adds latency to every request. - Unlike the 0.5.5 tracker, resolution is a plain global with a no-op default rather than
bifrostServiceLocator, so no registration is required andtryBifrostServiceLocatoris unnecessary.
0.6.0 #
- Added direct Firebase Performance HTTP metric instrumentation in [RestAPI]
- Adds
firebase_performanceas a dependency - Toggle via top-level
bifrostPerformanceEnabled(defaults totrue; auto-disabled when Firebase isn't initialized) - Test env disables performance monitoring automatically via
bifrostTestEnv.reset()
0.5.6 #
- Breaking: Removed [PerformanceTracker], [tryBifrostServiceLocator], and HTTP metric hooks from [RestAPI]
- [RestAPI] reuses one [http.Client] per instance; use [RestAPI.closeClient] when discarding an API
0.5.5 #
- Added pluggable [PerformanceTracker] via [bifrostServiceLocator] for HTTP metrics (removed in 0.5.6)
- [RestAPI] uses performance hooks when a tracker is set (no-op when null)
- [MockPerformanceTracker] in
package:bifrosted/testing.dartfor tests (removed in 0.5.6) - App supplies Firebase/Sentry/etc. implementations; bifrosted has no Firebase dependency
0.5.4 #
- Breaking: Simplified [SystemNotifier] to three UI-only callbacks:
onNetworkError(),onUnauthorized(),onRequestFailed({statusCode, body})- Removed
onForbidden,onServerError,onApiError
- Docs: notifiers must handle user-facing UI only; use
bifrostLoggerfor diagnostics
0.5.3 #
- Breaking: Removed
fetchList— usefetch<List<T>>with the samefromJson - Breaking: Removed
endpointonfetch— usecacheKeyonly (it was only used for caching) fetch<R, M>auto-detects top-level JSON array vs object;fromJsonis always the item parser (User.fromJson), return type isUserorList<User>
0.5.2 #
- Removed
unwrapResponse()override - Removed in-memory singleton cache (
_memoryCache,clearMemoryCache,useMemoryCache) - Simplified
fetch/fetchListsignatures
0.5.1 #
- Added
bifrostJsonDecode- Global async JSON decoder- Defaults to synchronous
jsonDecode(works everywhere) - Override to decode on a background isolate for large payloads:
// Dart: bifrostJsonDecode = (body) => Isolate.run(() => jsonDecode(body)); // Flutter: bifrostJsonDecode = (body) => compute(jsonDecode, body); - Web-safe: no
dart:isolatedependency in bifrosted itself
- Defaults to synchronous
0.5.0 #
- Breaking: Removed
dart:iodependency - now works on web/Jaspr - Breaking:
post/put/patch/deletebody is nowObject?instead ofString?- Maps and Lists are auto-encoded to JSON
- Strings are sent as-is
- Breaking: Removed
Deserializerclass - deserialization is now inlined - Added
unwrapResponse()override for wrapped API responses ({"data": {...}}) - Added
mutate<T>()for write operations (POST/PUT/PATCH/DELETE) with optional deserialization - Added
send()for fire-and-forget writes that returnbool - Both
mutateandsendacceptinvalidateKeysfor automatic cache invalidation - Added in-memory singleton cache for deserialized objects
BifrostRepository.clearMemoryCache()to resetuseMemoryCacheparameter onfetch/fetchListto opt out per call
clearAllCache()now only removes bifrost-prefixed keys (no longer wipes all storage)DeserializationExceptionis still exported for custom use
0.4.5 #
- update dependencies
0.4.0 #
- Breaking: Removed generics from
BifrostRepository - Added
bifrostServiceLocator- Set once, used everywhere// At app startup: bifrostServiceLocator = <T>() => Get.find<T>(); - Repositories now have zero boilerplate:
class UserRepo extends BifrostRepository { Future<User?> getUser(String id) => fetch<User>(...); }
0.3.0 #
- Added global mock client support for testing
useMockClient()- Enable mock responses for all RestAPI instancesuseRealClient()- Reset to real HTTP clientssetClientFactory()- Set custom client factory- No more per-API client overrides needed
0.2.2 #
- Fixed build.yaml to correctly combine generated code into
.g.dartfiles- Changed
build_to: cacheandbuild_extensions: .fake.g.part - Generator output now properly merges with json_serializable/freezed
- Changed
0.2.1 #
- Added
build.yamlfor auto-discovery by build_runner- No manual configuration needed - just add the dependency and run build_runner
- Works like freezed/json_serializable out of the box
0.2.0 #
- Added
@generateFakeannotation for code generation - Added
FakeUtilsutility class (usesfakerpackage)fakeForKey(String key)- generates fake data based on field namecreate<T>()- generates fake model from factoryfakeJson()/fakeJsonList()- generic JSON generators
- Added
FakeGeneratorfor build_runner integration- Generates
.fake()extension methods for annotated classes - Works with freezed models
- Generates
0.1.1 #
- Updated README
0.1.0 #
- Initial release
RestAPIabstract class for REST API clients- GET, POST, PUT, PATCH, DELETE methods
- Automatic error handling and logging
- Header management with extra headers support
BifrostRepositoryfor repository pattern with cachingfetch<T>()andfetchList<T>()for automatic deserialization- Offline-first with cache fallback
- Automatic cache expiration
SystemNotifierinterface for global error handlingonNetworkError(),onUnauthorized(),onForbidden()onServerError(),onApiError()
StorageServiceinterface for pluggable storage backendsConnectionCheckerinterface for connectivity detection- Uses
loggerpackage for logging - Comprehensive test suite