api_contract
A runtime API response contract validator for Flutter/Dart. Detect mismatches between your expected API contracts and actual server responses before they silently break your app.
๐ฏ The Problem
Backend APIs change. Fields get renamed, types shift from int to String, optional fields become null, and new fields appear without warning. Your app keeps running โ but with wrong data, broken UI, or silent failures.
api_contract catches these mismatches at runtime by validating API responses against a contract you define.
โจ Features
- โ Runtime validation - Catch API contract violations before they cause bugs
- โ Multiple contract modes - Strict or lenient validation
- โ Type checking - Validate field types (string, number, boolean, list, map)
- โ Nested objects - Support for complex nested structures
- โ Built-in types - DateTime, Uri, Duration auto-detected
- โ Code generation - Auto-generate contracts from model classes
- โ Repository pattern - Clean integration with repository layer
- โ CI/CD friendly - Throw errors in CI, log warnings in development
- โ Zero runtime overhead in release - Disable validation in production
๐ฆ Installation
Add to your pubspec.yaml:
dependencies:
api_contract: ^0.2.1
dev_dependencies:
api_contract_generator: ^0.2.0
build_runner: ^2.4.13
Then run:
dart pub get
๐ Quick Start
Option 1: Auto-Generate from Model (Recommended)
1. Annotate your model:
import 'package:api_contract/api_contract.dart';
import 'package:api_contract_generator/api_contract_generator.dart';
part 'user.g.dart';
@ApiContractSchema(mode: ContractMode.strict, version: '1.0')
class User {
User({
required this.id,
required this.name,
required this.email,
this.avatar,
required this.createdAt,
});
final int id;
final String name;
final String email;
@optional
final String? avatar; // Can be missing from response
final DateTime createdAt; // Auto-detected as string in JSON
}
2. Generate contract:
dart run build_runner build --delete-conflicting-outputs
3. Use in repository:
class UserRepo {
Future<ApiResult<User>> getUser(int userId) async {
try {
final response = await _apiService.getUser(userId);
// Validate response
final validationResult = userContract.validate(response.toJson());
validationResult.throwIfInvalid();
return ApiResult.success(response);
} catch (error) {
return ApiResult.failure(error);
}
}
}
Option 2: Manual Contract Definition
import 'package:api_contract/api_contract.dart';
final userContract = ApiContract(
mode: ContractMode.strict,
version: '1.0',
fields: {
'id': ContractField.required(type: FieldType.number),
'name': ContractField.required(type: FieldType.string),
'email': ContractField.required(type: FieldType.string),
'avatar': ContractField.optional(type: FieldType.string),
'createdAt': ContractField.required(type: FieldType.string),
},
);
// Validate
final result = userContract.validate(responseJson);
result.throwIfInvalid();
๐ Usage Guide
Contract Modes
// Strict mode - flags unexpected fields as violations
@ApiContractSchema(mode: ContractMode.strict)
// Lenient mode - ignores extra fields (default)
@ApiContractSchema(mode: ContractMode.lenient)
Field Annotations
class Post {
final String title; // Required by default
@optional
final String? description; // Can be missing from response
@nullable
final String? author; // Must be present but can be null
final DateTime createdAt; // Auto-detected as string in JSON
}
Difference:
@optional: Field can be missing from JSON โContractField.optional()@nullable: Field must be present but value can benullโContractField.nullable()
Supported Types
| Dart Type | JSON Type | Auto-Detected |
|---|---|---|
String |
string | โ |
int, double, num |
number | โ |
bool |
boolean | โ |
List |
array | โ |
Map |
object | โ |
DateTime |
string | โ |
Uri |
string | โ |
Duration |
number | โ |
| Custom classes | nested | โ |
Repository Pattern Integration
class LessonRepo {
final LessonApiService _apiService;
Future<ApiResult<Lesson>> getLesson(int id) async {
try {
// 1. Fetch from API
final response = await _apiService.getLesson(id);
// 2. Validate contract
final validationResult = lessonContract.validate(response.toJson());
// 3. Handle violations
validationResult.when(
valid: () => print('โ Valid response'),
invalid: (violations) {
for (final v in violations) {
print('โ ${v.fieldPath}: ${v.message}');
}
},
);
// 4. Throw if invalid (in CI mode)
validationResult.throwIfInvalid();
return ApiResult.success(response);
} catch (error) {
return ApiResult.failure(error);
}
}
}
Global Configuration
void main() {
ApiContractConfig.setup(
onViolation: ViolationBehavior.throwInCI, // Throw in CI, log otherwise
enableInRelease: false, // Disable in production
logPrefix: '[Contract]',
);
runApp(MyApp());
}
ViolationBehavior options:
log- Print violations (default)warn- Print warningsthrowAlways- Always throw exceptionthrowInCI- Throw only whenCI=trueenv var is setsilent- Ignore violations
๐งช CI/CD Integration
Set CI=true in your CI environment:
# GitHub Actions
env:
CI: true
Then configure:
ApiContractConfig.setup(
onViolation: ViolationBehavior.throwInCI,
);
Contract violations will fail your pipeline before broken assumptions reach production.
๐ API Reference
ApiContract
| Method | Description |
|---|---|
validate(Map<String, dynamic> json) |
Validates JSON against contract |
upgrade({version, added, removed}) |
Creates new contract version |
copyWith({fields, mode, version}) |
Copies with overrides |
ApiContract.fromJson(Map) |
Auto-generate from sample JSON |
ApiContract.fromJsonSchema(Map) |
Generate from JSON Schema |
ContractField
| Constructor | Description |
|---|---|
ContractField.required(type:) |
Required field |
ContractField.optional(type:) |
Optional (can be missing) |
ContractField.nullable(type:) |
Required but can be null |
ContractField.nested(nestedContract:) |
Nested object |
ContractField.list({listItemContract:}) |
List field |
ContractField.deprecated(type:, message:) |
Deprecated field |
ViolationType
| Type | Description |
|---|---|
missingRequiredField |
Required field is absent |
typeMismatch |
Field has wrong type |
unexpectedField |
Extra field in strict mode |
nullableViolation |
Non-nullable field is null |
deprecatedFieldUsed |
Deprecated field present |
๐ Examples
Check the example directory for complete examples:
๐ Version Upgrades
Track contract evolution:
final v1 = ApiContract(version: '1.0', fields: {...});
final v2 = v1.upgrade(
version: '2.0',
added: {'avatar': ContractField.optional(type: FieldType.string)},
removed: ['legacyId'],
);
๐ค Contributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Submit a pull request
๐ License
MIT License. See LICENSE for details.
๐ Related Packages
- api_contract_generator - Code generator companion package
๐ก Tips
- Use
ContractMode.strictduring development to catch all changes - Switch to
ContractMode.lenientif backend adds optional fields frequently - Enable
throwInCIto fail builds on contract violations - Disable validation in production builds for performance
- Use
@optionalfor fields that might be missing - Use
@nullablefor fields that are always present but can be null
โก Performance
- Zero overhead in release builds (when
enableInRelease: false) - Minimal overhead in debug builds
- Efficient type checking and validation
- No reflection used
Made with โค๏ธ for Flutter developers tired of silent API breakages.
Libraries
- api_contract
- Runtime API response contract validator for Flutter/Dart.