wordpress_client 10.1.0
wordpress_client: ^10.1.0 copied to clipboard
A library to interact with the Wordpress REST API. Supports most of the common endpoints and all of the CRUD operations on the endpoints.
WordPress Client
A strongly typed WordPress client for Dart & Flutter: REST, GraphQL and the Abilities API.
Features #
- Typed REST client for 30+ endpoints (posts, pages, media, users, comments, taxonomies, blocks, templates, navigation, widgets, settings, themes, ...) with full CRUD. See Supported REST Methods.
- Typed results:
WordpressResponse<T>is a success or a failure; failures classify into a sealedWordpressFailure. - Auth: Application Passwords (including the browser "authorize application" flow), JWT (two plugins), or your own
IAuthorization. Credentials never follow the client to another origin. - Resilience: opt-in retries with backoff and
Retry-After. - Caching: GET cache with per-resource invalidation and ETag revalidation. Custom stores supported.
- Pagination:
listPages/listAll, plus parallel page fetching. - GraphQL (WPGraphQL): queries, mutations, partial results, GET, pagination, batching, persisted queries.
- Abilities API (WordPress 6.9+).
- Custom post types and plugin routes:
client.resource<T>('events', decoder: ...)gives typed CRUD and pagination for any collection. - Fewer round trips:
_embed/_fieldshelpers, the WordPress batch endpoint (client.batch), request de-duplication and a concurrency limit. - Offline writes:
OfflineWriteQueuekeeps writes that fail offline and replays them in order. - Extensibility: middlewares, Dio interceptors, custom endpoints, raw requests, polling streams.
- Testable:
MockWordpressAdapterserves canned responses, no live site needed.
Install #
dependencies:
wordpress_client: ^10.0.0
Requires Dart >=3.0.0.
Quick start #
import 'package:wordpress_client/wordpress_client.dart';
// From the site root (resolves <site>/wp-json/wp/v2)
final client = WordpressClient.forSite(
siteUrl: Uri.parse('https://example.com'),
);
// Or from the REST base URL
final same = WordpressClient(
baseUrl: Uri.parse('https://example.com/wp-json/wp/v2'),
);
final response = await client.posts.list(ListPostRequest(perPage: 10));
switch (response) {
case WordpressSuccessResponse():
print(response.data.map((p) => p.title?.rendered)); // List<Post>
case WordpressFailureResponse():
print('${response.code}: ${response.message}');
}
Create the client once and reuse it. Call client.dispose() when done.
Configure #
final client = WordpressClient.forSite(
siteUrl: Uri.parse('https://example.com'),
bootstrapper: (b) => b
.withDefaultAuthorization(
WordpressAuth.appPassword(user: 'admin', appPassword: 'xxxx xxxx xxxx'),
)
.withRequestTimeout(const Duration(seconds: 20))
.withRetryPolicy(const RetryPolicy.standard())
.withCache(ttl: const Duration(minutes: 5))
.withRequestDeduplication() // identical concurrent GETs share one call
.withMaxConcurrentRequests(6) // queue the rest
.withDefaultUserAgent('my-app/1.0')
.build(),
);
More: Bootstrap and Configuration, Request De-duplication and Concurrency, Authorization.
Sign in with the browser #
Let the user approve your app on their own site (WordPress 5.6+). No password is ever typed into your app.
await client.discover();
final flow = AppPasswordFlow.fromDiscovery(
client.discovery,
appName: 'My App',
successUrl: Uri.parse('myapp://wp-callback'),
rejectUrl: Uri.parse('myapp://wp-rejected'),
);
// open flow.authorizeUrl in the system browser, then on the redirect:
final result = flow.parseCallback(callbackUri);
if (result is AppPasswordGranted) {
client.reconfigure(
bootstrapper: (b) => b.withDefaultAuthorization(result.toAuthorization()).build(),
);
}
More: Browser Sign-In, Authorization.
Read, create, update, delete #
final post = (await client.posts.retrieve(RetrievePostRequest(id: 1))).dataOrThrow();
await client.posts.create(CreatePostRequest(
title: 'Hello',
content: '<p>First post</p>',
status: 'draft',
));
await client.posts.update(UpdatePostRequest(id: 1, title: 'Renamed'));
await client.posts.delete(DeletePostRequest(id: 1, force: true));
Write requests need credentials (withDefaultAuthorization, or authorization: on the request).
Handle results #
final r = await client.posts.list(ListPostRequest());
final posts = r.dataOrThrow(); // throws on failure
final safe = r.dataOrNull(); // null on failure
final count = r.map(onSuccess: (s) => s.data.length, onFailure: (_) => 0);
if (r case WordpressFailureResponse(:final failure)) {
switch (failure) {
case NetworkFailure(:final kind): print('network: $kind'); // timeout, connection, cancelled
case AuthFailure(:final reason): print('auth: $reason');
case ApiFailure(:final statusCode, :final error): print('$statusCode ${error?.code}');
case DecodeFailure(): print('bad response body');
case MiddlewareFailure(): print('a middleware aborted it');
case ClientFailure(:final type): print('client: $type');
}
}
More: Error Handling and Responses.
Query fluently #
final res = await client.posts.query
.withSearch('flutter')
.withCategories([3, 7])
.withOrder(Order.desc)
.withPerPage(20)
.execute();
final bySlug = await client.pages.extensions.findBySlug('about'); // Page?
// One request instead of three: embed the author and featured image
final embedded = await client.posts.query.withEmbed(['author', 'wp:featuredmedia']).execute();
final post = embedded.dataOrThrow().first;
print(post.embedded.author?.name); // also post.embedded.featuredMedia
final lean = await client.posts.query.withFields(['id', 'title', 'link']).execute();
More: Fluent Queries, Embed and Fields, Interfaces and Extensions.
Paginate #
// Everything in one response (use maxItems to cap it)
final all = await client.posts.listAll(ListPostRequest(), maxItems: 1000);
// Lazily, one response per page
await for (final page in client.posts.listPages(ListPostRequest(), perPage: 50)) {
// page is a WordpressResponse<List<Post>>; a failing page ends the stream
}
// Pages in parallel
final merged = await client.parallel
.list<Post, ListPostRequest>(
interface: client.posts,
requestBuilder: () => [
for (var p = 1; p <= 10; p++)
ParallelRequest(page: p, request: ListPostRequest(page: p, perPage: 50)),
],
)
.merge();
More: Pagination and Finders, Parallel Requests.
Retries #
Off by default. Retries timeouts, connection errors and 429/502/503/504, only for idempotent methods (never POST/PATCH).
b.withRetryPolicy(const RetryPolicy(maxAttempts: 4, baseDelay: Duration(seconds: 1)))
// override for one raw request
client.raw(WordpressRequest(
method: HttpMethod.get,
url: RequestUrl.relative('posts'),
retryPolicy: RetryPolicy.none,
));
More: Retries.
Cache #
b.withCache(
ttl: const Duration(minutes: 5),
ttlFor: (url) => url.path.endsWith('/categories') ? const Duration(hours: 1) : null,
)
GET responses are cached per URL, query and credential. A write evicts only the resource it touched. Expired entries with an ETag are revalidated with a 304. More: Caching.
GraphQL (WPGraphQL) #
final res = await client.graphql.query<List<Map<String, dynamic>>>(
document: r'query($n: Int!) { posts(first: $n) { nodes { id title } } }',
variables: {'n': 5},
parseData: (d) => (d['posts']['nodes'] as List).cast<Map<String, dynamic>>(),
);
// Keep data and errors together (partial results)
final result = await client.graphql.execute<Map<String, dynamic>>(
document: '{ viewer { name } }',
parseData: (d) => d,
requireAuth: true,
);
if (result case WordpressSuccessResponse(:final data)) {
print(data.data); print(data.errors);
}
Also mutate, useGet, paginate / queryAll, batch, persisted queries and registered queries. More: GraphQL.
Custom post types and plugin routes #
class Event {
Event(this.id, this.title);
factory Event.fromJson(Map<String, dynamic> j) => Event(j['id'] as int, j['title'] as String);
final int id;
final String title;
Map<String, dynamic> toJson() => {'title': title};
}
final events = client.resource<Event>('events', decoder: Event.fromJson, encoder: (e) => e.toJson());
final all = (await events.listAll()).dataOrThrow();
final one = (await events.retrieve(7)).dataOrThrow();
await events.create(Event(0, 'Launch'));
// A plugin namespace instead of wp/v2
final orders = client.resource<Order>('orders', namespace: 'my-shop/v1', decoder: Order.fromJson);
More: Custom Post Types.
Batch writes #
final result = (await client.batch.run([
const BatchOperation.create('posts', body: {'title': 'A', 'status': 'draft'}),
BatchOperation.update('posts', 12, body: {'title': 'B'}),
BatchOperation.delete('posts', 13, force: true),
])).dataOrThrow();
print('${result.succeeded.length} ok, ${result.failures.length} failed');
Sent to /wp-json/batch/v1 (WordPress 5.6+), 25 per call; longer lists are split for you. More: Batch Requests.
Offline writes #
final queue = OfflineWriteQueue(store: MemoryWriteQueueStore()); // implement WriteQueueStore to persist
final client = WordpressClient.forSite(
siteUrl: siteUrl,
bootstrapper: (b) => b.withMiddleware(queue).withDefaultAuthorization(auth).build(),
);
final r = await client.posts.create(CreatePostRequest(title: 'Hi'));
if (r.wasQueued) showSnack('Saved, will sync when online');
final sync = await queue.flush(client); // when connectivity returns
print('${sync.sent.length} sent, ${sync.rejected.length} refused, ${sync.remaining} left');
Only JSON writes that failed to connect are queued (timeouts only with queueOnTimeout: true, because the server may have processed them).
More: Offline Write Queue, Middlewares.
Abilities API (WordPress 6.9+) #
final abilities = (await client.abilities.list(category: 'site')).dataOrThrow();
final out = await client.abilities.run('my-plugin/get-user-info', input: {'user_id': 1});
Requires an authenticated user. More: Abilities.
Middlewares #
client.registerMiddleware(
DelegatedMiddleware(
onRequestDelegate: (req) async {
print('-> ${req.method.name} ${req.extra[WordpressRequest.metaUrl]}');
return req;
},
onResponseDelegate: (res) async {
print('<- ${res.code} in ${res.duration.inMilliseconds}ms');
return res;
},
),
);
LoggingMiddleware(sink: print) logs one line per request with credentials redacted (Logging), and CacheMiddleware / OfflineWriteQueue are middlewares too.
More: Middlewares.
Custom endpoints and raw requests #
// Any URL on the client's origin, untyped
final raw = await client.raw(WordpressRequest(
method: HttpMethod.get,
url: RequestUrl.relative('posts'),
queryParameters: {'per_page': 1},
));
print(raw.code); print(raw.data);
Typed custom endpoints: Using Custom Requests. Polling streams: Streaming and Polling.
Test your code #
import 'package:dio/dio.dart';
import 'package:wordpress_client/testing.dart';
import 'package:wordpress_client/wordpress_client.dart';
final adapter = MockWordpressAdapter()
..reply('GET', '/posts', MockResponse.paged([{'id': 1}], total: 1, totalPages: 1))
..reply('POST', '/posts', MockResponse.wpError(statusCode: 403, code: 'rest_cannot_create'));
final client = WordpressClient.fromDioInstance(
baseUrl: Uri.parse('https://example.com/wp-json/wp/v2'),
instance: Dio()..httpClientAdapter = adapter,
);
expect(adapter.callCount(method: 'GET', path: '/posts'), 1);
More: Testing.
Security notes #
- A request is only sent to the client's own origin (scheme, host and port). Anything else fails with
RequestErrorType.originMismatch. reconfigure(baseUri: ...)to a different origin drops the default credentials and discovery data. Pass new credentials in the same call.- Cache keys hold a SHA-256 digest of the
Authorizationheader, never the header itself.
Documentation #
Everything is in the Wiki:
Contributing #
Bugs and feature requests go to the issue tracker. PRs are welcome.
