nebula_api_studio 0.1.1
nebula_api_studio: ^0.1.1 copied to clipboard
A professional Dart/Flutter package combining a Compiler + Runtime SDK. Convert OpenAPI 3.x / Swagger 2.0 specs into fully type-safe Dart SDKs with smart caching, retry policies, a plugin system, offl [...]
example/main.dart
// ignore_for_file: avoid_print
/// Nebula API Studio — Complete Usage Example
///
/// This file demonstrates the full runtime API:
/// • MockPetStoreAdapter — zero-network mock implementing [HttpAdapter]
/// • NebulaClient — constructed with plugins, retry and cache
/// • Plugin chain — JwtAuthPlugin + LoggingPlugin + AnalyticsPlugin
/// • PetsService — hand-written service layer (mirrors generated code)
/// • Result<T> monad — fold, map, when-style, getOrElse
/// • Cache statistics — via AnalyticsPlugin.snapshot()
/// • Graceful shutdown — client.dispose()
library;
import 'dart:async';
import 'dart:convert';
import 'package:nebula_api_studio/nebula_api_studio.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Domain models (normally emitted by `nebula generate`)
// ─────────────────────────────────────────────────────────────────────────────
/// Simulated Pet model — mirrors what the code-generator would produce.
class Pet {
// ── Constructors ────────────────────────────────────────────────────────────
const Pet({
required this.id,
required this.name,
required this.category,
required this.status,
this.age,
this.tags = const [],
});
factory Pet.fromJson(Map<String, dynamic> j) => Pet(
id: j['id'] as String,
name: j['name'] as String,
category: j['category'] as String,
status: j['status'] as String,
age: j['age'] as int?,
tags: (j['tags'] as List<dynamic>?)?.cast<String>() ?? [],
);
// ── Fields ──────────────────────────────────────────────────────────────────
final String id;
final String name;
final String category;
final String status;
final int? age;
final List<String> tags;
// ── Methods ─────────────────────────────────────────────────────────────────
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'category': category,
'status': status,
if (age != null) 'age': age,
'tags': tags,
};
@override
String toString() =>
'Pet(id: $id, name: $name, category: $category, '
'status: $status, age: $age, tags: $tags)';
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock HTTP adapter — returns hard-coded responses, no real network traffic
// ─────────────────────────────────────────────────────────────────────────────
/// Implements [HttpAdapter] with a small in-memory "database" of pets.
/// Replace with [DefaultAdapter] in production.
class MockPetStoreAdapter extends HttpAdapter {
final _pets = <String, Pet>{
'pet-001': const Pet(
id: 'pet-001',
name: 'Buddy',
category: 'dog',
status: 'available',
age: 3,
tags: ['friendly', 'trained'],
),
'pet-002': const Pet(
id: 'pet-002',
name: 'Whiskers',
category: 'cat',
status: 'adopted',
age: 5,
tags: ['indoor', 'calm'],
),
'pet-003': const Pet(
id: 'pet-003',
name: 'Rio',
category: 'bird',
status: 'available',
age: 2,
tags: ['talking', 'colorful'],
),
};
int _requestCount = 0;
int get requestCount => _requestCount;
@override
Future<ApiResponse> send(ApiRequest request) async {
_requestCount++;
// Simulate a tiny network latency.
await Future<void>.delayed(const Duration(milliseconds: 30));
final path = request.path;
final method = request.method.toUpperCase();
// ── GET /pets ─────────────────────────────────────────────────────────────
if (_isPetsListPath(path) && method == 'GET') {
final items = _pets.values.map((p) => p.toJson()).toList();
return _jsonResponse(200, {
'items': items,
'total': items.length,
});
}
// ── GET /pets/{id} ────────────────────────────────────────────────────────
if (_isPetItemPath(path) && method == 'GET') {
final id = _extractId(path);
final pet = _pets[id];
if (pet != null) return _jsonResponse(200, pet.toJson());
return _jsonResponse(404, {
'code': 'NOT_FOUND',
'message': 'Pet $id not found',
});
}
// ── POST /pets ────────────────────────────────────────────────────────────
if (_isPetsListPath(path) && method == 'POST') {
final body = request.body is Map<String, dynamic>
? request.body as Map<String, dynamic>
: <String, dynamic>{};
final newId = 'pet-${(_pets.length + 1).toString().padLeft(3, '0')}';
final newPet = Pet(
id: newId,
name: body['name'] as String? ?? 'Unknown',
category: body['category'] as String? ?? 'other',
status: body['status'] as String? ?? 'available',
age: body['age'] as int?,
tags: (body['tags'] as List<dynamic>?)?.cast<String>() ?? [],
);
_pets[newPet.id] = newPet;
return _jsonResponse(201, newPet.toJson());
}
// ── DELETE /pets/{id} ─────────────────────────────────────────────────────
if (_isPetItemPath(path) && method == 'DELETE') {
final id = _extractId(path);
if (_pets.containsKey(id)) {
_pets.remove(id);
return const ApiResponse(
statusCode: 204,
body: '',
headers: {},
);
}
return _jsonResponse(404, {
'code': 'NOT_FOUND',
'message': 'Pet $id not found',
});
}
// ── Fallback ──────────────────────────────────────────────────────────────
return _jsonResponse(501, {
'code': 'NOT_IMPLEMENTED',
'message': 'Endpoint $method $path is not mocked',
});
}
@override
Future<void> close() async {}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Strips the base URL and any version prefix, returning only the
/// resource portion of the path (e.g. "/pets" or "/pets/pet-001").
///
/// Examples:
/// https://api.example.com/v1/pets → /pets
/// https://api.example.com/v1/pets/p-1 → /pets/p-1
/// /v1/pets → /pets
/// /pets → /pets
static String _normalisePath(String raw) {
// 1. Extract the URI path (strips scheme + host + query).
String path;
try {
path = Uri.parse(raw).path;
} catch (_) {
path = raw;
}
// 2. Remove a leading version segment like /v1, /v2, /api/v3, etc.
// Pattern: optional /api then /v<digits> at the start of the path.
path = path.replaceFirst(RegExp(r'^(?:/api)?/v\d+'), '');
// 3. Ensure exactly one leading slash.
if (!path.startsWith('/')) path = '/$path';
return path;
}
static bool _isPetsListPath(String raw) {
final p = _normalisePath(raw);
return p == '/pets' || p == '/pets/';
}
static bool _isPetItemPath(String raw) {
final p = _normalisePath(raw);
final parts = p.split('/').where((s) => s.isNotEmpty).toList();
return parts.length == 2 && parts[0] == 'pets';
}
static String _extractId(String raw) {
final p = _normalisePath(raw);
return p.split('/').where((s) => s.isNotEmpty).last;
}
static ApiResponse _jsonResponse(int status, Map<String, dynamic> body) =>
ApiResponse(
statusCode: status,
body: jsonEncode(body),
headers: const {'content-type': 'application/json'},
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Service layer (mirrors what `nebula generate` would produce)
// ─────────────────────────────────────────────────────────────────────────────
/// Hand-written service that wraps [NebulaClient] — same pattern as generated.
class PetsService {
PetsService(this._client);
final NebulaClient _client;
static const _base = '/pets';
// ── List ──────────────────────────────────────────────────────────────────
Future<Result<List<Pet>>> listPets({String? status, int limit = 20}) =>
_client.request<List<Pet>>(
ApiRequest(
method: 'GET',
path: _base,
query: {
if (status != null) 'status': status,
'limit': limit,
},
useCache: true,
),
(json) {
final data = json as Map<String, dynamic>;
return (data['items'] as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Pet.fromJson)
.toList();
},
);
// ── Get one ───────────────────────────────────────────────────────────────
Future<Result<Pet>> getPet(String petId) => _client.request<Pet>(
ApiRequest(method: 'GET', path: '$_base/$petId', useCache: true),
(json) => Pet.fromJson(json as Map<String, dynamic>),
);
// ── Create ────────────────────────────────────────────────────────────────
Future<Result<Pet>> createPet({
required String name,
required String category,
required String status,
int? age,
List<String> tags = const [],
}) =>
_client.request<Pet>(
ApiRequest(
method: 'POST',
path: _base,
body: {
'name': name,
'category': category,
'status': status,
if (age != null) 'age': age,
'tags': tags,
},
),
(json) => Pet.fromJson(json as Map<String, dynamic>),
);
// ── Delete ────────────────────────────────────────────────────────────────
Future<Result<bool>> deletePet(String petId) => _client.request<bool>(
ApiRequest(method: 'DELETE', path: '$_base/$petId'),
(_) => true,
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Console helpers
// ─────────────────────────────────────────────────────────────────────────────
void _section(String title) {
print('\n${'─' * 62}');
print(' $title');
print('─' * 62);
}
void _ok(String msg) => print(' ✓ $msg');
void _err(String msg) => print(' ✗ $msg');
void _info(String msg) => print(' $msg');
// ─────────────────────────────────────────────────────────────────────────────
// Shared fold helper — adapts Result.fold to the "when" style used below
// ─────────────────────────────────────────────────────────────────────────────
extension ResultWhen<T> on Result<T> {
R when<R>({
required R Function(T value) success,
required R Function(ApiError error) failure,
}) =>
fold(onSuccess: success, onFailure: failure);
}
// ─────────────────────────────────────────────────────────────────────────────
// main()
// ─────────────────────────────────────────────────────────────────────────────
Future<void> main() async {
print('╔══════════════════════════════════════════════════════════════╗');
print('║ Nebula API Studio — Runtime Demo v0.1.0 ║');
print('╚══════════════════════════════════════════════════════════════╝');
// ── 1. Create mock adapter ────────────────────────────────────────────────
_section('1. Adapter Setup');
final adapter = MockPetStoreAdapter();
_ok('MockPetStoreAdapter instantiated (no real network calls)');
// ── 2. Build plugin chain ─────────────────────────────────────────────────
_section('2. Plugin Chain');
final authPlugin = JwtAuthPlugin(
tokenProvider: () async => 'demo-jwt-token-xxxx',
);
final loggingPlugin = LoggingPlugin(level: LogLevel.basic);
final analyticsPlugin = AnalyticsPlugin(
onFlush: (snapshot) => _info(
'Analytics flush — ${snapshot.totalRequests} req, '
'success rate: '
'${(snapshot.overallSuccessRate * 100).toStringAsFixed(0)}%',
),
);
_ok('JwtAuthPlugin configured (Bearer token)');
_ok('LoggingPlugin configured (basic level)');
_ok('AnalyticsPlugin configured');
// ── 3. Instantiate NebulaClient ───────────────────────────────────────────
_section('3. NebulaClient Construction');
final client = NebulaClient(
baseUrl: 'https://api.petstore.example.com/v1',
adapter: adapter,
plugins: [authPlugin, loggingPlugin, analyticsPlugin],
cache: MemoryCache(maxCapacity: 200),
invalidation: InvalidationGraph.crud('/pets'),
retry: ExponentialBackoff(
maxAttempts: 3,
baseDelay: const Duration(milliseconds: 100),
maxDelay: const Duration(seconds: 5),
),
);
_ok('NebulaClient created — baseUrl: https://api.petstore.example.com/v1');
final pets = PetsService(client);
// ── 4. List all pets ──────────────────────────────────────────────────────
_section('4. List All Pets [GET /pets]');
final listResult1 = await pets.listPets();
listResult1.when(
success: (list) {
_ok('Received ${list.length} pet(s):');
for (final p in list) {
_info(p.toString());
}
},
failure: (err) => _err('listPets failed: $err'),
);
// ── 5. Fetch by ID ────────────────────────────────────────────────────────
_section('5. Fetch Pet by ID [GET /pets/pet-001]');
final getResult = await pets.getPet('pet-001');
getResult.when(
success: (p) => _ok('Found: $p'),
failure: (err) => _err('getPet failed: $err'),
);
// ── 6. Cache hit — same request, no network call ──────────────────────────
_section('6. Cache Hit [GET /pets/pet-001 — served from memory cache]');
final before = adapter.requestCount;
final cachedResult = await pets.getPet('pet-001');
final after = adapter.requestCount;
cachedResult.when(
success: (p) {
final fromCache = after == before;
_ok('Got: ${p.name} — ${fromCache ? "⚡ from cache (0 network calls)" : "network call"}');
},
failure: (err) => _err('getPet (cached) failed: $err'),
);
// ── 7. 404 — non-existent pet ─────────────────────────────────────────────
_section('7. 404 Error Handling [GET /pets/pet-999]');
final notFound = await pets.getPet('pet-999');
notFound.when(
success: (p) => _err('Should NOT reach here: $p'),
failure: (err) {
_ok('Correctly received Failure — '
'type: ${err.type.name}, statusCode: ${err.statusCode}');
},
);
// ── 8. Create a new pet ───────────────────────────────────────────────────
_section('8. Create Pet [POST /pets]');
final createResult = await pets.createPet(
name: 'Nemo',
category: 'fish',
status: 'available',
age: 1,
tags: ['saltwater', 'tropical'],
);
createResult.when(
success: (p) => _ok('Created: $p'),
failure: (err) => _err('createPet failed: $err'),
);
// ── 9. Delete a pet ───────────────────────────────────────────────────────
_section('9. Delete Pet [DELETE /pets/pet-002]');
final deleteResult = await pets.deletePet('pet-002');
deleteResult.when(
success: (_) => _ok('pet-002 deleted successfully'),
failure: (err) => _err('deletePet failed: $err'),
);
// ── 10. List after mutations (cache invalidated) ──────────────────────────
_section('10. List After Mutations [cache was invalidated by POST/DELETE]');
final listResult2 = await pets.listPets();
listResult2.when(
success: (list) {
_ok('Now ${list.length} pet(s) in store:');
for (final p in list) {
_info(' • ${p.name} (${p.category}) — ${p.status}');
}
},
failure: (err) => _err('listPets failed: $err'),
);
// ── 11. Result.map() chaining ─────────────────────────────────────────────
_section('11. Result.map() — transform success value without unwrapping');
final namesResult = (await pets.listPets())
.map((list) => list.map((p) => p.name).join(', '));
namesResult.when(
success: (names) => _ok('Pet names: $names'),
failure: (err) => _err('Failed: $err'),
);
// ── 12. Result.getOrElse() ────────────────────────────────────────────────
_section('12. Result.getOrElse() — fallback on failure');
final names = (await pets.listPets())
.map((list) => list.map((p) => p.name).toList())
.getOrElse([]);
_ok('getOrElse result: $names');
// ── 13. Result.fold() — explicit exhaustive pattern ──────────────────────
_section('13. Result.fold() — exhaustive success / failure handling');
final singleResult = await pets.getPet('pet-001');
final message = singleResult.fold(
onSuccess: (p) => 'Success → ${p.name} is ${p.status}',
onFailure: (err) => 'Failure → ${err.type.name}: ${err.message}',
);
_ok(message);
// ── 14. Analytics snapshot ────────────────────────────────────────────────
_section('14. Analytics Snapshot');
final snap = analyticsPlugin.snapshot();
_info('Total requests dispatched : ${snap.totalRequests}');
_info('Total errors : ${snap.totalErrors}');
_info('Total cache hits : ${snap.totalCacheHits}');
_info(
'Overall success rate : '
'${(snap.overallSuccessRate * 100).toStringAsFixed(1)}%',
);
_info('HTTP adapter call count : ${adapter.requestCount}');
if (snap.endpoints.isNotEmpty) {
_info('Per-endpoint breakdown:');
for (final m in snap.endpoints.values) {
_info(
' ${m.key.padRight(35)} '
'reqs=${m.totalRequests} '
'ok=${m.successCount} '
'fail=${m.failureCount} '
'p50=${m.p50}ms',
);
}
}
// ── 15. Graceful shutdown ─────────────────────────────────────────────────
_section('15. Graceful Shutdown');
await client.dispose();
_ok('NebulaClient disposed — adapter and plugins cleaned up');
print(
'\n╔══════════════════════════════════════════════════════════════╗',
);
print(
'║ Demo completed successfully! 🎉 ║',
);
print(
'╚══════════════════════════════════════════════════════════════╝\n',
);
}