
Maui Rest Api
- 44 installs
- 163 repo stars
- Updated July 6, 2026
- davidortinau/maui-skills
Consumes REST APIs in .NET MAUI apps with HttpClient and System.Text.Json, DI registration, a service pattern, full CRUD, and error handling.
About
Guides consuming REST APIs in .NET MAUI apps using HttpClient with System.Text.Json, DI registration, a service interface/implementation pattern, full CRUD and error handling. A developer uses it when calling backend REST endpoints from a MAUI app.
- HttpClient with System.Text.Json and DI registration
- Service interface pattern with full CRUD and error handling
Maui Rest Api by the numbers
- 44 all-time installs (skills.sh)
- Ranked #612 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/davidortinau/maui-skills --skill maui-rest-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 163 |
| Last updated | July 6, 2026 |
| Repository | davidortinau/maui-skills ↗ |
What it does
Consumes REST APIs in .NET MAUI apps with HttpClient and System.Text.Json, DI registration, a service pattern, full CRUD, and error handling.
Files
REST API Consumption — Gotchas & Best Practices
Common Mistakes
1. Creating HttpClient per request
// ❌ Creates socket exhaustion — each instance opens a new connection
public async Task<List<Item>> GetItemsAsync()
{
using var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/items");
// ...
}
// ✅ Register once in DI, inject everywhere
builder.Services.AddSingleton(sp => new HttpClient
{
BaseAddress = new Uri("https://api.example.com")
});2. Blocking with .Result or .Wait()
// ❌ Deadlocks on the UI thread
var items = _apiService.GetItemsAsync().Result;
// ✅ Always use async/await
var items = await _apiService.GetItemsAsync();3. Deserializing before checking status
// ❌ Tries to deserialize error HTML/JSON as your model
var content = await response.Content.ReadAsStringAsync();
var items = JsonSerializer.Deserialize<List<Item>>(content, _jsonOptions);
// ✅ Check status first
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var items = JsonSerializer.Deserialize<List<Item>>(content, _jsonOptions) ?? [];4. Hardcoding BaseAddress in service methods
// ❌ Absolute URIs in every method — hard to change, easy to typo
await _httpClient.GetAsync("https://api.example.com/api/items");
// ✅ Set BaseAddress in DI, use relative URIs in methods
await _httpClient.GetAsync("api/items");5. Missing error handling for network failures
// ❌ Crashes on network timeout, DNS failure, etc.
var items = await _apiService.GetItemsAsync();
// ✅ Catch both network and deserialization errors
try
{
var items = await _apiService.GetItemsAsync();
}
catch (HttpRequestException ex) { /* network or HTTP error */ }
catch (JsonException ex) { /* malformed response */ }Platform Pitfalls
⚠️ Clear-text HTTP blocked on emulators/simulators
Local dev servers on http:// are blocked by default. Configure exceptions:
- Android: needs
network_security_config.xmlwithcleartextTrafficPermitted="true"for10.0.2.2 - iOS/Mac Catalyst: needs
NSAllowsLocalNetworkinginInfo.plist
⚠️ Android emulator uses 10.0.2.2 for localhost
The Android emulator maps 10.0.2.2 to the host machine. localhost refers to the emulator itself.
// ❌ On Android emulator, this hits the emulator, not your dev machine
new Uri("http://localhost:5000")
// ✅ Use the emulator's host loopback address
new Uri("http://10.0.2.2:5000")iOS simulators use localhost directly.
⚠️ Inconsistent JSON casing
APIs typically use camelCase; C# properties are PascalCase. Without JsonSerializerOptions, deserialization silently returns default values.
// ❌ Properties stay null/default — no error thrown
JsonSerializer.Deserialize<Item>(content);
// ✅ Configure casing policy
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};Decision Framework
| Scenario | Error handling approach |
|---|---|
| Failure is unexpected (auth'd endpoints) | EnsureSuccessStatusCode() — throws HttpRequestException |
| Need to branch on status codes | Check IsSuccessStatusCode or response.StatusCode |
| Network may be unreliable (mobile) | Wrap in try/catch for HttpRequestException |
| Response format may vary | Also catch JsonException |
Checklist
- [ ]
HttpClientregistered as singleton or viaIHttpClientFactory— never created per-request - [ ]
BaseAddressset in DI; service methods use relative URIs - [ ]
JsonSerializerOptionswithCamelCasepolicy applied consistently - [ ]
IsSuccessStatusCodeorEnsureSuccessStatusCode()checked before deserializing - [ ]
try/catchforHttpRequestExceptionandJsonExceptionin ViewModel calls - [ ] All API calls use
async/await— no.Resultor.Wait() - [ ] Service interface pattern used so ViewModels depend on abstractions
- [ ] Android clear-text config for local dev (
10.0.2.2) - [ ] iOS
NSAllowsLocalNetworkingfor local dev
REST API Reference
HttpClient & JSON Setup
Always configure a shared JsonSerializerOptions with camel-case naming:
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};DI Registration
Register HttpClient as a singleton or use IHttpClientFactory. Set BaseAddress once:
// MauiProgram.cs
builder.Services.AddSingleton(sp => new HttpClient
{
BaseAddress = new Uri("https://api.example.com")
});
builder.Services.AddSingleton<IMyApiService, MyApiService>();For more control, use the factory pattern:
builder.Services.AddHttpClient<IMyApiService, MyApiService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});Service Interface + Implementation
Define a clean interface for each API resource:
public interface IMyApiService
{
Task<List<Item>> GetItemsAsync();
Task<Item?> GetItemAsync(int id);
Task<Item?> CreateItemAsync(Item item);
Task<bool> UpdateItemAsync(Item item);
Task<bool> DeleteItemAsync(int id);
}Implement the interface, injecting HttpClient:
public class MyApiService : IMyApiService
{
private readonly HttpClient _httpClient;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public MyApiService(HttpClient httpClient)
{
_httpClient = httpClient;
}CRUD Operations
GET (list)
public async Task<List<Item>> GetItemsAsync()
{
var response = await _httpClient.GetAsync("api/items");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<Item>>(content, _jsonOptions) ?? [];
}GET (single)
public async Task<Item?> GetItemAsync(int id)
{
var response = await _httpClient.GetAsync($"api/items/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Item>(content, _jsonOptions);
}POST (create)
public async Task<Item?> CreateItemAsync(Item item)
{
var json = JsonSerializer.Serialize(item, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("api/items", content);
if (!response.IsSuccessStatusCode)
return null;
var responseBody = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Item>(responseBody, _jsonOptions);
}PUT (update)
public async Task<bool> UpdateItemAsync(Item item)
{
var json = JsonSerializer.Serialize(item, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync($"api/items/{item.Id}", content);
return response.IsSuccessStatusCode;
}DELETE
public async Task<bool> DeleteItemAsync(int id)
{
var response = await _httpClient.DeleteAsync($"api/items/{id}");
return response.IsSuccessStatusCode;
}
}Common HTTP Response Codes
| Code | Meaning | Typical use |
|---|---|---|
| 200 | OK | Successful GET or PUT |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE or PUT (no body) |
| 400 | Bad Request | Validation error in request body |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate or state conflict |
Platform-Specific: Local Development with HTTP Clear-Text
Emulators and simulators block clear-text HTTP by default. When targeting a local dev server over http://:
Android — add a network security config in Platforms/Android/Resources/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>Reference it in AndroidManifest.xml:
<application android:networkSecurityConfig="@xml/network_security_config" ... />iOS / Mac Catalyst — add an NSAppTransportSecurity exception in Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>Note: Android emulators reach the host machine at10.0.2.2. iOS simulators uselocalhostdirectly.