├── lib ├── src │ ├── dtos │ │ ├── jsonable.dart │ │ ├── otp_response.g.dart │ │ ├── log_stat.g.dart │ │ ├── apple_client_secret.g.dart │ │ ├── cron_job.g.dart │ │ ├── batch_result.g.dart │ │ ├── otp_response.dart │ │ ├── cron_job.dart │ │ ├── token_config.g.dart │ │ ├── log_stat.dart │ │ ├── auth_method_mfa.g.dart │ │ ├── auth_method_otp.g.dart │ │ ├── token_config.dart │ │ ├── apple_client_secret.dart │ │ ├── auth_method_mfa.dart │ │ ├── auth_method_otp.dart │ │ ├── mfa_config.g.dart │ │ ├── email_template_config.g.dart │ │ ├── mfa_config.dart │ │ ├── health_check.dart │ │ ├── backup_file_info.dart │ │ ├── health_check.g.dart │ │ ├── batch_result.dart │ │ ├── email_template_config.dart │ │ ├── backup_file_info.g.dart │ │ ├── auth_method_oauth2.dart │ │ ├── auth_method_password.g.dart │ │ ├── password_auth_config.g.dart │ │ ├── auth_method_password.dart │ │ ├── record_subscription_event.dart │ │ ├── password_auth_config.dart │ │ ├── record_auth.dart │ │ ├── record_auth.g.dart │ │ ├── auth_alert_config.g.dart │ │ ├── log_model.dart │ │ ├── oauth2_config.g.dart │ │ ├── auth_alert_config.dart │ │ ├── record_subscription_event.g.dart │ │ ├── oauth2_config.dart │ │ ├── auth_method_oauth2.g.dart │ │ ├── otp_config.dart │ │ ├── result_list.g.dart │ │ ├── otp_config.g.dart │ │ ├── log_model.g.dart │ │ ├── auth_methods_list.dart │ │ ├── auth_methods_list.g.dart │ │ ├── auth_method_provider.dart │ │ ├── auth_method_provider.g.dart │ │ ├── result_list.dart │ │ ├── collection_field.dart │ │ ├── collection_model.dart │ │ ├── record_model.dart │ │ └── collection_model.g.dart │ ├── services │ │ ├── base_service.dart │ │ ├── health_service.dart │ │ ├── cron_service.dart │ │ ├── file_service.dart │ │ ├── log_service.dart │ │ ├── collection_service.dart │ │ ├── settings_service.dart │ │ ├── backup_service.dart │ │ ├── base_crud_service.dart │ │ ├── batch_service.dart │ │ └── realtime_service.dart │ ├── sse │ │ ├── sse_message.g.dart │ │ ├── sse_message.dart │ │ └── sse_client.dart │ ├── client_exception.dart │ ├── sync_queue.dart │ ├── auth_store.dart │ ├── async_auth_store.dart │ ├── multipart_request.dart │ └── caster.dart └── pocketbase.dart ├── test ├── dtos │ ├── otp_response_test.dart │ ├── apple_client_secret_test.dart │ ├── log_stat_test.dart │ ├── cron_job_test.dart │ ├── health_check_test.dart │ ├── backup_file_info_test.dart │ ├── log_model_test.dart │ ├── schema_field_test.dart │ ├── auth_method_provider_test.dart │ ├── record_auth_test.dart │ ├── record_subscription_event_test.dart │ ├── auth_methods_list_test.dart │ ├── result_list_test.dart │ ├── collection_model_test.dart │ └── record_model_test.dart ├── sync_queue_test.dart ├── services │ ├── health_service_test.dart │ ├── cron_service_test.dart │ ├── file_service_test.dart │ ├── log_service_test.dart │ ├── collection_service_test.dart │ ├── batch_service_test.dart │ ├── backup_service_test.dart │ ├── settings_service_test.dart │ └── crud_suite.dart ├── sse │ ├── sse_message_test.dart │ └── sse_client_test.dart ├── auth_store_test.dart ├── async_auth_store_test.dart └── caster_test.dart ├── .gitignore ├── pubspec.yaml ├── example └── example.dart ├── LICENSE └── analysis_options.yaml /lib/src/dtos/jsonable.dart: -------------------------------------------------------------------------------- 1 | /// Interface for JSON serializable classes. 2 | abstract class Jsonable { 3 | Map toJson(); 4 | } 5 | -------------------------------------------------------------------------------- /lib/src/services/base_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | 3 | abstract class BaseService { 4 | final PocketBase _client; 5 | 6 | PocketBase get client => _client; 7 | 8 | BaseService(this._client); 9 | } 10 | -------------------------------------------------------------------------------- /test/dtos/otp_response_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("OTPResponse", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "otpId": "test", 9 | }; 10 | 11 | final model = OTPResponse.fromJson(json); 12 | 13 | expect(model.toJson(), json); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /test/dtos/apple_client_secret_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("AppleClientSecret", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "secret": "test", 9 | }; 10 | 11 | final model = AppleClientSecret.fromJson(json); 12 | 13 | expect(model.toJson(), json); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /test/dtos/log_stat_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("LogStat", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "total": 123, 9 | "date": "test_date", 10 | }; 11 | 12 | final model = LogStat.fromJson(json); 13 | 14 | expect(model.toJson(), json); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub. 2 | .dart_tool/ 3 | .packages 4 | 5 | # Conventional directory for build outputs. 6 | build/ 7 | 8 | # Conventional directory for dart doc outputs. 9 | doc/ 10 | 11 | # Omit markdown preview files 12 | README.html 13 | CHANGELOG.html 14 | 15 | # Omit committing pubspec.lock for library packages; see 16 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 17 | pubspec.lock 18 | -------------------------------------------------------------------------------- /test/dtos/cron_job_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("CronJob", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "id": "test_id", 9 | "expression": "1 2 3 4 5", 10 | }; 11 | 12 | final model = CronJob.fromJson(json); 13 | 14 | expect(model.toJson(), json); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: pocketbase 2 | description: Multi-platform Dart SDK for PocketBase, an open source realtime backend in 1 file. 3 | repository: https://github.com/pocketbase/dart-sdk 4 | 5 | version: 0.23.0+1 6 | 7 | environment: 8 | sdk: '>=3.0.0 <4.0.0' 9 | 10 | dependencies: 11 | http: ^1.3.0 12 | json_annotation: ^4.9.0 13 | 14 | dev_dependencies: 15 | build_runner: ^2.2.0 16 | json_serializable: ^6.0.0 17 | lints: ^5.0.0 18 | test: ^1.16.0 19 | -------------------------------------------------------------------------------- /test/dtos/health_check_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("HealthCheck", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "code": 200, 9 | "message": "test", 10 | "data": {"test": 123}, 11 | }; 12 | 13 | final model = HealthCheck.fromJson(json); 14 | 15 | expect(model.toJson(), json); 16 | }); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /test/dtos/backup_file_info_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("BackupFileInfo", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "key": "test_key", 9 | "size": 100, 10 | "modified": "test_modified", 11 | }; 12 | 13 | final model = BackupFileInfo.fromJson(json); 14 | 15 | expect(model.toJson(), json); 16 | }); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /test/dtos/log_model_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("LogModel", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "id": "test_id", 9 | "created": "test_created", 10 | "updated": "test_updated", 11 | "level": -4, 12 | "message": "test_message", 13 | "data": {"a": 123}, 14 | }; 15 | 16 | final model = LogModel.fromJson(json); 17 | 18 | expect(model.toJson(), json); 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/dtos/otp_response.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'otp_response.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | OTPResponse _$OTPResponseFromJson(Map json) => OTPResponse( 10 | otpId: json['otpId'] as String? ?? "", 11 | ); 12 | 13 | Map _$OTPResponseToJson(OTPResponse instance) => 14 | { 15 | 'otpId': instance.otpId, 16 | }; 17 | -------------------------------------------------------------------------------- /test/dtos/schema_field_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("CollectionField", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "id": "schema_id", 9 | "name": "schema_name", 10 | "type": "schema_type", 11 | "system": true, 12 | "required": false, 13 | "presentable": true, 14 | "example": {"a": 123}, 15 | }; 16 | 17 | final model = CollectionField.fromJson(json); 18 | 19 | expect(model.toJson(), json); 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/dtos/log_stat.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'log_stat.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | LogStat _$LogStatFromJson(Map json) => LogStat( 10 | total: (json['total'] as num?)?.toInt() ?? 0, 11 | date: json['date'] as String? ?? "", 12 | ); 13 | 14 | Map _$LogStatToJson(LogStat instance) => { 15 | 'total': instance.total, 16 | 'date': instance.date, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/src/dtos/apple_client_secret.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'apple_client_secret.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AppleClientSecret _$AppleClientSecretFromJson(Map json) => 10 | AppleClientSecret( 11 | secret: json['secret'] as String? ?? "", 12 | ); 13 | 14 | Map _$AppleClientSecretToJson(AppleClientSecret instance) => 15 | { 16 | 'secret': instance.secret, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/src/dtos/cron_job.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'cron_job.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | CronJob _$CronJobFromJson(Map json) => CronJob( 10 | id: json['id'] as String? ?? "", 11 | expression: json['expression'] as String? ?? "", 12 | ); 13 | 14 | Map _$CronJobToJson(CronJob instance) => { 15 | 'id': instance.id, 16 | 'expression': instance.expression, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/src/dtos/batch_result.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'batch_result.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | BatchResult _$BatchResultFromJson(Map json) => BatchResult( 10 | status: json['status'] as num? ?? 0, 11 | body: json['body'], 12 | ); 13 | 14 | Map _$BatchResultToJson(BatchResult instance) => 15 | { 16 | 'status': instance.status, 17 | 'body': instance.body, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/src/dtos/otp_response.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "otp_response.g.dart"; 8 | 9 | /// Response DTO of a otp request response. 10 | @JsonSerializable(explicitToJson: true) 11 | class OTPResponse implements Jsonable { 12 | String otpId; 13 | 14 | OTPResponse({ 15 | this.otpId = "", 16 | }); 17 | 18 | static OTPResponse fromJson(Map json) => 19 | _$OTPResponseFromJson(json); 20 | 21 | @override 22 | Map toJson() => _$OTPResponseToJson(this); 23 | 24 | @override 25 | String toString() => jsonEncode(toJson()); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/dtos/cron_job.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "cron_job.g.dart"; 8 | 9 | /// Response DTO of a cron job item. 10 | @JsonSerializable(explicitToJson: true) 11 | class CronJob implements Jsonable { 12 | String id; 13 | String expression; 14 | 15 | CronJob({ 16 | this.id = "", 17 | this.expression = "", 18 | }); 19 | 20 | static CronJob fromJson(Map json) => _$CronJobFromJson(json); 21 | 22 | @override 23 | Map toJson() => _$CronJobToJson(this); 24 | 25 | @override 26 | String toString() => jsonEncode(toJson()); 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/dtos/token_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'token_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | TokenConfig _$TokenConfigFromJson(Map json) => TokenConfig( 10 | duration: json['duration'] as num? ?? 0, 11 | secret: json['secret'] as String?, 12 | ); 13 | 14 | Map _$TokenConfigToJson(TokenConfig instance) => 15 | { 16 | 'duration': instance.duration, 17 | 'secret': instance.secret, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/src/dtos/log_stat.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "log_stat.g.dart"; 8 | 9 | /// Response DTO of a single log statistic summary item. 10 | @JsonSerializable(explicitToJson: true) 11 | class LogStat implements Jsonable { 12 | int total; 13 | String date; 14 | 15 | LogStat({ 16 | this.total = 0, 17 | this.date = "", 18 | }); 19 | 20 | static LogStat fromJson(Map json) => _$LogStatFromJson(json); 21 | 22 | @override 23 | Map toJson() => _$LogStatToJson(this); 24 | 25 | @override 26 | String toString() => jsonEncode(toJson()); 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_mfa.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_method_mfa.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthMethodMFA _$AuthMethodMFAFromJson(Map json) => 10 | AuthMethodMFA( 11 | duration: json['duration'] as num? ?? 0, 12 | enabled: json['enabled'] as bool? ?? false, 13 | ); 14 | 15 | Map _$AuthMethodMFAToJson(AuthMethodMFA instance) => 16 | { 17 | 'duration': instance.duration, 18 | 'enabled': instance.enabled, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_otp.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_method_otp.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthMethodOTP _$AuthMethodOTPFromJson(Map json) => 10 | AuthMethodOTP( 11 | duration: json['duration'] as num? ?? 0, 12 | enabled: json['enabled'] as bool? ?? false, 13 | ); 14 | 15 | Map _$AuthMethodOTPToJson(AuthMethodOTP instance) => 16 | { 17 | 'duration': instance.duration, 18 | 'enabled': instance.enabled, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/src/dtos/token_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "token_config.g.dart"; 8 | 9 | /// Response DTO of a single collection token config. 10 | @JsonSerializable(explicitToJson: true) 11 | class TokenConfig implements Jsonable { 12 | num duration; 13 | String? secret; 14 | 15 | TokenConfig({ 16 | this.duration = 0, 17 | this.secret, 18 | }); 19 | 20 | static TokenConfig fromJson(Map json) => 21 | _$TokenConfigFromJson(json); 22 | 23 | @override 24 | Map toJson() => _$TokenConfigToJson(this); 25 | 26 | @override 27 | String toString() => jsonEncode(toJson()); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/dtos/apple_client_secret.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "apple_client_secret.g.dart"; 8 | 9 | /// Response DTO of the Apple OAuth2 Client Secret response. 10 | @JsonSerializable(explicitToJson: true) 11 | class AppleClientSecret implements Jsonable { 12 | String secret; 13 | 14 | AppleClientSecret({ 15 | this.secret = "", 16 | }); 17 | 18 | static AppleClientSecret fromJson(Map json) => 19 | _$AppleClientSecretFromJson(json); 20 | 21 | @override 22 | Map toJson() => _$AppleClientSecretToJson(this); 23 | 24 | @override 25 | String toString() => jsonEncode(toJson()); 26 | } 27 | -------------------------------------------------------------------------------- /test/dtos/auth_method_provider_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("AuthMethodProvider", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "name": "test_name", 9 | "displayName": "test_displayName", 10 | "state": "test_state", 11 | "codeVerifier": "test_codeVerifier", 12 | "codeChallenge": "test_codeChallenge", 13 | "codeChallengeMethod": "test_codeChallengeMethod", 14 | "authURL": "test_authUrl", 15 | "pkce": null, 16 | }; 17 | 18 | final model = AuthMethodProvider.fromJson(json); 19 | 20 | expect(model.toJson(), json); 21 | }); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_mfa.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "auth_method_mfa.g.dart"; 8 | 9 | /// Response DTO of mfa auth method option. 10 | @JsonSerializable(explicitToJson: true) 11 | class AuthMethodMFA implements Jsonable { 12 | num duration; 13 | bool enabled; 14 | 15 | AuthMethodMFA({ 16 | this.duration = 0, 17 | this.enabled = false, 18 | }); 19 | 20 | static AuthMethodMFA fromJson(Map json) => 21 | _$AuthMethodMFAFromJson(json); 22 | 23 | @override 24 | Map toJson() => _$AuthMethodMFAToJson(this); 25 | 26 | @override 27 | String toString() => jsonEncode(toJson()); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_otp.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "auth_method_otp.g.dart"; 8 | 9 | /// Response DTO of otp auth method option. 10 | @JsonSerializable(explicitToJson: true) 11 | class AuthMethodOTP implements Jsonable { 12 | num duration; 13 | bool enabled; 14 | 15 | AuthMethodOTP({ 16 | this.duration = 0, 17 | this.enabled = false, 18 | }); 19 | 20 | static AuthMethodOTP fromJson(Map json) => 21 | _$AuthMethodOTPFromJson(json); 22 | 23 | @override 24 | Map toJson() => _$AuthMethodOTPToJson(this); 25 | 26 | @override 27 | String toString() => jsonEncode(toJson()); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/dtos/mfa_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'mfa_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MFAConfig _$MFAConfigFromJson(Map json) => MFAConfig( 10 | duration: json['duration'] as num? ?? 0, 11 | enabled: json['enabled'] as bool? ?? false, 12 | rule: json['rule'] as String? ?? "", 13 | ); 14 | 15 | Map _$MFAConfigToJson(MFAConfig instance) => { 16 | 'duration': instance.duration, 17 | 'enabled': instance.enabled, 18 | 'rule': instance.rule, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/src/dtos/email_template_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'email_template_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | EmailTemplateConfig _$EmailTemplateConfigFromJson(Map json) => 10 | EmailTemplateConfig( 11 | subject: json['subject'] as String? ?? "", 12 | body: json['body'] as String? ?? "", 13 | ); 14 | 15 | Map _$EmailTemplateConfigToJson( 16 | EmailTemplateConfig instance) => 17 | { 18 | 'subject': instance.subject, 19 | 'body': instance.body, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/src/dtos/mfa_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "mfa_config.g.dart"; 8 | 9 | /// Response DTO of a single collection mfa auth config. 10 | @JsonSerializable(explicitToJson: true) 11 | class MFAConfig implements Jsonable { 12 | num duration; 13 | bool enabled; 14 | String rule; 15 | 16 | MFAConfig({ 17 | this.duration = 0, 18 | this.enabled = false, 19 | this.rule = "", 20 | }); 21 | 22 | static MFAConfig fromJson(Map json) => 23 | _$MFAConfigFromJson(json); 24 | 25 | @override 26 | Map toJson() => _$MFAConfigToJson(this); 27 | 28 | @override 29 | String toString() => jsonEncode(toJson()); 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/dtos/health_check.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "health_check.g.dart"; 8 | 9 | /// Response DTO of a health check. 10 | @JsonSerializable(explicitToJson: true) 11 | class HealthCheck implements Jsonable { 12 | int code; 13 | String message; 14 | Map data; 15 | 16 | HealthCheck({ 17 | this.code = 0, 18 | this.message = "", 19 | this.data = const {}, 20 | }); 21 | 22 | static HealthCheck fromJson(Map json) => 23 | _$HealthCheckFromJson(json); 24 | 25 | @override 26 | Map toJson() => _$HealthCheckToJson(this); 27 | 28 | @override 29 | String toString() => jsonEncode(toJson()); 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/dtos/backup_file_info.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "backup_file_info.g.dart"; 8 | 9 | /// Response DTO of a backup file info entry. 10 | @JsonSerializable(explicitToJson: true) 11 | class BackupFileInfo implements Jsonable { 12 | String key; 13 | int size; 14 | String modified; 15 | 16 | BackupFileInfo({ 17 | this.key = "", 18 | this.size = 0, 19 | this.modified = "", 20 | }); 21 | 22 | static BackupFileInfo fromJson(Map json) => 23 | _$BackupFileInfoFromJson(json); 24 | 25 | @override 26 | Map toJson() => _$BackupFileInfoToJson(this); 27 | 28 | @override 29 | String toString() => jsonEncode(toJson()); 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/dtos/health_check.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'health_check.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | HealthCheck _$HealthCheckFromJson(Map json) => HealthCheck( 10 | code: (json['code'] as num?)?.toInt() ?? 0, 11 | message: json['message'] as String? ?? "", 12 | data: json['data'] as Map? ?? const {}, 13 | ); 14 | 15 | Map _$HealthCheckToJson(HealthCheck instance) => 16 | { 17 | 'code': instance.code, 18 | 'message': instance.message, 19 | 'data': instance.data, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/src/services/health_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | import "../dtos/health_check.dart"; 3 | import "base_service.dart"; 4 | 5 | /// The service that handles the **Health APIs**. 6 | /// 7 | /// Usually shouldn't be initialized manually and instead 8 | /// [PocketBase.health] should be used. 9 | class HealthService extends BaseService { 10 | HealthService(super.client); 11 | 12 | /// Checks the health status of the api. 13 | Future check({ 14 | Map query = const {}, 15 | Map headers = const {}, 16 | }) { 17 | return client 18 | .send>( 19 | "/api/health", 20 | query: query, 21 | headers: headers, 22 | ) 23 | .then(HealthCheck.fromJson); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/dtos/batch_result.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "batch_result.g.dart"; 8 | 9 | /// Response DTO of a single batch request result. 10 | @JsonSerializable(explicitToJson: true) 11 | class BatchResult implements Jsonable { 12 | num status; 13 | 14 | // usually null, Map or List> 15 | dynamic body; 16 | 17 | BatchResult({ 18 | this.status = 0, 19 | this.body, 20 | }); 21 | 22 | static BatchResult fromJson(Map json) => 23 | _$BatchResultFromJson(json); 24 | 25 | @override 26 | Map toJson() => _$BatchResultToJson(this); 27 | 28 | @override 29 | String toString() => jsonEncode(toJson()); 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/dtos/email_template_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "email_template_config.g.dart"; 8 | 9 | /// Response DTO of a single collection email template config. 10 | @JsonSerializable(explicitToJson: true) 11 | class EmailTemplateConfig implements Jsonable { 12 | String subject; 13 | String body; 14 | 15 | EmailTemplateConfig({ 16 | this.subject = "", 17 | this.body = "", 18 | }); 19 | 20 | static EmailTemplateConfig fromJson(Map json) => 21 | _$EmailTemplateConfigFromJson(json); 22 | 23 | @override 24 | Map toJson() => _$EmailTemplateConfigToJson(this); 25 | 26 | @override 27 | String toString() => jsonEncode(toJson()); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/dtos/backup_file_info.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'backup_file_info.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | BackupFileInfo _$BackupFileInfoFromJson(Map json) => 10 | BackupFileInfo( 11 | key: json['key'] as String? ?? "", 12 | size: (json['size'] as num?)?.toInt() ?? 0, 13 | modified: json['modified'] as String? ?? "", 14 | ); 15 | 16 | Map _$BackupFileInfoToJson(BackupFileInfo instance) => 17 | { 18 | 'key': instance.key, 19 | 'size': instance.size, 20 | 'modified': instance.modified, 21 | }; 22 | -------------------------------------------------------------------------------- /test/sync_queue_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/src/sync_queue.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("SyncQueue()", () { 6 | test("async operations order", () async { 7 | var output = ""; 8 | 9 | final queue = SyncQueue(onComplete: () { 10 | expect(output, "abc"); 11 | }); 12 | 13 | // ignore: cascade_invocations 14 | queue 15 | ..enqueue(() async { 16 | // ignore: inference_failure_on_instance_creation 17 | await Future.delayed(const Duration(milliseconds: 5)); 18 | 19 | output += "a"; 20 | }) 21 | ..enqueue(() async { 22 | output += "b"; 23 | }) 24 | ..enqueue(() async { 25 | output += "c"; 26 | }); 27 | }); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /test/dtos/record_auth_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("RecordAuth", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "token": "test_token", 9 | "record": { 10 | "id": "test_id", 11 | "created": "test_created", 12 | "updated": "test_updated", 13 | "collectionId": "test_collectionId", 14 | "collectionName": "test_collectionName", 15 | "expand": { 16 | "test": RecordModel({"id": "123"}).toJson(), 17 | }, 18 | "a": 1, 19 | }, 20 | "meta": {"test": 123}, 21 | }; 22 | 23 | final model = RecordAuth.fromJson(json); 24 | 25 | expect(model.toJson(), json); 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /test/dtos/record_subscription_event_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("RecordSubscriptionEvent", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "action": "test_action", 9 | "record": { 10 | "id": "test_id", 11 | "created": "test_created", 12 | "updated": "test_updated", 13 | "collectionId": "test_collectionId", 14 | "collectionName": "test_collectionName", 15 | "expand": { 16 | "test": RecordModel({"id": "123"}).toJson(), 17 | }, 18 | "a": 1, 19 | }, 20 | }; 21 | 22 | final model = RecordSubscriptionEvent.fromJson(json); 23 | 24 | expect(model.toJson(), json); 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_oauth2.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "auth_method_provider.dart"; 6 | import "jsonable.dart"; 7 | 8 | part "auth_method_oauth2.g.dart"; 9 | 10 | /// Response DTO of oauth2 auth method option. 11 | @JsonSerializable(explicitToJson: true) 12 | class AuthMethodOAuth2 implements Jsonable { 13 | bool enabled; 14 | List providers; 15 | 16 | AuthMethodOAuth2({ 17 | this.enabled = false, 18 | this.providers = const [], 19 | }); 20 | 21 | static AuthMethodOAuth2 fromJson(Map json) => 22 | _$AuthMethodOAuth2FromJson(json); 23 | 24 | @override 25 | Map toJson() => _$AuthMethodOAuth2ToJson(this); 26 | 27 | @override 28 | String toString() => jsonEncode(toJson()); 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/sse/sse_message.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'sse_message.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | SseMessage _$SseMessageFromJson(Map json) => SseMessage( 10 | id: json['id'] as String? ?? "", 11 | event: json['event'] as String? ?? "message", 12 | data: json['data'] as String? ?? "", 13 | retry: (json['retry'] as num?)?.toInt() ?? 0, 14 | ); 15 | 16 | Map _$SseMessageToJson(SseMessage instance) => 17 | { 18 | 'id': instance.id, 19 | 'event': instance.event, 20 | 'data': instance.data, 21 | 'retry': instance.retry, 22 | }; 23 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_password.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_method_password.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthMethodPassword _$AuthMethodPasswordFromJson(Map json) => 10 | AuthMethodPassword( 11 | enabled: json['enabled'] as bool? ?? false, 12 | identityFields: (json['identityFields'] as List?) 13 | ?.map((e) => e as String) 14 | .toList(), 15 | ); 16 | 17 | Map _$AuthMethodPasswordToJson(AuthMethodPassword instance) => 18 | { 19 | 'enabled': instance.enabled, 20 | 'identityFields': instance.identityFields, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/src/dtos/password_auth_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'password_auth_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | PasswordAuthConfig _$PasswordAuthConfigFromJson(Map json) => 10 | PasswordAuthConfig( 11 | enabled: json['enabled'] as bool? ?? false, 12 | identityFields: (json['identityFields'] as List?) 13 | ?.map((e) => e as String) 14 | .toList(), 15 | ); 16 | 17 | Map _$PasswordAuthConfigToJson(PasswordAuthConfig instance) => 18 | { 19 | 'enabled': instance.enabled, 20 | 'identityFields': instance.identityFields, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_password.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "auth_method_password.g.dart"; 8 | 9 | /// Response DTO of password/identity auth method option. 10 | @JsonSerializable(explicitToJson: true) 11 | class AuthMethodPassword implements Jsonable { 12 | bool enabled; 13 | List identityFields; 14 | 15 | AuthMethodPassword({ 16 | this.enabled = false, 17 | List? identityFields, 18 | }) : identityFields = identityFields ?? []; 19 | 20 | static AuthMethodPassword fromJson(Map json) => 21 | _$AuthMethodPasswordFromJson(json); 22 | 23 | @override 24 | Map toJson() => _$AuthMethodPasswordToJson(this); 25 | 26 | @override 27 | String toString() => jsonEncode(toJson()); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/dtos/record_subscription_event.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | import "record_model.dart"; 7 | 8 | part "record_subscription_event.g.dart"; 9 | 10 | /// Response DTO of a single realtime subscription event. 11 | @JsonSerializable(explicitToJson: true) 12 | class RecordSubscriptionEvent implements Jsonable { 13 | String action; 14 | RecordModel? record; 15 | 16 | RecordSubscriptionEvent({ 17 | this.action = "", 18 | this.record, 19 | }); 20 | 21 | static RecordSubscriptionEvent fromJson(Map json) => 22 | _$RecordSubscriptionEventFromJson(json); 23 | 24 | @override 25 | Map toJson() => _$RecordSubscriptionEventToJson(this); 26 | 27 | @override 28 | String toString() => jsonEncode(toJson()); 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/dtos/password_auth_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "password_auth_config.g.dart"; 8 | 9 | /// Response DTO of a single collection password auth config. 10 | @JsonSerializable(explicitToJson: true) 11 | class PasswordAuthConfig implements Jsonable { 12 | bool enabled; 13 | List identityFields; 14 | 15 | PasswordAuthConfig({ 16 | this.enabled = false, 17 | List? identityFields, 18 | }) : identityFields = identityFields ?? []; 19 | 20 | static PasswordAuthConfig fromJson(Map json) => 21 | _$PasswordAuthConfigFromJson(json); 22 | 23 | @override 24 | Map toJson() => _$PasswordAuthConfigToJson(this); 25 | 26 | @override 27 | String toString() => jsonEncode(toJson()); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/dtos/record_auth.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | import "record_model.dart"; 7 | 8 | part "record_auth.g.dart"; 9 | 10 | /// Response DTO of the record authentication data. 11 | @JsonSerializable(explicitToJson: true) 12 | class RecordAuth implements Jsonable { 13 | String token; 14 | RecordModel record; 15 | Map meta; 16 | 17 | RecordAuth({ 18 | this.token = "", 19 | this.meta = const {}, 20 | RecordModel? record, 21 | }) : record = record ?? RecordModel(); 22 | 23 | static RecordAuth fromJson(Map json) => 24 | _$RecordAuthFromJson(json); 25 | 26 | @override 27 | Map toJson() => _$RecordAuthToJson(this); 28 | 29 | @override 30 | String toString() => jsonEncode(toJson()); 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/dtos/record_auth.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'record_auth.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | RecordAuth _$RecordAuthFromJson(Map json) => RecordAuth( 10 | token: json['token'] as String? ?? "", 11 | meta: json['meta'] as Map? ?? const {}, 12 | record: json['record'] == null 13 | ? null 14 | : RecordModel.fromJson(json['record'] as Map), 15 | ); 16 | 17 | Map _$RecordAuthToJson(RecordAuth instance) => 18 | { 19 | 'token': instance.token, 20 | 'record': instance.record.toJson(), 21 | 'meta': instance.meta, 22 | }; 23 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_alert_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_alert_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthAlertConfig _$AuthAlertConfigFromJson(Map json) => 10 | AuthAlertConfig( 11 | enabled: json['enabled'] as bool? ?? false, 12 | emailTemplate: json['emailTemplate'] == null 13 | ? null 14 | : EmailTemplateConfig.fromJson( 15 | json['emailTemplate'] as Map), 16 | ); 17 | 18 | Map _$AuthAlertConfigToJson(AuthAlertConfig instance) => 19 | { 20 | 'enabled': instance.enabled, 21 | 'emailTemplate': instance.emailTemplate.toJson(), 22 | }; 23 | -------------------------------------------------------------------------------- /lib/src/dtos/log_model.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "log_model.g.dart"; 8 | 9 | /// Response DTO of a single log model. 10 | @JsonSerializable(explicitToJson: true) 11 | class LogModel implements Jsonable { 12 | String id; 13 | String created; 14 | String updated; 15 | int level; 16 | String message; 17 | Map data; 18 | 19 | LogModel({ 20 | this.id = "", 21 | this.created = "", 22 | this.updated = "", 23 | this.level = 0, 24 | this.message = "", 25 | this.data = const {}, 26 | }); 27 | 28 | static LogModel fromJson(Map json) => 29 | _$LogModelFromJson(json); 30 | 31 | @override 32 | Map toJson() => _$LogModelToJson(this); 33 | 34 | @override 35 | String toString() => jsonEncode(toJson()); 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/dtos/oauth2_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'oauth2_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | OAuth2Config _$OAuth2ConfigFromJson(Map json) => OAuth2Config( 10 | enabled: json['enabled'] as bool? ?? false, 11 | mappedFields: (json['mappedFields'] as Map?)?.map( 12 | (k, e) => MapEntry(k, e as String), 13 | ), 14 | providers: json['providers'] as List?, 15 | ); 16 | 17 | Map _$OAuth2ConfigToJson(OAuth2Config instance) => 18 | { 19 | 'enabled': instance.enabled, 20 | 'mappedFields': instance.mappedFields, 21 | 'providers': instance.providers, 22 | }; 23 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_alert_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "email_template_config.dart"; 6 | import "jsonable.dart"; 7 | 8 | part "auth_alert_config.g.dart"; 9 | 10 | /// Response DTO of a single collection auth alert config. 11 | @JsonSerializable(explicitToJson: true) 12 | class AuthAlertConfig implements Jsonable { 13 | bool enabled; 14 | EmailTemplateConfig emailTemplate; 15 | 16 | AuthAlertConfig({ 17 | this.enabled = false, 18 | EmailTemplateConfig? emailTemplate, 19 | }) : emailTemplate = emailTemplate ?? EmailTemplateConfig(); 20 | 21 | static AuthAlertConfig fromJson(Map json) => 22 | _$AuthAlertConfigFromJson(json); 23 | 24 | @override 25 | Map toJson() => _$AuthAlertConfigToJson(this); 26 | 27 | @override 28 | String toString() => jsonEncode(toJson()); 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/dtos/record_subscription_event.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'record_subscription_event.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | RecordSubscriptionEvent _$RecordSubscriptionEventFromJson( 10 | Map json) => 11 | RecordSubscriptionEvent( 12 | action: json['action'] as String? ?? "", 13 | record: json['record'] == null 14 | ? null 15 | : RecordModel.fromJson(json['record'] as Map), 16 | ); 17 | 18 | Map _$RecordSubscriptionEventToJson( 19 | RecordSubscriptionEvent instance) => 20 | { 21 | 'action': instance.action, 22 | 'record': instance.record?.toJson(), 23 | }; 24 | -------------------------------------------------------------------------------- /lib/src/dtos/oauth2_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "oauth2_config.g.dart"; 8 | 9 | /// Response DTO of a single collection oauth2 auth config. 10 | @JsonSerializable(explicitToJson: true) 11 | class OAuth2Config implements Jsonable { 12 | bool enabled; 13 | Map mappedFields; 14 | List providers; 15 | 16 | OAuth2Config({ 17 | this.enabled = false, 18 | Map? mappedFields, 19 | List? providers, 20 | }) : mappedFields = mappedFields ?? {}, 21 | providers = providers ?? []; 22 | 23 | static OAuth2Config fromJson(Map json) => 24 | _$OAuth2ConfigFromJson(json); 25 | 26 | @override 27 | Map toJson() => _$OAuth2ConfigToJson(this); 28 | 29 | @override 30 | String toString() => jsonEncode(toJson()); 31 | } 32 | -------------------------------------------------------------------------------- /test/dtos/auth_methods_list_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("AuthMethodsList", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "mfa": { 9 | "duration": 10, 10 | "enabled": false, 11 | }, 12 | "otp": { 13 | "duration": 20, 14 | "enabled": true, 15 | }, 16 | "password": { 17 | "enabled": true, 18 | "identityFields": ["a", "b"], 19 | }, 20 | "oauth2": { 21 | "enabled": true, 22 | "providers": [ 23 | AuthMethodProvider(name: "test1").toJson(), 24 | AuthMethodProvider(name: "test2").toJson(), 25 | ], 26 | }, 27 | }; 28 | 29 | final model = AuthMethodsList.fromJson(json); 30 | 31 | expect(model.toJson(), json); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_oauth2.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_method_oauth2.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthMethodOAuth2 _$AuthMethodOAuth2FromJson(Map json) => 10 | AuthMethodOAuth2( 11 | enabled: json['enabled'] as bool? ?? false, 12 | providers: (json['providers'] as List?) 13 | ?.map( 14 | (e) => AuthMethodProvider.fromJson(e as Map)) 15 | .toList() ?? 16 | const [], 17 | ); 18 | 19 | Map _$AuthMethodOAuth2ToJson(AuthMethodOAuth2 instance) => 20 | { 21 | 'enabled': instance.enabled, 22 | 'providers': instance.providers.map((e) => e.toJson()).toList(), 23 | }; 24 | -------------------------------------------------------------------------------- /lib/src/dtos/otp_config.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "email_template_config.dart"; 6 | import "jsonable.dart"; 7 | 8 | part "otp_config.g.dart"; 9 | 10 | /// Response DTO of a single collection otp auth config. 11 | @JsonSerializable(explicitToJson: true) 12 | class OTPConfig implements Jsonable { 13 | num duration; 14 | num length; 15 | bool enabled; 16 | EmailTemplateConfig emailTemplate; 17 | 18 | OTPConfig({ 19 | this.duration = 0, 20 | this.length = 0, 21 | this.enabled = false, 22 | EmailTemplateConfig? emailTemplate, 23 | }) : emailTemplate = emailTemplate ?? EmailTemplateConfig(); 24 | 25 | static OTPConfig fromJson(Map json) => 26 | _$OTPConfigFromJson(json); 27 | 28 | @override 29 | Map toJson() => _$OTPConfigToJson(this); 30 | 31 | @override 32 | String toString() => jsonEncode(toJson()); 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/dtos/result_list.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'result_list.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | ResultList _$ResultListFromJson( 10 | Map json) => 11 | ResultList( 12 | page: (json['page'] as num?)?.toInt() ?? 0, 13 | perPage: (json['perPage'] as num?)?.toInt() ?? 0, 14 | totalItems: (json['totalItems'] as num?)?.toInt() ?? 0, 15 | totalPages: (json['totalPages'] as num?)?.toInt() ?? 0, 16 | ); 17 | 18 | Map _$ResultListToJson( 19 | ResultList instance) => 20 | { 21 | 'page': instance.page, 22 | 'perPage': instance.perPage, 23 | 'totalItems': instance.totalItems, 24 | 'totalPages': instance.totalPages, 25 | }; 26 | -------------------------------------------------------------------------------- /lib/src/dtos/otp_config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'otp_config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | OTPConfig _$OTPConfigFromJson(Map json) => OTPConfig( 10 | duration: json['duration'] as num? ?? 0, 11 | length: json['length'] as num? ?? 0, 12 | enabled: json['enabled'] as bool? ?? false, 13 | emailTemplate: json['emailTemplate'] == null 14 | ? null 15 | : EmailTemplateConfig.fromJson( 16 | json['emailTemplate'] as Map), 17 | ); 18 | 19 | Map _$OTPConfigToJson(OTPConfig instance) => { 20 | 'duration': instance.duration, 21 | 'length': instance.length, 22 | 'enabled': instance.enabled, 23 | 'emailTemplate': instance.emailTemplate.toJson(), 24 | }; 25 | -------------------------------------------------------------------------------- /lib/src/dtos/log_model.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'log_model.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | LogModel _$LogModelFromJson(Map json) => LogModel( 10 | id: json['id'] as String? ?? "", 11 | created: json['created'] as String? ?? "", 12 | updated: json['updated'] as String? ?? "", 13 | level: (json['level'] as num?)?.toInt() ?? 0, 14 | message: json['message'] as String? ?? "", 15 | data: json['data'] as Map? ?? const {}, 16 | ); 17 | 18 | Map _$LogModelToJson(LogModel instance) => { 19 | 'id': instance.id, 20 | 'created': instance.created, 21 | 'updated': instance.updated, 22 | 'level': instance.level, 23 | 'message': instance.message, 24 | 'data': instance.data, 25 | }; 26 | -------------------------------------------------------------------------------- /test/dtos/result_list_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("ResultList", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "page": 2, 9 | "perPage": 20, 10 | "totalItems": 200, 11 | "totalPages": 10, 12 | "items": [ 13 | { 14 | "id": "test_id", 15 | "created": "test_created", 16 | "updated": "test_updated", 17 | "collectionId": "test_collectionId", 18 | "collectionName": "test_collectionName", 19 | "expand": { 20 | "test": RecordModel({"id": "1"}).toJson(), 21 | }, 22 | "a": 1, 23 | "b": "test", 24 | "c": true, 25 | } 26 | ], 27 | }; 28 | 29 | final model = ResultList.fromJson( 30 | json, 31 | RecordModel.fromJson, 32 | ); 33 | 34 | expect(model.toJson(), json); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: unnecessary_lambdas 2 | 3 | import "dart:async"; 4 | 5 | import "package:pocketbase/pocketbase.dart"; 6 | 7 | void main() { 8 | final pb = PocketBase("http://127.0.0.1:8090"); 9 | 10 | // fetch a paginated list with "example" records 11 | pb.collection("example").getList(page: 1, perPage: 10).then((result) { 12 | // success... 13 | print("Result: $result"); 14 | }).catchError((dynamic error) { 15 | // error... 16 | print("Error: $error"); 17 | }); 18 | 19 | // listen to realtime connect/reconnect events 20 | pb.realtime.subscribe("PB_CONNECT", (e) { 21 | print("Connected: $e"); 22 | }); 23 | 24 | // subscribe to realtime changes in the "example" collection 25 | pb.collection("example").subscribe("*", (e) { 26 | print(e.action); // create, update, delete 27 | print(e.record); // the changed record 28 | }); 29 | 30 | // unsubsribe from all "example" realtime subscriptions after 10 seconds 31 | Timer(const Duration(seconds: 10), () { 32 | pb.realtime.unsubscribe(); // unsubscribe from all realtime events 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/client_exception.dart: -------------------------------------------------------------------------------- 1 | /// An exception caused by an error in the PocketBase client. 2 | class ClientException implements Exception { 3 | /// The [Uri] of the failed request. 4 | final Uri? url; 5 | 6 | /// Indicates whether the error is a result from request cancellation/abort. 7 | final bool isAbort; 8 | 9 | /// The status code of the failed request. 10 | final int statusCode; 11 | 12 | /// Contains the JSON API error response. 13 | final Map response; 14 | 15 | /// The original response error (could be anything - String, Exception, etc.). 16 | final dynamic originalError; 17 | 18 | ClientException({ 19 | this.url, 20 | this.isAbort = false, 21 | this.statusCode = 0, 22 | this.response = const {}, 23 | this.originalError, 24 | }); 25 | 26 | @override 27 | String toString() { 28 | final errorData = { 29 | "url": url, 30 | "isAbort": isAbort, 31 | "statusCode": statusCode, 32 | "response": response, 33 | "originalError": originalError, 34 | }; 35 | 36 | return "ClientException: $errorData"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2022 - present, Gani Georgiev 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software 5 | and associated documentation files (the "Software"), to deal in the Software without restriction, 6 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 7 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or 11 | substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING 14 | BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 15 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 16 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /test/services/health_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("HealthService", () { 10 | test("check()", () async { 11 | final mock = MockClient((request) async { 12 | expect(request.method, "GET"); 13 | expect( 14 | request.url.toString(), 15 | "/base/api/health?a=1&a=2&b=%40demo", 16 | ); 17 | expect(request.headers["test"], "789"); 18 | 19 | return http.Response(jsonEncode({"code": 200, "message": "test"}), 200); 20 | }); 21 | 22 | final client = PocketBase("/base", httpClientFactory: () => mock); 23 | 24 | final result = await client.health.check( 25 | query: { 26 | "a": ["1", null, 2], 27 | "b": "@demo", 28 | }, 29 | headers: { 30 | "test": "789", 31 | }, 32 | ); 33 | 34 | expect(result, isA()); 35 | expect(result.code, 200); 36 | expect(result.message, "test"); 37 | }); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /test/sse/sse_message_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/src/sse/sse_message.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("SseMessage", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "id": "test_id", 9 | "event": "test_event", 10 | "data": "test_data", 11 | "retry": 123, 12 | }; 13 | 14 | final model = SseMessage.fromJson(json); 15 | 16 | expect(model.toJson(), json); 17 | }); 18 | 19 | test("jsonData() with serialized data object", () { 20 | final model = SseMessage(data: '{"a": 123}'); 21 | expect(model.jsonData(), {"a": 123}); 22 | }); 23 | 24 | test("jsonData() with serialized data array", () { 25 | final model = SseMessage(data: "[1, 2, 3]"); 26 | expect(model.jsonData(), { 27 | "data": [1, 2, 3] 28 | }); 29 | }); 30 | 31 | test("jsonData() with non-json data string", () { 32 | final model = SseMessage(data: "test"); 33 | expect(model.jsonData(), {"data": "test"}); 34 | }); 35 | 36 | test("jsonData() with empty data", () { 37 | final model = SseMessage(); 38 | expect(model.jsonData(), {}); 39 | }); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_methods_list.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "auth_method_mfa.dart"; 6 | import "auth_method_oauth2.dart"; 7 | import "auth_method_otp.dart"; 8 | import "auth_method_password.dart"; 9 | import "jsonable.dart"; 10 | 11 | part "auth_methods_list.g.dart"; 12 | 13 | /// Response DTO of the allowed authentication methods. 14 | @JsonSerializable(explicitToJson: true) 15 | class AuthMethodsList implements Jsonable { 16 | AuthMethodMFA mfa; 17 | AuthMethodOTP otp; 18 | AuthMethodPassword password; 19 | AuthMethodOAuth2 oauth2; 20 | 21 | AuthMethodsList({ 22 | AuthMethodMFA? mfa, 23 | AuthMethodOTP? otp, 24 | AuthMethodPassword? password, 25 | AuthMethodOAuth2? oauth2, 26 | }) : mfa = mfa ?? AuthMethodMFA(), 27 | otp = otp ?? AuthMethodOTP(), 28 | password = password ?? AuthMethodPassword(), 29 | oauth2 = oauth2 ?? AuthMethodOAuth2(); 30 | 31 | static AuthMethodsList fromJson(Map json) => 32 | _$AuthMethodsListFromJson(json); 33 | 34 | @override 35 | Map toJson() => _$AuthMethodsListToJson(this); 36 | 37 | @override 38 | String toString() => jsonEncode(toJson()); 39 | } 40 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_methods_list.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_methods_list.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthMethodsList _$AuthMethodsListFromJson(Map json) => 10 | AuthMethodsList( 11 | mfa: json['mfa'] == null 12 | ? null 13 | : AuthMethodMFA.fromJson(json['mfa'] as Map), 14 | otp: json['otp'] == null 15 | ? null 16 | : AuthMethodOTP.fromJson(json['otp'] as Map), 17 | password: json['password'] == null 18 | ? null 19 | : AuthMethodPassword.fromJson( 20 | json['password'] as Map), 21 | oauth2: json['oauth2'] == null 22 | ? null 23 | : AuthMethodOAuth2.fromJson(json['oauth2'] as Map), 24 | ); 25 | 26 | Map _$AuthMethodsListToJson(AuthMethodsList instance) => 27 | { 28 | 'mfa': instance.mfa.toJson(), 29 | 'otp': instance.otp.toJson(), 30 | 'password': instance.password.toJson(), 31 | 'oauth2': instance.oauth2.toJson(), 32 | }; 33 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_provider.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "auth_method_provider.g.dart"; 8 | 9 | /// Response DTO of a single OAuth2 provider. 10 | @JsonSerializable(explicitToJson: true) 11 | class AuthMethodProvider implements Jsonable { 12 | String name; 13 | String displayName; 14 | String state; 15 | String codeVerifier; 16 | String codeChallenge; 17 | String codeChallengeMethod; 18 | String authURL; 19 | bool? pkce; 20 | 21 | @Deprecated("use authURL") 22 | @JsonKey(includeToJson: false, includeFromJson: false) 23 | String get authUrl => authURL; 24 | 25 | @Deprecated("use authURL") 26 | set authUrl(String url) => authURL = url; 27 | 28 | AuthMethodProvider({ 29 | this.name = "", 30 | this.displayName = "", 31 | this.state = "", 32 | this.codeVerifier = "", 33 | this.codeChallenge = "", 34 | this.codeChallengeMethod = "", 35 | this.authURL = "", 36 | this.pkce, 37 | }); 38 | 39 | static AuthMethodProvider fromJson(Map json) => 40 | _$AuthMethodProviderFromJson(json); 41 | 42 | @override 43 | Map toJson() => _$AuthMethodProviderToJson(this); 44 | 45 | @override 46 | String toString() => jsonEncode(toJson()); 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/dtos/auth_method_provider.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_method_provider.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AuthMethodProvider _$AuthMethodProviderFromJson(Map json) => 10 | AuthMethodProvider( 11 | name: json['name'] as String? ?? "", 12 | displayName: json['displayName'] as String? ?? "", 13 | state: json['state'] as String? ?? "", 14 | codeVerifier: json['codeVerifier'] as String? ?? "", 15 | codeChallenge: json['codeChallenge'] as String? ?? "", 16 | codeChallengeMethod: json['codeChallengeMethod'] as String? ?? "", 17 | authURL: json['authURL'] as String? ?? "", 18 | pkce: json['pkce'] as bool?, 19 | ); 20 | 21 | Map _$AuthMethodProviderToJson(AuthMethodProvider instance) => 22 | { 23 | 'name': instance.name, 24 | 'displayName': instance.displayName, 25 | 'state': instance.state, 26 | 'codeVerifier': instance.codeVerifier, 27 | 'codeChallenge': instance.codeChallenge, 28 | 'codeChallengeMethod': instance.codeChallengeMethod, 29 | 'authURL': instance.authURL, 30 | 'pkce': instance.pkce, 31 | }; 32 | -------------------------------------------------------------------------------- /lib/src/services/cron_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | import "../dtos/cron_job.dart"; 3 | import "base_service.dart"; 4 | 5 | /// The service that handles the **Cron APIs**. 6 | /// 7 | /// Usually shouldn't be initialized manually and instead 8 | /// [PocketBase.backups] should be used. 9 | class CronService extends BaseService { 10 | CronService(super.client); 11 | 12 | /// Returns list with all registered app cron jobs. 13 | Future> getFullList({ 14 | Map query = const {}, 15 | Map headers = const {}, 16 | }) { 17 | return client 18 | .send>( 19 | "/api/crons", 20 | query: query, 21 | headers: headers, 22 | ) 23 | .then((data) => data 24 | .map( 25 | (item) => CronJob.fromJson(item as Map? ?? {})) 26 | .toList()); 27 | } 28 | 29 | /// Runs the specified cron job. 30 | Future run( 31 | String jobId, { 32 | Map body = const {}, 33 | Map query = const {}, 34 | Map headers = const {}, 35 | }) { 36 | return client.send( 37 | "/api/crons/${Uri.encodeComponent(jobId)}", 38 | method: "POST", 39 | body: body, 40 | query: query, 41 | headers: headers, 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/sync_queue.dart: -------------------------------------------------------------------------------- 1 | typedef AsyncOperation = Future Function(); 2 | 3 | /// SyncQueue is a very rudimentary queue of async operations 4 | /// that will be processed sequential/synchronous. 5 | class SyncQueue { 6 | SyncQueue({this.onComplete}); 7 | 8 | /// Callback function that is triggered after all of the async 9 | /// operations are processed. 10 | late final void Function()? onComplete; 11 | 12 | // List of async operations. 13 | final List _operations = []; 14 | 15 | /// Enqueue appends an async operation to the queue and 16 | /// execute it it is the only one. 17 | void enqueue(AsyncOperation op) { 18 | _operations.add(op); 19 | 20 | if (_operations.length == 1) { 21 | // start processing 22 | dequeue(); 23 | } 24 | } 25 | 26 | /// Dequeue starts the queue processing. 27 | /// 28 | /// Each processed operation is removed from the queue once the 29 | /// it completes. 30 | void dequeue() { 31 | if (_operations.isEmpty) { 32 | return; 33 | } 34 | 35 | _operations.first().whenComplete(() { 36 | _operations.removeAt(0); 37 | 38 | if (_operations.isEmpty) { 39 | onComplete?.call(); 40 | 41 | return; // no more operations 42 | } 43 | 44 | // proceed with the next operation from the queue 45 | dequeue(); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/src/sse/sse_message.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "../dtos/jsonable.dart"; 6 | 7 | part "sse_message.g.dart"; 8 | 9 | /// A generic event stream message model. 10 | @JsonSerializable(explicitToJson: true) 11 | class SseMessage implements Jsonable { 12 | /// String identifier representing the last event ID value. 13 | String id; 14 | 15 | /// The name/type of the event message. 16 | String event; 17 | 18 | /// The raw data of the event message. 19 | String data; 20 | 21 | /// The reconnection time (in milliseconds). 22 | int retry; 23 | 24 | SseMessage({ 25 | this.id = "", 26 | this.event = "message", 27 | this.data = "", 28 | this.retry = 0, 29 | }); 30 | 31 | /// Decodes the event message data as json map. 32 | Map jsonData() { 33 | if (data.isNotEmpty) { 34 | try { 35 | final decoded = jsonDecode(data); 36 | return decoded is Map ? decoded : {"data": decoded}; 37 | } catch (_) {} 38 | 39 | // normalize and wrap as object 40 | return {"data": data}; 41 | } 42 | 43 | return {}; 44 | } 45 | 46 | static SseMessage fromJson(Map json) => 47 | _$SseMessageFromJson(json); 48 | 49 | @override 50 | Map toJson() => _$SseMessageToJson(this); 51 | 52 | @override 53 | String toString() => jsonEncode(toJson()); 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/dtos/result_list.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "jsonable.dart"; 6 | 7 | part "result_list.g.dart"; 8 | 9 | /// The factory function (eg. `fromJson()`) that will be used for a 10 | /// single item in a paginated list. 11 | typedef ListItemFactory = M Function( 12 | Map item, 13 | ); 14 | 15 | /// Response DTO of a generic paginated list. 16 | @JsonSerializable(explicitToJson: true) 17 | class ResultList implements Jsonable { 18 | int page; 19 | int perPage; 20 | int totalItems; 21 | int totalPages; 22 | 23 | @JsonKey(includeToJson: false, includeFromJson: false) // manually serialized 24 | List items; 25 | 26 | ResultList({ 27 | this.page = 0, 28 | this.perPage = 0, 29 | this.totalItems = 0, 30 | this.totalPages = 0, 31 | this.items = const [], 32 | }); 33 | 34 | factory ResultList.fromJson( 35 | Map json, 36 | ListItemFactory itemFactoryFunc, 37 | ) { 38 | final result = _$ResultListFromJson(json) 39 | ..items = (json["items"] as List?) 40 | ?.map((item) => itemFactoryFunc(item as Map)) 41 | .toList() 42 | .cast() ?? 43 | const []; 44 | 45 | return result; 46 | } 47 | 48 | @override 49 | Map toJson() { 50 | final json = _$ResultListToJson(this); 51 | 52 | json["items"] = items.map((item) => item.toJson()).toList(); 53 | 54 | return json; 55 | } 56 | 57 | @override 58 | String toString() => jsonEncode(toJson()); 59 | } 60 | -------------------------------------------------------------------------------- /test/sse/sse_client_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/src/sse/sse_client.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("SseClient", () { 10 | test("initialize and stream SseMessage objects", () { 11 | final mock2 = MockClient.streaming( 12 | (http.BaseRequest request, http.ByteStream bodyStream) async { 13 | final stream = Stream.fromIterable([ 14 | "id:test_id1\n", 15 | "event:test_event1\n", 16 | 'data:{"a":123}\n', 17 | "random line that should be ignored\n", 18 | "retry:100\n", 19 | "\n", 20 | "id:test_id2\n", 21 | "event:test_event2\n", 22 | "data:none_object\n", 23 | "another random line that should be ignored\n", 24 | "\n", 25 | ]).transform(utf8.encoder); 26 | 27 | return http.StreamedResponse(stream, 200); 28 | }, 29 | ); 30 | 31 | final client = SseClient("/base", httpClientFactory: () => mock2); 32 | var count = 0; 33 | client.onMessage.listen(expectAsync1((a) { 34 | count++; 35 | if (count == 1) { 36 | expect(a.id, "test_id1"); 37 | expect(a.event, "test_event1"); 38 | expect(a.data, '{"a":123}'); 39 | expect(a.retry, 100); 40 | expect(a.jsonData(), equals({"a": 123})); 41 | } else if (count == 2) { 42 | expect(a.id, "test_id2"); 43 | expect(a.event, "test_event2"); 44 | expect(a.data, "none_object"); 45 | expect(a.retry, 0); 46 | expect(a.jsonData(), equals({"data": "none_object"})); 47 | } 48 | }, count: 2)); 49 | }); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /lib/pocketbase.dart: -------------------------------------------------------------------------------- 1 | /// Multi-platform [Future]-based library for interacting with the 2 | /// [PocketBase API](https://pocketbase.io/docs/). 3 | library; 4 | 5 | // main 6 | export "src/async_auth_store.dart"; 7 | export "src/auth_store.dart"; 8 | export "src/client.dart"; 9 | export "src/client_exception.dart"; 10 | 11 | // dtos 12 | export "src/dtos/apple_client_secret.dart"; 13 | export "src/dtos/auth_alert_config.dart"; 14 | export "src/dtos/auth_method_mfa.dart"; 15 | export "src/dtos/auth_method_oauth2.dart"; 16 | export "src/dtos/auth_method_otp.dart"; 17 | export "src/dtos/auth_method_password.dart"; 18 | export "src/dtos/auth_method_provider.dart"; 19 | export "src/dtos/auth_methods_list.dart"; 20 | export "src/dtos/backup_file_info.dart"; 21 | export "src/dtos/batch_result.dart"; 22 | export "src/dtos/collection_field.dart"; 23 | export "src/dtos/collection_model.dart"; 24 | export "src/dtos/cron_job.dart"; 25 | export "src/dtos/email_template_config.dart"; 26 | export "src/dtos/health_check.dart"; 27 | export "src/dtos/jsonable.dart"; 28 | export "src/dtos/log_model.dart"; 29 | export "src/dtos/log_stat.dart"; 30 | export "src/dtos/mfa_config.dart"; 31 | export "src/dtos/oauth2_config.dart"; 32 | export "src/dtos/otp_config.dart"; 33 | export "src/dtos/otp_response.dart"; 34 | export "src/dtos/password_auth_config.dart"; 35 | export "src/dtos/record_auth.dart"; 36 | export "src/dtos/record_model.dart"; 37 | export "src/dtos/record_subscription_event.dart"; 38 | export "src/dtos/result_list.dart"; 39 | export "src/dtos/token_config.dart"; 40 | 41 | // services (exported mainly for dartdoc - https://github.com/dart-lang/dartdoc/issues/2154) 42 | export "src/services/backup_service.dart"; 43 | export "src/services/base_crud_service.dart"; 44 | export "src/services/batch_service.dart"; 45 | export "src/services/collection_service.dart"; 46 | export "src/services/cron_service.dart"; 47 | export "src/services/file_service.dart"; 48 | export "src/services/health_service.dart"; 49 | export "src/services/log_service.dart"; 50 | export "src/services/realtime_service.dart"; 51 | export "src/services/record_service.dart"; 52 | export "src/services/settings_service.dart"; 53 | -------------------------------------------------------------------------------- /test/services/cron_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("CronService", () { 10 | test("getFullList()", () async { 11 | final expectedResult = [CronJob(id: "k1"), CronJob(id: "k2")]; 12 | 13 | final mock = MockClient((request) async { 14 | expect(request.method, "GET"); 15 | expect( 16 | request.url.toString(), 17 | "/base/api/crons?a=1&a=2&b=%40demo", 18 | ); 19 | expect(request.headers["test"], "789"); 20 | 21 | return http.Response(jsonEncode(expectedResult), 200); 22 | }); 23 | 24 | final client = PocketBase("/base", httpClientFactory: () => mock); 25 | 26 | final result = await client.crons.getFullList( 27 | query: { 28 | "a": ["1", null, 2], 29 | "b": "@demo", 30 | }, 31 | headers: { 32 | "test": "789", 33 | }, 34 | ); 35 | 36 | expect(result, isA>()); 37 | expect(result.length, expectedResult.length); 38 | expect(result[0].id, expectedResult[0].id); 39 | expect(result[1].id, expectedResult[1].id); 40 | }); 41 | 42 | test("run()", () async { 43 | final mock = MockClient((request) async { 44 | expect(request.method, "POST"); 45 | expect(request.body, jsonEncode({"test_body": 123})); 46 | expect( 47 | request.url.toString(), 48 | "/base/api/crons/%40test_id?a=1&a=2&b=%40demo", 49 | ); 50 | expect(request.headers["test"], "789"); 51 | 52 | return http.Response("", 204); 53 | }); 54 | 55 | final client = PocketBase("/base", httpClientFactory: () => mock); 56 | 57 | await client.crons.run( 58 | "@test_id", 59 | query: { 60 | "a": ["1", null, 2], 61 | "b": "@demo", 62 | }, 63 | body: { 64 | "test_body": 123, 65 | }, 66 | headers: { 67 | "test": "789", 68 | }, 69 | ); 70 | }); 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /test/services/file_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("FileService", () { 10 | test("blank uri on missing filename", () { 11 | final client = PocketBase("/base/"); 12 | final result = client.files.getURL( 13 | RecordModel({"id": "@r123", "collectionId": "@c123"}), 14 | "", 15 | query: { 16 | "demo": [1, null, "@test"], 17 | }, 18 | ); 19 | 20 | expect(result.toString(), ""); 21 | }); 22 | 23 | test("retrieve encoded record file url", () { 24 | final client = PocketBase("/base/"); 25 | final result = client.files.getURL( 26 | RecordModel({"id": "@r123", "collectionId": "@c123"}), 27 | "@f123.png", 28 | query: { 29 | "demo": [1, null, "@test"], 30 | }, 31 | ); 32 | 33 | expect( 34 | result.toString(), 35 | "/base/api/files/%40c123/%40r123/%40f123.png?demo=1&demo=%40test", 36 | ); 37 | }); 38 | 39 | test("getToken()", () async { 40 | final mock = MockClient((request) async { 41 | expect(request.method, "POST"); 42 | expect( 43 | request.body, 44 | jsonEncode({ 45 | "test_body": 123, 46 | })); 47 | expect( 48 | request.url.toString(), 49 | "/base/api/files/token?a=1&a=2&b=%40demo", 50 | ); 51 | expect(request.headers["test"], "789"); 52 | 53 | return http.Response( 54 | jsonEncode({"token": "456"}), 55 | 200, 56 | ); 57 | }); 58 | 59 | final client = PocketBase("/base", httpClientFactory: () => mock); 60 | 61 | final result = await client.files.getToken( 62 | query: { 63 | "a": ["1", null, 2], 64 | "b": "@demo", 65 | }, 66 | body: { 67 | "test_body": 123, 68 | }, 69 | headers: { 70 | "test": "789", 71 | }, 72 | ); 73 | 74 | expect(result, "456"); 75 | }); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/services/file_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | import "../dtos/record_model.dart"; 3 | import "base_service.dart"; 4 | 5 | /// The service that handles the **File APIs**. 6 | /// 7 | /// Usually shouldn't be initialized manually and instead 8 | /// [PocketBase.files] should be used. 9 | class FileService extends BaseService { 10 | FileService(super.client); 11 | 12 | // Legacy alias of getURL(). 13 | Uri getUrl( 14 | RecordModel record, 15 | String filename, { 16 | String? thumb, 17 | String? token, 18 | bool? download, 19 | Map query = const {}, 20 | }) { 21 | return getURL( 22 | record, 23 | filename, 24 | thumb: thumb, 25 | token: token, 26 | download: download, 27 | query: query, 28 | ); 29 | } 30 | 31 | /// Builds and returns an absolute record file url. 32 | Uri getURL( 33 | RecordModel record, 34 | String filename, { 35 | String? thumb, 36 | String? token, 37 | bool? download, 38 | Map query = const {}, 39 | }) { 40 | if (filename.isEmpty || record.id.isEmpty) { 41 | return Uri(); // blank Uri 42 | } 43 | 44 | final params = Map.of(query); 45 | params["thumb"] ??= thumb; 46 | params["token"] ??= token; 47 | if (download != null && download) { 48 | params["download"] = ""; 49 | } 50 | 51 | final collectionIdOrName = record.collectionId.isEmpty 52 | ? record.collectionName 53 | : record.collectionId; 54 | 55 | return client.buildURL( 56 | "/api/files/${Uri.encodeComponent(collectionIdOrName)}/${Uri.encodeComponent(record.id)}/${Uri.encodeComponent(filename)}", 57 | params, 58 | ); 59 | } 60 | 61 | /// Requests a new private file access token for the current auth model. 62 | Future getToken({ 63 | Map body = const {}, 64 | Map query = const {}, 65 | Map headers = const {}, 66 | }) { 67 | return client 68 | .send>( 69 | "/api/files/token", 70 | method: "POST", 71 | body: body, 72 | query: query, 73 | headers: headers, 74 | ) 75 | .then((data) => data["token"] as String); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/dtos/collection_field.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "../caster.dart" as caster; 6 | import "jsonable.dart"; 7 | 8 | /// Response DTO of a single collection schema field. 9 | @JsonSerializable(explicitToJson: true) 10 | class CollectionField implements Jsonable { 11 | String get id => get("id", ""); 12 | set id(String val) => data["id"] = val; 13 | 14 | String get name => get("name", ""); 15 | set name(String val) => data["name"] = val; 16 | 17 | String get type => get("type", ""); 18 | set type(String val) => data["type"] = val; 19 | 20 | bool get system => get("system", false); 21 | set system(bool val) => data["system"] = val; 22 | 23 | bool get required => get("required", false); 24 | set required(bool val) => data["required"] = val; 25 | 26 | bool get presentable => get("presentable", false); 27 | set presentable(bool val) => data["presentable"] = val; 28 | 29 | bool get hidden => get("hidden", false); 30 | set hidden(bool val) => data["hidden"] = val; 31 | 32 | Map data = {}; 33 | 34 | CollectionField([Map? data]) : data = data ?? {}; 35 | 36 | static CollectionField fromJson(Map json) => 37 | CollectionField(json); 38 | 39 | @override 40 | Map toJson() => Map.from(data); 41 | 42 | @override 43 | String toString() => jsonEncode(data); 44 | 45 | /// Extracts a single model value by a dot-notation path 46 | /// and tries to cast it to the specified generic type. 47 | /// 48 | /// If explicitly set, returns [defaultValue] on missing path. 49 | /// 50 | /// For more details about the casting rules, please refer to 51 | /// [caster.extract()]. 52 | /// 53 | /// Example: 54 | /// 55 | /// ```dart 56 | /// final data = {"a": {"b": [{"b1": 1}, {"b2": 2}, {"b3": 3}]}}; 57 | /// final field = CollectionField(data); 58 | /// final result0 = field.get("a.b.c", 999); // 999 59 | /// final result1 = field.get("a.b.2.b3"); // 3 60 | /// final result2 = field.get("a.b.2.b3"); // "3" 61 | /// ``` 62 | T get(String fieldNameOrPath, [T? defaultValue]) { 63 | return caster.extract(data, fieldNameOrPath, defaultValue); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/src/auth_store.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | import "dart:convert"; 3 | 4 | import "./dtos/record_model.dart"; 5 | 6 | /// Event object that holds an AuthStore state. 7 | class AuthStoreEvent { 8 | AuthStoreEvent(this.token, this.record); 9 | 10 | final String token; 11 | final RecordModel? record; 12 | 13 | @Deprecated("use record") 14 | dynamic get model => record as dynamic; 15 | 16 | @override 17 | String toString() => "token: $token\nrecord: $record"; 18 | } 19 | 20 | /// Base authentication store management service that keep tracks of 21 | /// the authenticated User/Admin model and its token. 22 | class AuthStore { 23 | final _onChangeController = StreamController.broadcast(); 24 | 25 | String _token = ""; 26 | RecordModel? _record; 27 | 28 | /// Returns the saved auth token (if any). 29 | String get token => _token; 30 | 31 | /// Returns the saved auth record (if any). 32 | RecordModel? get record => _record; 33 | 34 | @Deprecated("use record") 35 | dynamic get model => record as dynamic; 36 | 37 | /// Stream that gets triggered on each auth store change 38 | /// (aka. on [save()] and [clear()] call). 39 | Stream get onChange => _onChangeController.stream; 40 | 41 | /// Loosely checks if the current AuthStore has valid auth data 42 | /// (eg. whether the token is expired or not). 43 | bool get isValid { 44 | final parts = token.split("."); 45 | if (parts.length != 3) { 46 | return false; 47 | } 48 | 49 | final tokenPart = base64.normalize(parts[1]); 50 | final data = jsonDecode(utf8.decode(base64Decode(tokenPart))) 51 | as Map; 52 | 53 | final exp = data["exp"] is int 54 | ? data["exp"] as int 55 | : (int.tryParse(data["exp"].toString()) ?? 0); 56 | 57 | return exp > (DateTime.now().millisecondsSinceEpoch / 1000); 58 | } 59 | 60 | /// Saves the provided [newToken] and [newRecord] auth data into the store. 61 | void save(String newToken, RecordModel? newRecord) { 62 | _token = newToken; 63 | _record = newRecord; 64 | 65 | _onChangeController.add(AuthStoreEvent(token, newRecord)); 66 | } 67 | 68 | /// Clears the previously stored [token] and [record] auth data. 69 | void clear() { 70 | _token = ""; 71 | _record = null; 72 | 73 | _onChangeController.add(AuthStoreEvent(token, record)); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/dtos/collection_model_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/pocketbase.dart"; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("CollectionModel", () { 6 | test("fromJson() and toJson()", () { 7 | final json = { 8 | "id": "test_id", 9 | "type": "test_type", 10 | "created": "test_created", 11 | "updated": "test_updated", 12 | "name": "test_name", 13 | "fields": [ 14 | { 15 | "id": "fields_id", 16 | "name": "fields_name", 17 | "type": "fields_type", 18 | "system": true, 19 | "required": false, 20 | "presentable": false, 21 | "options": {"a": 123}, 22 | }, 23 | ], 24 | "system": true, 25 | "listRule": "test_listRule", 26 | "viewRule": null, 27 | "createRule": "test_createRule", 28 | "updateRule": "", 29 | "deleteRule": "test_deleteRule", 30 | "indexes": ["a", "b"], 31 | "viewQuery": "test_viewQuery", 32 | "authRule": null, 33 | "manageRule": null, 34 | "authAlert": { 35 | "enabled": true, 36 | "emailTemplate": {"subject": "a", "body": "b"}, 37 | }, 38 | "oauth2": { 39 | "enabled": true, 40 | "mappedFields": {"a": "b"}, 41 | "providers": [ 42 | {"name": "a"} 43 | ] 44 | }, 45 | "passwordAuth": { 46 | "enabled": true, 47 | "identityFields": ["a", "b"], 48 | }, 49 | "mfa": { 50 | "duration": 100, 51 | "enabled": false, 52 | "rule": "abc", 53 | }, 54 | "otp": { 55 | "duration": 100, 56 | "enabled": true, 57 | "length": 10, 58 | "emailTemplate": {"subject": "a", "body": "b"}, 59 | }, 60 | "authToken": {"duration": 100, "secret": "test"}, 61 | "passwordResetToken": {"duration": 100, "secret": "test"}, 62 | "emailChangeToken": {"duration": 100, "secret": "test"}, 63 | "verificationToken": {"duration": 100, "secret": "test"}, 64 | "fileToken": {"duration": 100, "secret": "test"}, 65 | "verificationTemplate": {"subject": "a", "body": "b"}, 66 | "resetPasswordTemplate": {"subject": "a", "body": "b"}, 67 | "confirmEmailChangeTemplate": {"subject": "a", "body": "b"}, 68 | }; 69 | 70 | final model = CollectionModel.fromJson(json); 71 | 72 | expect(model.toJson(), json); 73 | }); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /lib/src/services/log_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | import "../client_exception.dart"; 3 | import "../dtos/log_model.dart"; 4 | import "../dtos/log_stat.dart"; 5 | import "../dtos/result_list.dart"; 6 | import "base_service.dart"; 7 | 8 | /// The service that handles the **Log APIs**. 9 | /// 10 | /// Usually shouldn't be initialized manually and instead 11 | /// [PocketBase.logs] should be used. 12 | class LogService extends BaseService { 13 | LogService(super.client); 14 | 15 | /// Returns paginated logs list. 16 | Future> getList({ 17 | int page = 1, 18 | int perPage = 30, 19 | String? filter, 20 | String? sort, 21 | Map query = const {}, 22 | Map headers = const {}, 23 | }) { 24 | final params = Map.of(query); 25 | params["page"] = page; 26 | params["perPage"] = perPage; 27 | params["filter"] ??= filter; 28 | params["sort"] ??= sort; 29 | 30 | return client 31 | .send>( 32 | "/api/logs", 33 | query: params, 34 | headers: headers, 35 | ) 36 | .then((data) => ResultList.fromJson(data, LogModel.fromJson)); 37 | } 38 | 39 | /// Returns a single log by its id. 40 | Future getOne( 41 | String id, { 42 | Map query = const {}, 43 | Map headers = const {}, 44 | }) async { 45 | if (id.isEmpty) { 46 | throw ClientException( 47 | url: client.buildURL("/api/logs/"), 48 | statusCode: 404, 49 | response: { 50 | "code": 404, 51 | "message": "Missing required log id.", 52 | "data": {}, 53 | }, 54 | ); 55 | } 56 | 57 | return client 58 | .send>( 59 | "/api/logs/${Uri.encodeComponent(id)}", 60 | query: query, 61 | headers: headers, 62 | ) 63 | .then(LogModel.fromJson); 64 | } 65 | 66 | /// Returns request logs statistics. 67 | Future> getStats({ 68 | Map query = const {}, 69 | Map headers = const {}, 70 | }) { 71 | return client 72 | .send>( 73 | "/api/logs/stats", 74 | query: query, 75 | headers: headers, 76 | ) 77 | .then((data) => data 78 | .map( 79 | (item) => LogStat.fromJson(item as Map? ?? {}), 80 | ) 81 | .toList()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/dtos/collection_model.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:json_annotation/json_annotation.dart"; 4 | 5 | import "auth_alert_config.dart"; 6 | import "collection_field.dart"; 7 | import "email_template_config.dart"; 8 | import "jsonable.dart"; 9 | import "mfa_config.dart"; 10 | import "oauth2_config.dart"; 11 | import "otp_config.dart"; 12 | import "password_auth_config.dart"; 13 | import "token_config.dart"; 14 | 15 | part "collection_model.g.dart"; 16 | 17 | /// Response DTO of a single collection model. 18 | @JsonSerializable(explicitToJson: true) 19 | class CollectionModel implements Jsonable { 20 | String id; 21 | String type; 22 | String created; 23 | String updated; 24 | String name; 25 | bool system; 26 | String? listRule; 27 | String? viewRule; 28 | String? createRule; 29 | String? updateRule; 30 | String? deleteRule; 31 | List fields; 32 | List indexes; 33 | 34 | // view fields 35 | // --- 36 | String? viewQuery; 37 | 38 | // auth fields 39 | // --- 40 | String? authRule; 41 | String? manageRule; 42 | AuthAlertConfig? authAlert; 43 | OAuth2Config? oauth2; 44 | PasswordAuthConfig? passwordAuth; 45 | MFAConfig? mfa; 46 | OTPConfig? otp; 47 | TokenConfig? authToken; 48 | TokenConfig? passwordResetToken; 49 | TokenConfig? emailChangeToken; 50 | TokenConfig? verificationToken; 51 | TokenConfig? fileToken; 52 | EmailTemplateConfig? verificationTemplate; 53 | EmailTemplateConfig? resetPasswordTemplate; 54 | EmailTemplateConfig? confirmEmailChangeTemplate; 55 | 56 | CollectionModel({ 57 | this.id = "", 58 | this.type = "base", 59 | this.created = "", 60 | this.updated = "", 61 | this.name = "", 62 | this.system = false, 63 | this.listRule, 64 | this.viewRule, 65 | this.createRule, 66 | this.updateRule, 67 | this.deleteRule, 68 | this.fields = const [], 69 | this.indexes = const [], 70 | this.viewQuery, 71 | this.authRule, 72 | this.manageRule, 73 | this.authAlert, 74 | this.oauth2, 75 | this.passwordAuth, 76 | this.mfa, 77 | this.otp, 78 | this.authToken, 79 | this.passwordResetToken, 80 | this.emailChangeToken, 81 | this.verificationToken, 82 | this.fileToken, 83 | this.verificationTemplate, 84 | this.resetPasswordTemplate, 85 | this.confirmEmailChangeTemplate, 86 | }); 87 | 88 | static CollectionModel fromJson(Map json) => 89 | _$CollectionModelFromJson(json); 90 | 91 | @override 92 | Map toJson() => _$CollectionModelToJson(this); 93 | 94 | @override 95 | String toString() => jsonEncode(toJson()); 96 | } 97 | -------------------------------------------------------------------------------- /test/auth_store_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: lines_longer_than_80_chars 2 | 3 | import "package:pocketbase/pocketbase.dart"; 4 | import "package:test/test.dart"; 5 | 6 | void main() { 7 | group("AuthStore.token and AuthStore.model", () { 8 | test("read getters", () { 9 | final store = AuthStore(); 10 | 11 | expect(store.token, isEmpty); 12 | expect(store.record, isNull); 13 | 14 | store.save("test_token", RecordModel({"id": "test"})); 15 | 16 | expect(store.token, "test_token"); 17 | expect(store.record?.id, "test"); 18 | }); 19 | }); 20 | 21 | group("AuthStore.isValid", () { 22 | test("with empty token", () { 23 | final store = AuthStore(); 24 | 25 | expect(store.isValid, isFalse); 26 | }); 27 | 28 | test("with invalid JWT token", () { 29 | final store = AuthStore()..save("invalid", null); 30 | 31 | expect(store.isValid, isFalse); 32 | }); 33 | 34 | test("with expired JWT token", () { 35 | const token = 36 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NDA5OTE2NjF9.TxZjXz_Ks665Hju0FkZSGqHFCYBbgBmMGOLnIzkg9Dg"; 37 | final store = AuthStore()..save(token, null); 38 | 39 | expect(store.isValid, isFalse); 40 | }); 41 | 42 | test("with valid JWT token", () { 43 | const token = 44 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4OTM0NTI0NjF9.yVr-4JxMz6qUf1MIlGx8iW2ktUrQaFecjY_TMm7Bo4o"; 45 | final store = AuthStore()..save(token, null); 46 | 47 | expect(store.isValid, isTrue); 48 | }); 49 | }); 50 | 51 | group("AuthStore.save()", () { 52 | test("saves new token and model", () async { 53 | final store = AuthStore(); 54 | const testToken = "test_token"; 55 | final testModel = RecordModel({"id": "test"}); 56 | 57 | store.onChange.listen(expectAsync1((e) { 58 | expect(e.token, testToken); 59 | expect(e.record, testModel); 60 | })); 61 | 62 | store.save(testToken, testModel); 63 | 64 | expect(store.token, testToken); 65 | expect(store.record, testModel); 66 | }); 67 | }); 68 | 69 | group("AuthStore.clear()", () { 70 | test("clears the stored token and model", () async { 71 | final store = AuthStore() 72 | ..save("test_token", RecordModel({"id": "test"})); 73 | 74 | expect(store.token, "test_token"); 75 | expect(store.record?.id, "test"); 76 | 77 | store.onChange.listen(expectAsync1((e) { 78 | expect(e.token, isEmpty); 79 | expect(e.record, isNull); 80 | })); 81 | 82 | store.clear(); 83 | 84 | expect(store.token, isEmpty); 85 | expect(store.record, isNull); 86 | }); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/services/collection_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | import "../dtos/collection_model.dart"; 3 | import "base_crud_service.dart"; 4 | 5 | /// The service that handles the **Collection APIs**. 6 | /// 7 | /// Usually shouldn't be initialized manually and instead 8 | /// [PocketBase.collections] should be used. 9 | class CollectionService extends BaseCrudService { 10 | CollectionService(super.client); 11 | 12 | @override 13 | String get baseCrudPath => "/api/collections"; 14 | 15 | @override 16 | CollectionModel itemFactoryFunc(Map json) => 17 | CollectionModel.fromJson(json); 18 | 19 | /// Imports the provided collections. 20 | /// 21 | /// If [deleteMissing] is `true`, all local collections and schema fields, 22 | /// that are not present in the imported configuration, WILL BE DELETED 23 | /// (including their related records data)! 24 | Future import( 25 | List collections, { 26 | bool deleteMissing = false, 27 | Map body = const {}, 28 | Map query = const {}, 29 | Map headers = const {}, 30 | }) { 31 | final enrichedBody = Map.of(body); 32 | enrichedBody["collections"] = collections; 33 | enrichedBody["deleteMissing"] = deleteMissing; 34 | 35 | return client.send( 36 | "$baseCrudPath/import", 37 | method: "PUT", 38 | body: enrichedBody, 39 | query: query, 40 | headers: headers, 41 | ); 42 | } 43 | 44 | /// Returns type indexed map with scaffolded collection models 45 | /// populated with their default field values. 46 | Future> getScaffolds({ 47 | Map body = const {}, 48 | Map query = const {}, 49 | Map headers = const {}, 50 | }) { 51 | return client 52 | .send>( 53 | "$baseCrudPath/meta/scaffolds", 54 | body: body, 55 | query: query, 56 | headers: headers, 57 | ) 58 | .then((data) { 59 | final result = {}; 60 | 61 | data.forEach((key, value) { 62 | result[key] = 63 | CollectionModel.fromJson(value as Map? ?? {}); 64 | }); 65 | 66 | return result; 67 | }); 68 | } 69 | 70 | /// Deletes all records associated with the specified collection. 71 | Future truncate( 72 | String collectionIdOrName, { 73 | Map body = const {}, 74 | Map query = const {}, 75 | Map headers = const {}, 76 | }) { 77 | return client.send( 78 | "$baseCrudPath/${Uri.encodeComponent(collectionIdOrName)}/truncate", 79 | method: "DELETE", 80 | body: body, 81 | query: query, 82 | headers: headers, 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/src/async_auth_store.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "auth_store.dart"; 4 | import "dtos/record_model.dart"; 5 | import "sync_queue.dart"; 6 | 7 | typedef SaveFunc = Future Function(String data); 8 | typedef ClearFunc = Future Function(); 9 | 10 | /// AsyncAuthStore is a pluggable AuthStore implementation that 11 | /// could be used with any external async persistent layer 12 | /// (shared_preferences, hive, local file, etc.). 13 | /// 14 | /// Below is an example using `SharedPreferences`: 15 | /// 16 | /// ```dart 17 | /// final prefs = await SharedPreferences.getInstance(); 18 | /// 19 | /// final store = AsyncAuthStore( 20 | /// save: (String data) async => prefs.setString('pb_auth', data), 21 | /// initial: prefs.getString('pb_auth'), 22 | /// ); 23 | /// 24 | /// final pb = PocketBase('http://example.com', authStore: store); 25 | /// ``` 26 | class AsyncAuthStore extends AuthStore { 27 | /// Async function that is called every time when the auth 28 | /// store state needs to be persisted. 29 | late final SaveFunc saveFunc; 30 | 31 | /// An optional async function that is called every time when 32 | /// the auth store needs to be cleared. 33 | /// 34 | /// If not explicitly set, `saveFunc` with empty data will be used. 35 | late final ClearFunc? clearFunc; 36 | 37 | // encoded data keys 38 | final String _tokenKey = "token"; 39 | final String _modelKey = "model"; 40 | 41 | AsyncAuthStore({ 42 | required SaveFunc save, 43 | String? initial, // initial data to load into the store 44 | ClearFunc? clear, 45 | }) { 46 | saveFunc = save; 47 | clearFunc = clear; 48 | 49 | _loadInitial(initial); 50 | } 51 | 52 | final _queue = SyncQueue(); 53 | 54 | @override 55 | void save( 56 | String newToken, 57 | RecordModel? newRecord, 58 | ) { 59 | super.save(newToken, newRecord); 60 | 61 | final encoded = jsonEncode({_tokenKey: token, _modelKey: record}); 62 | 63 | _queue.enqueue(() => saveFunc(encoded)); 64 | } 65 | 66 | @override 67 | void clear() { 68 | super.clear(); 69 | 70 | if (clearFunc == null) { 71 | _queue.enqueue(() => saveFunc("")); 72 | } else { 73 | _queue.enqueue(() => clearFunc!()); 74 | } 75 | } 76 | 77 | void _loadInitial(String? initial) { 78 | if (initial == null || initial.isEmpty) { 79 | return; 80 | } 81 | 82 | var decoded = {}; 83 | try { 84 | final raw = jsonDecode(initial); 85 | if (raw is Map) { 86 | decoded = raw; 87 | } 88 | } catch (_) { 89 | return; 90 | } 91 | 92 | final token = decoded[_tokenKey] as String? ?? ""; 93 | 94 | final record = 95 | RecordModel.fromJson(decoded[_modelKey] as Map? ?? {}); 96 | 97 | save(token, record); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/async_auth_store_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | import "package:pocketbase/pocketbase.dart"; 4 | import "package:test/test.dart"; 5 | 6 | void main() { 7 | group("AsyncAuthStore()", () { 8 | test("load empty initial", () async { 9 | var saveCalled = 0; 10 | 11 | final store = AsyncAuthStore( 12 | save: (String data) async { 13 | saveCalled++; 14 | }, 15 | ); 16 | 17 | expect(saveCalled, 0); 18 | expect(store.token, ""); 19 | expect(store.record, null); 20 | }); 21 | 22 | test("load RecordModel initial", () async { 23 | var saveCalled = 0; 24 | 25 | final store = AsyncAuthStore( 26 | save: (String data) async { 27 | saveCalled++; 28 | 29 | expect(data, contains('"token":"123"')); 30 | expect(data, contains('"id":"456"')); 31 | }, 32 | initial: '{"token":"123", "model":{"id": "456", "collectionId":""}}', 33 | ); 34 | 35 | expect(saveCalled, 1); 36 | expect(store.token, "123"); 37 | expect(store.record?.id, "456"); 38 | }); 39 | 40 | test("async save()", () async { 41 | var calls = 0; 42 | final completer = Completer(); 43 | 44 | final store = AsyncAuthStore( 45 | save: (String data) async { 46 | calls++; 47 | if (calls == 2) { 48 | completer.complete(data); 49 | } 50 | }, 51 | ); 52 | 53 | // ignore: cascade_invocations 54 | store 55 | ..save("123", null) 56 | ..save("456", null); 57 | 58 | await expectLater(await completer.future, contains('"token":"456"')); 59 | expect(store.token, "456"); 60 | }); 61 | 62 | test("async clear with non explicit clear func", () async { 63 | final completer = Completer(); 64 | 65 | final store = AsyncAuthStore( 66 | save: (String data) async { 67 | if (data.contains("test")) { 68 | return; 69 | } 70 | completer.complete(data); 71 | }, 72 | ); 73 | 74 | // ignore: cascade_invocations 75 | store 76 | ..save("test", null) 77 | ..clear(); 78 | 79 | await expectLater(await completer.future, contains("")); 80 | expect(store.token, ""); 81 | }); 82 | 83 | test("async clear with explicit clear func", () async { 84 | var saveCalls = 0; 85 | var clearCalls = 0; 86 | 87 | final store = AsyncAuthStore( 88 | save: (String data) async { 89 | saveCalls++; 90 | }, 91 | clear: () async { 92 | clearCalls++; 93 | }, 94 | ); 95 | 96 | // ignore: cascade_invocations 97 | store 98 | ..save("test", null) 99 | ..clear() 100 | ..clear(); 101 | 102 | // ignore: inference_failure_on_instance_creation 103 | await Future.delayed(const Duration(milliseconds: 1)); 104 | 105 | expect(store.token, ""); 106 | expect(saveCalls, 1); 107 | expect(clearCalls, 2); 108 | }); 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /lib/src/services/settings_service.dart: -------------------------------------------------------------------------------- 1 | import "../client.dart"; 2 | import "../dtos/apple_client_secret.dart"; 3 | import "base_service.dart"; 4 | 5 | /// The service that handles the **Settings APIs**. 6 | /// 7 | /// Usually shouldn't be initialized manually and instead 8 | /// [PocketBase.settings] should be used. 9 | class SettingsService extends BaseService { 10 | SettingsService(super.client); 11 | 12 | /// Fetch all available app settings. 13 | Future> getAll({ 14 | Map query = const {}, 15 | Map headers = const {}, 16 | }) { 17 | return client.send>( 18 | "/api/settings", 19 | query: query, 20 | headers: headers, 21 | ); 22 | } 23 | 24 | /// Bulk updates app settings. 25 | Future> update({ 26 | Map body = const {}, 27 | Map query = const {}, 28 | Map headers = const {}, 29 | }) { 30 | return client.send>( 31 | "/api/settings", 32 | method: "PATCH", 33 | body: body, 34 | query: query, 35 | headers: headers, 36 | ); 37 | } 38 | 39 | /// Performs a S3 storage connection test. 40 | Future testS3({ 41 | String filesystem = "storage", // "storage" or "backups" 42 | Map body = const {}, 43 | Map query = const {}, 44 | Map headers = const {}, 45 | }) { 46 | final enrichedBody = Map.of(body); 47 | enrichedBody["filesystem"] ??= filesystem; 48 | 49 | return client.send( 50 | "/api/settings/test/s3", 51 | method: "POST", 52 | body: enrichedBody, 53 | query: query, 54 | headers: headers, 55 | ); 56 | } 57 | 58 | /// Sends a test email. 59 | /// 60 | /// The possible `template` values are: 61 | /// - verification 62 | /// - password-reset 63 | /// - email-change 64 | Future testEmail( 65 | String toEmail, 66 | String template, { 67 | String? collection, // fallback to _superusers 68 | Map body = const {}, 69 | Map query = const {}, 70 | Map headers = const {}, 71 | }) { 72 | final enrichedBody = Map.of(body); 73 | enrichedBody["email"] ??= toEmail; 74 | enrichedBody["template"] ??= template; 75 | enrichedBody["collection"] ??= collection; 76 | 77 | return client.send( 78 | "/api/settings/test/email", 79 | method: "POST", 80 | body: enrichedBody, 81 | query: query, 82 | headers: headers, 83 | ); 84 | } 85 | 86 | /// Generates a new Apple OAuth2 client secret. 87 | Future generateAppleClientSecret( 88 | String clientId, 89 | String teamId, 90 | String keyId, 91 | String privateKey, 92 | int duration, { 93 | Map body = const {}, 94 | Map query = const {}, 95 | Map headers = const {}, 96 | }) { 97 | final enrichedBody = Map.of(body); 98 | enrichedBody["clientId"] ??= clientId; 99 | enrichedBody["teamId"] ??= teamId; 100 | enrichedBody["keyId"] ??= keyId; 101 | enrichedBody["privateKey"] ??= privateKey; 102 | enrichedBody["duration"] ??= duration; 103 | 104 | return client 105 | .send>( 106 | "/api/settings/apple/generate-client-secret", 107 | method: "POST", 108 | body: enrichedBody, 109 | query: query, 110 | headers: headers, 111 | ) 112 | .then(AppleClientSecret.fromJson); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | analyzer: 4 | language: 5 | strict-casts: true 6 | strict-raw-types: true 7 | strict-inference: true 8 | exclude: [build/**, lib/**.g.dart] 9 | 10 | linter: 11 | rules: 12 | - annotate_overrides 13 | - avoid_bool_literals_in_conditional_expressions 14 | - avoid_catching_errors 15 | - avoid_classes_with_only_static_members 16 | - avoid_dynamic_calls 17 | - avoid_empty_else 18 | - avoid_function_literals_in_foreach_calls 19 | - avoid_init_to_null 20 | - avoid_null_checks_in_equality_operators 21 | - avoid_private_typedef_functions 22 | - avoid_redundant_argument_values 23 | - avoid_relative_lib_imports 24 | - avoid_renaming_method_parameters 25 | - avoid_return_types_on_setters 26 | - avoid_returning_null_for_void 27 | - avoid_returning_this 28 | - avoid_shadowing_type_parameters 29 | - avoid_single_cascade_in_expression_statements 30 | - avoid_types_as_parameter_names 31 | - avoid_unused_constructor_parameters 32 | - await_only_futures 33 | - camel_case_types 34 | - cascade_invocations 35 | - comment_references 36 | - constant_identifier_names 37 | - control_flow_in_finally 38 | - curly_braces_in_flow_control_structures 39 | - directives_ordering 40 | - empty_catches 41 | - empty_constructor_bodies 42 | - empty_statements 43 | - file_names 44 | - hash_and_equals 45 | - implementation_imports 46 | - join_return_with_assignment 47 | - library_names 48 | - library_prefixes 49 | - lines_longer_than_80_chars 50 | - missing_whitespace_between_adjacent_strings 51 | - no_adjacent_strings_in_list 52 | - no_duplicate_case_values 53 | - no_runtimeType_toString 54 | - non_constant_identifier_names 55 | - null_closures 56 | - omit_local_variable_types 57 | - only_throw_errors 58 | - overridden_fields 59 | - package_names 60 | - package_prefixed_library_names 61 | - prefer_adjacent_string_concatenation 62 | - prefer_asserts_in_initializer_lists 63 | - prefer_collection_literals 64 | - prefer_conditional_assignment 65 | - prefer_const_constructors 66 | - prefer_const_declarations 67 | - prefer_contains 68 | - prefer_final_fields 69 | - prefer_final_locals 70 | - prefer_function_declarations_over_variables 71 | - prefer_generic_function_type_aliases 72 | - prefer_initializing_formals 73 | - prefer_inlined_adds 74 | - prefer_interpolation_to_compose_strings 75 | - prefer_is_empty 76 | - prefer_is_not_empty 77 | - prefer_is_not_operator 78 | - prefer_null_aware_operators 79 | - prefer_relative_imports 80 | - prefer_double_quotes 81 | - prefer_typing_uninitialized_variables 82 | - prefer_void_to_null 83 | - provide_deprecation_message 84 | - recursive_getters 85 | - slash_for_doc_comments 86 | - sort_pub_dependencies 87 | - test_types_in_equals 88 | - throw_in_finally 89 | - type_annotate_public_apis 90 | - type_init_formals 91 | - unawaited_futures 92 | - unnecessary_brace_in_string_interps 93 | - unnecessary_const 94 | - unnecessary_getters_setters 95 | - unnecessary_lambdas 96 | - unnecessary_new 97 | - unnecessary_null_aware_assignments 98 | - unnecessary_null_in_if_null_operators 99 | - unnecessary_overrides 100 | - unnecessary_parenthesis 101 | - unnecessary_statements 102 | - unnecessary_string_interpolations 103 | - unnecessary_this 104 | - unrelated_type_equality_checks 105 | - use_is_even_rather_than_modulo 106 | - use_rethrow_when_possible 107 | - use_string_buffers 108 | - valid_regexps 109 | - void_checks 110 | -------------------------------------------------------------------------------- /test/services/log_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("LogService", () { 10 | test("getList()", () async { 11 | final mock = MockClient((request) async { 12 | expect(request.method, "GET"); 13 | expect( 14 | request.url.toString(), 15 | "/base/api/logs?a=1&a=2&b=%40demo&page=2&perPage=15&filter=filter%3D123&sort=sort%3D456", 16 | ); 17 | expect(request.headers["test"], "789"); 18 | 19 | return http.Response( 20 | jsonEncode({ 21 | "page": 2, 22 | "perPage": 15, 23 | "totalItems": 17, 24 | "totalPages": 2, 25 | "items": [ 26 | {"id": "1"}, 27 | {"id": "2"}, 28 | ], 29 | }), 30 | 200); 31 | }); 32 | 33 | final client = PocketBase("/base", httpClientFactory: () => mock); 34 | 35 | final result = await client.logs.getList( 36 | page: 2, 37 | perPage: 15, 38 | filter: "filter=123", 39 | sort: "sort=456", 40 | query: { 41 | "a": ["1", null, 2], 42 | "b": "@demo", 43 | }, 44 | headers: { 45 | "test": "789", 46 | }, 47 | ); 48 | 49 | expect(result.page, 2); 50 | expect(result.perPage, 15); 51 | expect(result.totalItems, 17); 52 | expect(result.totalPages, 2); 53 | expect(result.items, isA>()); 54 | expect(result.items.length, 2); 55 | }); 56 | 57 | test("getOne()", () async { 58 | final mock = MockClient((request) async { 59 | expect(request.method, "GET"); 60 | expect( 61 | request.url.toString(), 62 | "/base/api/logs/%40id123?a=1&a=2&b=%40demo", 63 | ); 64 | expect(request.headers["test"], "789"); 65 | 66 | return http.Response(jsonEncode({"id": "@id123"}), 200); 67 | }); 68 | 69 | final client = PocketBase("/base", httpClientFactory: () => mock); 70 | 71 | final result = await client.logs.getOne( 72 | "@id123", 73 | query: { 74 | "a": ["1", null, 2], 75 | "b": "@demo", 76 | }, 77 | headers: { 78 | "test": "789", 79 | }, 80 | ); 81 | 82 | expect(result, isA()); 83 | expect(result.id, "@id123"); 84 | }); 85 | 86 | test("getOne() with empty id", () async { 87 | final client = PocketBase("/base"); 88 | 89 | await expectLater( 90 | client.logs.getOne(""), throwsA(isA())); 91 | }); 92 | 93 | test("getStats()", () async { 94 | final mock = MockClient((request) async { 95 | expect(request.method, "GET"); 96 | expect( 97 | request.url.toString(), 98 | "/base/api/logs/stats?a=1&a=2&b=%40demo", 99 | ); 100 | expect(request.headers["test"], "789"); 101 | 102 | return http.Response( 103 | jsonEncode([ 104 | {"total": 1, "date": "2022-01-01"}, 105 | {"total": 2, "date": "2022-01-02"}, 106 | ]), 107 | 200); 108 | }); 109 | 110 | final client = PocketBase("/base", httpClientFactory: () => mock); 111 | 112 | final result = await client.logs.getStats( 113 | query: { 114 | "a": ["1", null, 2], 115 | "b": "@demo", 116 | }, 117 | headers: { 118 | "test": "789", 119 | }, 120 | ); 121 | 122 | expect(result.length, 2); 123 | expect(result[0].total, 1); 124 | expect(result[1].total, 2); 125 | }); 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /test/services/collection_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | import "crud_suite.dart"; 9 | 10 | void main() { 11 | group("CollectionService", () { 12 | crudServiceTests( 13 | CollectionService.new, 14 | "collections", 15 | ); 16 | 17 | test("import()", () async { 18 | final collections = [ 19 | CollectionModel(id: "id1"), 20 | CollectionModel(id: "id2"), 21 | ]; 22 | 23 | final mock = MockClient((request) async { 24 | expect(request.method, "PUT"); 25 | expect( 26 | request.body, 27 | jsonEncode({ 28 | "test_body": 123, 29 | "collections": collections, 30 | "deleteMissing": true, 31 | })); 32 | expect( 33 | request.url.toString(), 34 | "/base/api/collections/import?a=1&a=2&b=%40demo", 35 | ); 36 | expect(request.headers["test"], "789"); 37 | 38 | return http.Response( 39 | jsonEncode({"a": 1, "b": false, "c": "test"}), 40 | 200, 41 | ); 42 | }); 43 | 44 | final client = PocketBase("/base", httpClientFactory: () => mock); 45 | 46 | await client.collections.import( 47 | collections, 48 | deleteMissing: true, 49 | query: { 50 | "a": ["1", null, 2], 51 | "b": "@demo", 52 | }, 53 | body: { 54 | "test_body": 123, 55 | }, 56 | headers: { 57 | "test": "789", 58 | }, 59 | ); 60 | }); 61 | 62 | test("getScaffolds()", () async { 63 | final mock = MockClient((request) async { 64 | expect(request.method, "GET"); 65 | expect(request.body, jsonEncode({"test_body": 123})); 66 | expect( 67 | request.url.toString(), 68 | "/base/api/collections/meta/scaffolds?a=1&a=2&b=%40demo", 69 | ); 70 | expect(request.headers["test"], "789"); 71 | 72 | return http.Response( 73 | jsonEncode({ 74 | "test1": CollectionModel(id: "id1").toJson(), 75 | "test2": CollectionModel(id: "id2").toJson(), 76 | }), 77 | 200, 78 | ); 79 | }); 80 | 81 | final client = PocketBase("/base", httpClientFactory: () => mock); 82 | 83 | final result = await client.collections.getScaffolds( 84 | query: { 85 | "a": ["1", null, 2], 86 | "b": "@demo", 87 | }, 88 | body: { 89 | "test_body": 123, 90 | }, 91 | headers: { 92 | "test": "789", 93 | }, 94 | ); 95 | 96 | expect(result.length, 2); 97 | expect(result["test1"]?.id, "id1"); 98 | expect(result["test2"]?.id, "id2"); 99 | }); 100 | }); 101 | 102 | test("truncate()", () async { 103 | final mock = MockClient((request) async { 104 | expect(request.method, "DELETE"); 105 | expect(request.body, jsonEncode({"test_body": 123})); 106 | expect( 107 | request.url.toString(), 108 | "/base/api/collections/test%3D/truncate?a=1&a=2&b=%40demo", 109 | ); 110 | expect(request.headers["test"], "789"); 111 | 112 | return http.Response( 113 | jsonEncode({"a": 1, "b": false, "c": "test"}), 114 | 200, 115 | ); 116 | }); 117 | 118 | final client = PocketBase("/base", httpClientFactory: () => mock); 119 | 120 | await client.collections.truncate( 121 | "test=", 122 | query: { 123 | "a": ["1", null, 2], 124 | "b": "@demo", 125 | }, 126 | body: { 127 | "test_body": 123, 128 | }, 129 | headers: { 130 | "test": "789", 131 | }, 132 | ); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /lib/src/services/backup_service.dart: -------------------------------------------------------------------------------- 1 | import "package:http/http.dart" as http; 2 | 3 | import "../client.dart"; 4 | import "../dtos/backup_file_info.dart"; 5 | import "base_service.dart"; 6 | 7 | /// The service that handles the **Backup and restore APIs**. 8 | /// 9 | /// Usually shouldn't be initialized manually and instead 10 | /// [PocketBase.backups] should be used. 11 | class BackupService extends BaseService { 12 | BackupService(super.client); 13 | 14 | /// Fetch all available app settings. 15 | Future> getFullList({ 16 | Map query = const {}, 17 | Map headers = const {}, 18 | }) { 19 | return client 20 | .send>( 21 | "/api/backups", 22 | query: query, 23 | headers: headers, 24 | ) 25 | .then((data) => data 26 | .map((item) => 27 | BackupFileInfo.fromJson(item as Map? ?? {})) 28 | .toList()); 29 | } 30 | 31 | /// Initializes a new backup. 32 | Future create( 33 | String basename, { 34 | Map body = const {}, 35 | Map query = const {}, 36 | Map headers = const {}, 37 | }) { 38 | final enrichedBody = Map.of(body); 39 | enrichedBody["name"] ??= basename; 40 | 41 | return client.send( 42 | "/api/backups", 43 | method: "POST", 44 | body: enrichedBody, 45 | query: query, 46 | headers: headers, 47 | ); 48 | } 49 | 50 | /// Uploads an existing backup file. 51 | /// 52 | /// The key of the MultipartFile file must be "file". 53 | /// 54 | /// Example: 55 | /// ```dart 56 | /// await pb.backups.upload(http.MultipartFile.fromBytes("file", ...)) 57 | /// ``` 58 | Future upload( 59 | http.MultipartFile file, { 60 | Map body = const {}, 61 | Map query = const {}, 62 | Map headers = const {}, 63 | }) { 64 | return client.send( 65 | "/api/backups/upload", 66 | method: "POST", 67 | body: body, 68 | query: query, 69 | headers: headers, 70 | files: [file], 71 | ); 72 | } 73 | 74 | /// Deletes a single backup file. 75 | Future delete( 76 | String key, { 77 | Map body = const {}, 78 | Map query = const {}, 79 | Map headers = const {}, 80 | }) { 81 | return client.send( 82 | "/api/backups/${Uri.encodeComponent(key)}", 83 | method: "DELETE", 84 | body: body, 85 | query: query, 86 | headers: headers, 87 | ); 88 | } 89 | 90 | /// Initializes an app data restore from an existing backup. 91 | Future restore( 92 | String key, { 93 | Map body = const {}, 94 | Map query = const {}, 95 | Map headers = const {}, 96 | }) { 97 | return client.send( 98 | "/api/backups/${Uri.encodeComponent(key)}/restore", 99 | method: "POST", 100 | body: body, 101 | query: query, 102 | headers: headers, 103 | ); 104 | } 105 | 106 | @Deprecated("use getDownloadURL") 107 | Uri getDownloadUrl( 108 | String token, 109 | String key, { 110 | Map query = const {}, 111 | }) { 112 | return getDownloadURL(token, key, query: query); 113 | } 114 | 115 | /// Builds a download url for a single existing backup using a 116 | /// superuser file token and the backup file key. 117 | /// 118 | /// The file token can be generated via `pb.files.getToken()`. 119 | Uri getDownloadURL( 120 | String token, 121 | String key, { 122 | Map query = const {}, 123 | }) { 124 | final params = Map.of(query); 125 | params["token"] ??= token; 126 | 127 | return client.buildURL( 128 | "/api/backups/${Uri.encodeComponent(key)}", 129 | params, 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /test/caster_test.dart: -------------------------------------------------------------------------------- 1 | import "package:pocketbase/src/caster.dart" as caster; 2 | import "package:test/test.dart"; 3 | 4 | void main() { 5 | group("caster", () { 6 | test("extract()", () { 7 | final data = { 8 | "a": null, 9 | "b": 1.5, 10 | "c": "test", 11 | "d": false, 12 | "e": ["1", "2", "3"], 13 | "f": { 14 | "a": { 15 | "b": [ 16 | {"b1": 1}, 17 | {"b2": 2}, 18 | {"b3": 3} 19 | ] 20 | } 21 | }, 22 | }; 23 | 24 | expect(caster.extract(data, "unknown"), null); 25 | expect(caster.extract(data, "unknown", "missing!"), "missing!"); 26 | expect(caster.extract(data, "b"), 1.5); 27 | expect(caster.extract(data, "b"), 1); 28 | expect(caster.extract(data, "c"), "test"); 29 | expect(caster.extract>(data, "e"), ["1", "2", "3"]); 30 | expect(caster.extract>(data, "e"), [1, 2, 3]); 31 | expect(caster.extract>(data, "f.a.b"), [ 32 | {"b1": 1}, 33 | {"b2": 2}, 34 | {"b3": 3} 35 | ]); 36 | expect(caster.extract(data, "f.a.b.1.b2"), 2); 37 | expect(caster.extract(data, "f.a.b.2.b2"), 0); 38 | expect(caster.extract(data, "f.missing", -1), -1); 39 | }); 40 | 41 | test("toString()", () { 42 | expect(caster.toString(null), ""); 43 | expect(caster.toString(1), "1"); 44 | expect(caster.toString("test"), "test"); 45 | expect(caster.toString(false), "false"); 46 | expect(caster.toString(["1", 2, 3]), "[1, 2, 3]"); 47 | expect(caster.toString({"test": 123}), "{test: 123}"); 48 | }); 49 | 50 | test("toList()", () { 51 | expect(caster.toList(1), [true]); 52 | expect(caster.toList(0), [false]); 53 | expect(caster.toList(null), []); 54 | expect(caster.toList(1), [1]); 55 | expect(caster.toList(1.5), [1]); 56 | expect(caster.toList(null), []); 57 | expect(caster.toList(null), []); 58 | expect(caster.toList(1), [1]); 59 | expect(caster.toList("test"), ["test"]); 60 | expect(caster.toList("false"), ["false"]); 61 | expect(caster.toList(["1", 2, 3]), ["1", 2, 3]); 62 | }); 63 | 64 | test("toBool()", () { 65 | expect(caster.toBool(null), false); 66 | expect(caster.toBool(1), true); 67 | expect(caster.toBool(0), false); 68 | expect(caster.toBool(-1), true); 69 | expect(caster.toBool(""), false); 70 | expect(caster.toBool("false"), false); 71 | expect(caster.toBool("0"), false); 72 | expect(caster.toBool("test"), true); 73 | expect(caster.toBool("false"), false); 74 | expect(caster.toBool([]), false); 75 | expect(caster.toBool(["1", 2, 3]), true); 76 | expect(caster.toBool({}), false); 77 | expect(caster.toBool({"test": 123}), true); 78 | }); 79 | 80 | test("toInt()", () { 81 | expect(caster.toInt(null), 0); 82 | expect(caster.toInt(1), 1); 83 | expect(caster.toInt(2.4), 2); 84 | expect(caster.toInt(""), 0); 85 | expect(caster.toInt("false"), 0); 86 | expect(caster.toInt("123"), 123); 87 | expect(caster.toInt("test"), 0); 88 | expect(caster.toInt(false), 0); 89 | expect(caster.toInt(true), 1); 90 | expect(caster.toInt([]), 0); 91 | expect(caster.toInt(["1", 2, 3]), 3); 92 | expect(caster.toInt({}), 0); 93 | expect(caster.toInt({"a": 123, "b": 456}), 2); 94 | }); 95 | 96 | test("getDoubleValue()", () { 97 | expect(caster.toDouble(null), 0); 98 | expect(caster.toDouble(1), 1); 99 | expect(caster.toDouble(2.4), 2.4); 100 | expect(caster.toDouble(""), 0); 101 | expect(caster.toDouble("false"), 0); 102 | expect(caster.toDouble("123.4"), 123.4); 103 | expect(caster.toDouble("test"), 0); 104 | expect(caster.toDouble(false), 0); 105 | expect(caster.toDouble(true), 1); 106 | expect(caster.toDouble([]), 0); 107 | expect(caster.toDouble(["1", 2, 3]), 3); 108 | expect(caster.toDouble({}), 0); 109 | expect(caster.toDouble({"a": 123, "b": 456}), 2); 110 | }); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /test/services/batch_service_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: lines_longer_than_80_chars 2 | 3 | import "dart:convert"; 4 | 5 | import "package:http/http.dart" as http; 6 | import "package:http/testing.dart"; 7 | import "package:pocketbase/pocketbase.dart"; 8 | import "package:test/test.dart"; 9 | 10 | void main() { 11 | group("BatchService", () { 12 | test("send create/update/upsert/delete batch request", () async { 13 | final batchResult = [ 14 | BatchResult(status: 200, body: {"test": 123}), 15 | BatchResult(status: 204), 16 | ]; 17 | 18 | final mock = MockClient((request) async { 19 | expect(request.method, "POST"); 20 | expect( 21 | request.url.toString(), 22 | "/base/api/batch?a=1&a=2&b=%40demo", 23 | ); 24 | expect(request.headers["test"], "789"); 25 | expect( 26 | request.headers["content-type"], 27 | contains("multipart/form-data; boundary=dart-http-boundary-"), 28 | ); 29 | 30 | // multipart/form-data body check 31 | expect(request.body, contains("--dart-http-boundary")); 32 | expect( 33 | request.body, 34 | contains('content-disposition: form-data; name="@jsonPayload"\r\n'), 35 | ); 36 | expect( 37 | request.body, 38 | contains( 39 | '{"test_body":123,"requests":[{"method":"POST","url":"/api/collections/test/records?create=123","headers":{"test":"create_h"},"body":{"title":"create"}},{"method":"PATCH","url":"/api/collections/test/records/%40test_id1?update=123","headers":{"test":"update_h"},"body":{"title":"update"}},{"method":"PUT","url":"/api/collections/test/records?upsert=123","headers":{"test":"upsert_h"},"body":{"title":"upsert"}},{"method":"DELETE","url":"/api/collections/test/records/%40test_id2?d=123","headers":{},"body":{}}]}\r\n', 40 | ), 41 | ); 42 | expect( 43 | request.body, 44 | contains( 45 | 'content-disposition: form-data; name="requests.0.file"; filename="f1.txt"\r\n', 46 | ), 47 | ); 48 | expect( 49 | request.body, 50 | contains( 51 | 'content-disposition: form-data; name="requests.0.file"; filename="f2.txt"\r\n', 52 | ), 53 | ); 54 | expect( 55 | request.body, 56 | contains( 57 | 'content-disposition: form-data; name="requests.1.file"; filename="f3.txt"\r\n', 58 | ), 59 | ); 60 | expect( 61 | request.body, 62 | contains( 63 | 'content-disposition: form-data; name="requests.2.file"; filename="f4.txt"\r\n', 64 | ), 65 | ); 66 | 67 | return http.Response(jsonEncode(batchResult), 200); 68 | }); 69 | 70 | final client = PocketBase("/base", httpClientFactory: () => mock); 71 | 72 | final f1 = http.MultipartFile.fromString( 73 | "file", 74 | "f1", 75 | filename: "f1.txt", 76 | ); 77 | 78 | final f2 = http.MultipartFile.fromString( 79 | "file", 80 | "f2", 81 | filename: "f2.txt", 82 | ); 83 | 84 | final f3 = http.MultipartFile.fromString( 85 | "file", 86 | "f3", 87 | filename: "f3.txt", 88 | ); 89 | 90 | final f4 = http.MultipartFile.fromString( 91 | "file", 92 | "f4", 93 | filename: "f4.txt", 94 | ); 95 | 96 | final batch = client.createBatch() 97 | ..collection("test").create( 98 | body: {"title": "create"}, 99 | files: [f1, f2], 100 | query: {"create": 123}, 101 | headers: {"test": "create_h"}, 102 | ) 103 | ..collection("test").update( 104 | "@test_id1", 105 | body: {"title": "update"}, 106 | files: [f3], 107 | query: {"update": 123}, 108 | headers: {"test": "update_h"}, 109 | ) 110 | ..collection("test").upsert( 111 | body: {"title": "upsert"}, 112 | files: [f4], 113 | query: {"upsert": 123}, 114 | headers: {"test": "upsert_h"}, 115 | ) 116 | ..collection("test").delete("@test_id2", query: {"d": 123}); 117 | 118 | final result = await batch.send( 119 | query: { 120 | "a": ["1", null, 2], 121 | "b": "@demo", 122 | }, 123 | body: { 124 | "test_body": 123, 125 | }, 126 | headers: { 127 | "test": "789", 128 | }, 129 | ); 130 | 131 | expect(jsonEncode(result), jsonEncode(batchResult)); 132 | }); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /test/services/backup_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("BackupService", () { 10 | test("getFullList()", () async { 11 | final expectedResult = [ 12 | BackupFileInfo(key: "k1"), 13 | BackupFileInfo(key: "k2") 14 | ]; 15 | 16 | final mock = MockClient((request) async { 17 | expect(request.method, "GET"); 18 | expect( 19 | request.url.toString(), 20 | "/base/api/backups?a=1&a=2&b=%40demo", 21 | ); 22 | expect(request.headers["test"], "789"); 23 | 24 | return http.Response(jsonEncode(expectedResult), 200); 25 | }); 26 | 27 | final client = PocketBase("/base", httpClientFactory: () => mock); 28 | 29 | final result = await client.backups.getFullList( 30 | query: { 31 | "a": ["1", null, 2], 32 | "b": "@demo", 33 | }, 34 | headers: { 35 | "test": "789", 36 | }, 37 | ); 38 | 39 | expect(result, isA>()); 40 | expect(result.length, expectedResult.length); 41 | expect(result[0].key, expectedResult[0].key); 42 | expect(result[1].key, expectedResult[1].key); 43 | }); 44 | 45 | test("create()", () async { 46 | final mock = MockClient((request) async { 47 | expect(request.method, "POST"); 48 | expect( 49 | request.body, jsonEncode({"test_body": 123, "name": "test_name"})); 50 | expect( 51 | request.url.toString(), 52 | "/base/api/backups?a=1&a=2&b=%40demo", 53 | ); 54 | expect(request.headers["test"], "789"); 55 | 56 | return http.Response("", 204); 57 | }); 58 | 59 | final client = PocketBase("/base", httpClientFactory: () => mock); 60 | 61 | await client.backups.create( 62 | "test_name", 63 | query: { 64 | "a": ["1", null, 2], 65 | "b": "@demo", 66 | }, 67 | body: { 68 | "test_body": 123, 69 | }, 70 | headers: { 71 | "test": "789", 72 | }, 73 | ); 74 | }); 75 | 76 | test("upload()", () async { 77 | final mock = MockClient((request) async { 78 | expect(request.method, "POST"); 79 | expect(request.body, contains("content-disposition: form-data;")); 80 | expect(request.body, contains('name="@jsonPayload"')); 81 | expect(request.body, contains('{"test_body":123}\r\n')); 82 | expect(request.body, contains('form-data; name="file"')); 83 | expect( 84 | request.url.toString(), 85 | "/base/api/backups/upload?a=1&a=2&b=%40demo", 86 | ); 87 | expect(request.headers["test"], "789"); 88 | 89 | return http.Response("", 204); 90 | }); 91 | 92 | final client = PocketBase("/base", httpClientFactory: () => mock); 93 | 94 | await client.backups.upload( 95 | http.MultipartFile.fromBytes("file", []), 96 | query: { 97 | "a": ["1", null, 2], 98 | "b": "@demo", 99 | }, 100 | body: { 101 | "test_body": 123, 102 | }, 103 | headers: { 104 | "test": "789", 105 | }, 106 | ); 107 | }); 108 | 109 | test("restore()", () async { 110 | final mock = MockClient((request) async { 111 | expect(request.method, "POST"); 112 | expect(request.body, jsonEncode({"test_body": 123})); 113 | expect( 114 | request.url.toString(), 115 | "/base/api/backups/%40test_name/restore?a=1&a=2&b=%40demo", 116 | ); 117 | expect(request.headers["test"], "789"); 118 | 119 | return http.Response("", 204); 120 | }); 121 | 122 | final client = PocketBase("/base", httpClientFactory: () => mock); 123 | 124 | await client.backups.restore( 125 | "@test_name", 126 | query: { 127 | "a": ["1", null, 2], 128 | "b": "@demo", 129 | }, 130 | body: { 131 | "test_body": 123, 132 | }, 133 | headers: { 134 | "test": "789", 135 | }, 136 | ); 137 | }); 138 | 139 | test("getDownloadURL()", () async { 140 | final client = PocketBase("/base"); 141 | 142 | final url = client.backups.getDownloadURL( 143 | "@test_token", 144 | "@test_name", 145 | query: { 146 | "demo": [1, null, "@test"], 147 | }, 148 | ); 149 | 150 | expect( 151 | url.toString(), 152 | "/base/api/backups/%40test_name?demo=1&demo=%40test&token=%40test_token", 153 | ); 154 | }); 155 | }); 156 | } 157 | -------------------------------------------------------------------------------- /lib/src/dtos/record_model.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: deprecated_member_use_from_same_package 2 | 3 | import "dart:convert"; 4 | 5 | import "package:json_annotation/json_annotation.dart"; 6 | 7 | import "../caster.dart" as caster; 8 | import "jsonable.dart"; 9 | 10 | //part "record_model.g.dart"; // not actually used 11 | 12 | /// Response DTO of a single record model. 13 | @JsonSerializable() 14 | class RecordModel implements Jsonable { 15 | String get id => get("id", ""); 16 | set id(String val) => data["id"] = val; 17 | 18 | String get collectionId => get("collectionId"); 19 | 20 | String get collectionName => get("collectionName"); 21 | 22 | @Deprecated( 23 | "created is no longer mandatory field; use get('created')", 24 | ) 25 | String get created => get("created"); 26 | 27 | @Deprecated( 28 | "updated is no longer mandatory field; use get('updated')", 29 | ) 30 | String get updated => get("updated"); 31 | 32 | Map data; 33 | 34 | @Deprecated(""" 35 | This field is superseded by the more generic get(keyPath) method. 36 | You can access the expanded record models and fields using dot-notation similar to the regular record fields: 37 | record.get("expand.user.email"); 38 | record.get("expand.user"); 39 | record.get>("expand.products"); 40 | """) 41 | @JsonKey(includeFromJson: false, includeToJson: false) 42 | Map> expand = {}; 43 | 44 | final List _singleExpandKeys = []; 45 | final List _multiExpandKeys = []; 46 | 47 | RecordModel([Map? data]) : data = data ?? {}; 48 | 49 | static RecordModel fromJson(Map json) { 50 | final model = RecordModel(json); 51 | 52 | // @todo remove with the expand field removal 53 | // 54 | // resolve and normalize the expand item(s) recursively 55 | (json["expand"] as Map? ?? {}).forEach((key, value) { 56 | final result = []; 57 | 58 | if (value is Iterable) { 59 | model._multiExpandKeys.add(key); 60 | for (final item in value) { 61 | result.add(RecordModel.fromJson(item as Map? ?? {})); 62 | } 63 | } 64 | 65 | if (value is Map) { 66 | model._singleExpandKeys.add(key); 67 | result.add(RecordModel.fromJson(value as Map? ?? {})); 68 | } 69 | 70 | model.expand[key] = result; 71 | }); 72 | 73 | return model; 74 | } 75 | 76 | @override 77 | Map toJson() => Map.from(data); 78 | 79 | @override 80 | String toString() => jsonEncode(data); 81 | 82 | /// Extracts a single model value by a dot-notation path 83 | /// and tries to cast it to the specified generic type. 84 | /// 85 | /// If explicitly set, returns [defaultValue] on missing path. 86 | /// 87 | /// For more details about the casting rules, please refer to 88 | /// [caster.extract()]. 89 | /// 90 | /// Example: 91 | /// 92 | /// ```dart 93 | /// final data = {"a": {"b": [{"b1": 1}, {"b2": 2}, {"b3": 3}]}}; 94 | /// final record = RecordModel(data); 95 | /// final result0 = record.get("a.b.c", 999); // 999 96 | /// final result1 = record.get("a.b.2.b3"); // 3 97 | /// final result2 = record.get("a.b.2.b3"); // "3" 98 | /// ``` 99 | T get(String fieldNameOrPath, [T? defaultValue]) { 100 | return caster.extract(data, fieldNameOrPath, defaultValue); 101 | } 102 | 103 | // Updates a single Record field value. 104 | void set(String fieldName, dynamic value) { 105 | data[fieldName] = value; 106 | } 107 | 108 | @Deprecated("use get(...)") 109 | T getDataValue(String fieldNameOrPath, [T? defaultValue]) { 110 | return caster.extract(data, fieldNameOrPath, defaultValue); 111 | } 112 | 113 | /// An alias for [get()]. 114 | String getStringValue(String fieldNameOrPath, [String? defaultValue]) { 115 | return get(fieldNameOrPath, defaultValue); 116 | } 117 | 118 | /// An alias for [get>()]. 119 | List getListValue(String fieldNameOrPath, [List? defaultValue]) { 120 | return get>(fieldNameOrPath, defaultValue); 121 | } 122 | 123 | /// An alias for [get()]. 124 | bool getBoolValue(String fieldNameOrPath, [bool? defaultValue]) { 125 | return get(fieldNameOrPath, defaultValue); 126 | } 127 | 128 | /// An alias for [get()]. 129 | int getIntValue(String fieldNameOrPath, [int? defaultValue]) { 130 | return get(fieldNameOrPath, defaultValue); 131 | } 132 | 133 | /// An alias for [get()]. 134 | double getDoubleValue(String fieldNameOrPath, [double? defaultValue]) { 135 | return get(fieldNameOrPath, defaultValue); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /lib/src/dtos/collection_model.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'collection_model.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | CollectionModel _$CollectionModelFromJson(Map json) => 10 | CollectionModel( 11 | id: json['id'] as String? ?? "", 12 | type: json['type'] as String? ?? "base", 13 | created: json['created'] as String? ?? "", 14 | updated: json['updated'] as String? ?? "", 15 | name: json['name'] as String? ?? "", 16 | system: json['system'] as bool? ?? false, 17 | listRule: json['listRule'] as String?, 18 | viewRule: json['viewRule'] as String?, 19 | createRule: json['createRule'] as String?, 20 | updateRule: json['updateRule'] as String?, 21 | deleteRule: json['deleteRule'] as String?, 22 | fields: (json['fields'] as List?) 23 | ?.map((e) => CollectionField.fromJson(e as Map)) 24 | .toList() ?? 25 | const [], 26 | indexes: (json['indexes'] as List?) 27 | ?.map((e) => e as String) 28 | .toList() ?? 29 | const [], 30 | viewQuery: json['viewQuery'] as String?, 31 | authRule: json['authRule'] as String?, 32 | manageRule: json['manageRule'] as String?, 33 | authAlert: json['authAlert'] == null 34 | ? null 35 | : AuthAlertConfig.fromJson(json['authAlert'] as Map), 36 | oauth2: json['oauth2'] == null 37 | ? null 38 | : OAuth2Config.fromJson(json['oauth2'] as Map), 39 | passwordAuth: json['passwordAuth'] == null 40 | ? null 41 | : PasswordAuthConfig.fromJson( 42 | json['passwordAuth'] as Map), 43 | mfa: json['mfa'] == null 44 | ? null 45 | : MFAConfig.fromJson(json['mfa'] as Map), 46 | otp: json['otp'] == null 47 | ? null 48 | : OTPConfig.fromJson(json['otp'] as Map), 49 | authToken: json['authToken'] == null 50 | ? null 51 | : TokenConfig.fromJson(json['authToken'] as Map), 52 | passwordResetToken: json['passwordResetToken'] == null 53 | ? null 54 | : TokenConfig.fromJson( 55 | json['passwordResetToken'] as Map), 56 | emailChangeToken: json['emailChangeToken'] == null 57 | ? null 58 | : TokenConfig.fromJson( 59 | json['emailChangeToken'] as Map), 60 | verificationToken: json['verificationToken'] == null 61 | ? null 62 | : TokenConfig.fromJson( 63 | json['verificationToken'] as Map), 64 | fileToken: json['fileToken'] == null 65 | ? null 66 | : TokenConfig.fromJson(json['fileToken'] as Map), 67 | verificationTemplate: json['verificationTemplate'] == null 68 | ? null 69 | : EmailTemplateConfig.fromJson( 70 | json['verificationTemplate'] as Map), 71 | resetPasswordTemplate: json['resetPasswordTemplate'] == null 72 | ? null 73 | : EmailTemplateConfig.fromJson( 74 | json['resetPasswordTemplate'] as Map), 75 | confirmEmailChangeTemplate: json['confirmEmailChangeTemplate'] == null 76 | ? null 77 | : EmailTemplateConfig.fromJson( 78 | json['confirmEmailChangeTemplate'] as Map), 79 | ); 80 | 81 | Map _$CollectionModelToJson(CollectionModel instance) => 82 | { 83 | 'id': instance.id, 84 | 'type': instance.type, 85 | 'created': instance.created, 86 | 'updated': instance.updated, 87 | 'name': instance.name, 88 | 'system': instance.system, 89 | 'listRule': instance.listRule, 90 | 'viewRule': instance.viewRule, 91 | 'createRule': instance.createRule, 92 | 'updateRule': instance.updateRule, 93 | 'deleteRule': instance.deleteRule, 94 | 'fields': instance.fields.map((e) => e.toJson()).toList(), 95 | 'indexes': instance.indexes, 96 | 'viewQuery': instance.viewQuery, 97 | 'authRule': instance.authRule, 98 | 'manageRule': instance.manageRule, 99 | 'authAlert': instance.authAlert?.toJson(), 100 | 'oauth2': instance.oauth2?.toJson(), 101 | 'passwordAuth': instance.passwordAuth?.toJson(), 102 | 'mfa': instance.mfa?.toJson(), 103 | 'otp': instance.otp?.toJson(), 104 | 'authToken': instance.authToken?.toJson(), 105 | 'passwordResetToken': instance.passwordResetToken?.toJson(), 106 | 'emailChangeToken': instance.emailChangeToken?.toJson(), 107 | 'verificationToken': instance.verificationToken?.toJson(), 108 | 'fileToken': instance.fileToken?.toJson(), 109 | 'verificationTemplate': instance.verificationTemplate?.toJson(), 110 | 'resetPasswordTemplate': instance.resetPasswordTemplate?.toJson(), 111 | 'confirmEmailChangeTemplate': 112 | instance.confirmEmailChangeTemplate?.toJson(), 113 | }; 114 | -------------------------------------------------------------------------------- /test/services/settings_service_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:convert"; 2 | 3 | import "package:http/http.dart" as http; 4 | import "package:http/testing.dart"; 5 | import "package:pocketbase/pocketbase.dart"; 6 | import "package:test/test.dart"; 7 | 8 | void main() { 9 | group("SettingsService", () { 10 | test("getAll()", () async { 11 | final mock = MockClient((request) async { 12 | expect(request.method, "GET"); 13 | expect( 14 | request.url.toString(), 15 | "/base/api/settings?a=1&a=2&b=%40demo", 16 | ); 17 | expect(request.headers["test"], "789"); 18 | 19 | return http.Response( 20 | jsonEncode({"a": 1, "b": false, "c": "test"}), 21 | 200, 22 | ); 23 | }); 24 | 25 | final client = PocketBase("/base", httpClientFactory: () => mock); 26 | 27 | final result = await client.settings.getAll( 28 | query: { 29 | "a": ["1", null, 2], 30 | "b": "@demo", 31 | }, 32 | headers: { 33 | "test": "789", 34 | }, 35 | ); 36 | 37 | expect( 38 | result, 39 | equals({"a": 1, "b": false, "c": "test"}), 40 | ); 41 | }); 42 | 43 | test("update()", () async { 44 | final mock = MockClient((request) async { 45 | expect(request.method, "PATCH"); 46 | expect(request.body, jsonEncode({"test_body": 123})); 47 | expect( 48 | request.url.toString(), 49 | "/base/api/settings?a=1&a=2&b=%40demo", 50 | ); 51 | expect(request.headers["test"], "789"); 52 | 53 | return http.Response( 54 | jsonEncode({"a": 1, "b": false, "c": "test"}), 55 | 200, 56 | ); 57 | }); 58 | 59 | final client = PocketBase("/base", httpClientFactory: () => mock); 60 | 61 | final result = await client.settings.update( 62 | query: { 63 | "a": ["1", null, 2], 64 | "b": "@demo", 65 | }, 66 | body: { 67 | "test_body": 123, 68 | }, 69 | headers: { 70 | "test": "789", 71 | }, 72 | ); 73 | 74 | expect( 75 | result, 76 | equals({"a": 1, "b": false, "c": "test"}), 77 | ); 78 | }); 79 | 80 | test("testS3()", () async { 81 | final mock = MockClient((request) async { 82 | expect(request.method, "POST"); 83 | expect( 84 | request.body, 85 | jsonEncode({ 86 | "test_body": 123, 87 | "filesystem": "@demo", 88 | }), 89 | ); 90 | expect( 91 | request.url.toString(), 92 | "/base/api/settings/test/s3?a=1&a=2&b=%40demo", 93 | ); 94 | expect(request.headers["test"], "789"); 95 | 96 | return http.Response("", 204); 97 | }); 98 | 99 | final client = PocketBase("/base", httpClientFactory: () => mock); 100 | 101 | await client.settings.testS3( 102 | filesystem: "@demo", 103 | query: { 104 | "a": ["1", null, 2], 105 | "b": "@demo", 106 | }, 107 | body: { 108 | "test_body": 123, 109 | }, 110 | headers: { 111 | "test": "789", 112 | }, 113 | ); 114 | }); 115 | 116 | test("testEmail()", () async { 117 | final mock = MockClient((request) async { 118 | expect(request.method, "POST"); 119 | expect( 120 | request.body, 121 | jsonEncode({ 122 | "test_body": 123, 123 | "email": "test@example.com", 124 | "template": "test_template", 125 | "collection": "test_collection", 126 | })); 127 | expect( 128 | request.url.toString(), 129 | "/base/api/settings/test/email?a=1&a=2&b=%40demo", 130 | ); 131 | expect(request.headers["test"], "789"); 132 | 133 | return http.Response("", 204); 134 | }); 135 | 136 | final client = PocketBase("/base", httpClientFactory: () => mock); 137 | 138 | await client.settings.testEmail( 139 | "test@example.com", 140 | "test_template", 141 | collection: "test_collection", 142 | query: { 143 | "a": ["1", null, 2], 144 | "b": "@demo", 145 | }, 146 | body: { 147 | "test_body": 123, 148 | }, 149 | headers: { 150 | "test": "789", 151 | }, 152 | ); 153 | }); 154 | 155 | test("generateAppleClientSecret()", () async { 156 | final mock = MockClient((request) async { 157 | expect(request.method, "POST"); 158 | expect( 159 | request.body, 160 | jsonEncode({ 161 | "test_body": 123, 162 | "clientId": "1", 163 | "teamId": "2", 164 | "keyId": "3", 165 | "privateKey": "4", 166 | "duration": 5, 167 | })); 168 | expect( 169 | request.url.toString(), 170 | "/base/api/settings/apple/generate-client-secret?a=1&a=2&b=%40demo", 171 | ); 172 | expect(request.headers["test"], "789"); 173 | 174 | return http.Response( 175 | jsonEncode({"secret": "test"}), 176 | 200, 177 | ); 178 | }); 179 | 180 | final client = PocketBase("/base", httpClientFactory: () => mock); 181 | 182 | await client.settings.generateAppleClientSecret( 183 | "1", 184 | "2", 185 | "3", 186 | "4", 187 | 5, 188 | query: { 189 | "a": ["1", null, 2], 190 | "b": "@demo", 191 | }, 192 | body: { 193 | "test_body": 123, 194 | }, 195 | headers: { 196 | "test": "789", 197 | }, 198 | ); 199 | }); 200 | }); 201 | } 202 | -------------------------------------------------------------------------------- /lib/src/multipart_request.dart: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------- 2 | // This is a almost identical copy of http.MultipartRequest in order 3 | // to add support for repeatable fields (aka. array values). 4 | // 5 | // See https://github.com/dart-lang/http/issues/277 6 | // ------------------------------------------------------------------- 7 | 8 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 9 | // for details. All rights reserved. Use of this source code is governed by a 10 | // BSD-style license that can be found in the LICENSE file. 11 | 12 | // ignore_for_file: prefer_double_quotes, implementation_imports 13 | 14 | import "dart:async"; 15 | import "dart:convert"; 16 | import "dart:math"; 17 | import "package:http/http.dart"; 18 | import "package:http/src/boundary_characters.dart"; 19 | import "package:http/src/utils.dart"; 20 | 21 | final _newlineRegExp = RegExp(r"\r\n|\r|\n"); 22 | 23 | /// A `multipart/form-data` request. 24 | /// 25 | /// Such a request has both string [fields], which function as normal form 26 | /// fields, and (potentially streamed) binary [files]. 27 | /// 28 | /// This request automatically sets the Content-Type header to 29 | /// `multipart/form-data`. This value will override any value set by the user. 30 | /// 31 | /// var uri = Uri.https('example.com', 'create'); 32 | /// var request = http.MultipartRequest('POST', uri) 33 | /// ..fields['user'] = 'nweiz@google.com' 34 | /// ..files.add(await http.MultipartFile.fromPath( 35 | /// 'package', 'build/package.tar.gz', 36 | /// contentType: MediaType('application', 'x-tar'))); 37 | /// var response = await request.send(); 38 | /// if (response.statusCode == 200) print('Uploaded!'); 39 | class MultipartRequest extends BaseRequest { 40 | /// The total length of the multipart boundaries used when building the 41 | /// request body. 42 | /// 43 | /// According to http://tools.ietf.org/html/rfc1341.html, this can't be longer 44 | /// than 70. 45 | static const int _boundaryLength = 70; 46 | 47 | static final Random _random = Random(); 48 | 49 | /// The form fields to send for this request. 50 | final fields = >{}; 51 | 52 | /// The list of files to upload for this request. 53 | final files = []; 54 | 55 | MultipartRequest(super.method, super.url); 56 | 57 | /// The total length of the request body, in bytes. 58 | /// 59 | /// This is calculated from [fields] and [files] and cannot be set manually. 60 | @override 61 | int get contentLength { 62 | var length = 0; 63 | 64 | fields.forEach((name, values) { 65 | for (final value in values) { 66 | length += '--'.length + 67 | _boundaryLength + 68 | '\r\n'.length + 69 | utf8.encode(_headerForField(name, value)).length + 70 | utf8.encode(value).length + 71 | '\r\n'.length; 72 | } 73 | }); 74 | 75 | for (var file in files) { 76 | length += '--'.length + 77 | _boundaryLength + 78 | '\r\n'.length + 79 | utf8.encode(_headerForFile(file)).length + 80 | file.length + 81 | '\r\n'.length; 82 | } 83 | 84 | return length + '--'.length + _boundaryLength + '--\r\n'.length; 85 | } 86 | 87 | @override 88 | set contentLength(int? value) { 89 | throw UnsupportedError('Cannot set the contentLength property of ' 90 | 'multipart requests.'); 91 | } 92 | 93 | /// Freezes all mutable fields and returns a single-subscription [ByteStream] 94 | /// that will emit the request body. 95 | @override 96 | ByteStream finalize() { 97 | // TODO: freeze fields and files 98 | final boundary = _boundaryString(); 99 | headers['content-type'] = 'multipart/form-data; boundary=$boundary'; 100 | super.finalize(); 101 | return ByteStream(_finalize(boundary)); 102 | } 103 | 104 | Stream> _finalize(String boundary) async* { 105 | const line = [13, 10]; // \r\n 106 | final separator = utf8.encode('--$boundary\r\n'); 107 | final close = utf8.encode('--$boundary--\r\n'); 108 | 109 | for (var field in fields.entries) { 110 | for (final v in field.value) { 111 | yield separator; 112 | yield utf8.encode(_headerForField(field.key, v)); 113 | yield utf8.encode(v); 114 | yield line; 115 | } 116 | } 117 | 118 | for (final file in files) { 119 | yield separator; 120 | yield utf8.encode(_headerForFile(file)); 121 | yield* file.finalize(); 122 | yield line; 123 | } 124 | yield close; 125 | } 126 | 127 | /// Returns the header string for a field. 128 | /// 129 | /// The return value is guaranteed to contain only ASCII characters. 130 | String _headerForField(String name, String value) { 131 | var header = 132 | 'content-disposition: form-data; name="${_browserEncode(name)}"'; 133 | if (!isPlainAscii(value)) { 134 | header = '$header\r\n' 135 | 'content-type: text/plain; charset=utf-8\r\n' 136 | 'content-transfer-encoding: binary'; 137 | } 138 | return '$header\r\n\r\n'; 139 | } 140 | 141 | /// Returns the header string for a file. 142 | /// 143 | /// The return value is guaranteed to contain only ASCII characters. 144 | String _headerForFile(MultipartFile file) { 145 | var header = 'content-type: ${file.contentType}\r\n' 146 | 'content-disposition: form-data; name="${_browserEncode(file.field)}"'; 147 | 148 | if (file.filename != null) { 149 | header = '$header; filename="${_browserEncode(file.filename!)}"'; 150 | } 151 | return '$header\r\n\r\n'; 152 | } 153 | 154 | /// Encode [value] in the same way browsers do. 155 | String _browserEncode(String value) => 156 | // http://tools.ietf.org/html/rfc2388 mandates some complex encodings for 157 | // field names and file names, but in practice user agents seem not to 158 | // follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as 159 | // `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII 160 | // characters). We follow their behavior. 161 | value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22'); 162 | 163 | /// Returns a randomly-generated multipart boundary string 164 | String _boundaryString() { 165 | const prefix = 'dart-http-boundary-'; 166 | final list = List.generate( 167 | _boundaryLength - prefix.length, 168 | (index) => 169 | boundaryCharacters[_random.nextInt(boundaryCharacters.length)], 170 | growable: false); 171 | return '$prefix${String.fromCharCodes(list)}'; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /lib/src/services/base_crud_service.dart: -------------------------------------------------------------------------------- 1 | import "package:http/http.dart" as http; 2 | 3 | import "../client_exception.dart"; 4 | import "../dtos/jsonable.dart"; 5 | import "../dtos/result_list.dart"; 6 | import "base_service.dart"; 7 | 8 | /// Base generic crud service that is intented to be used by all other 9 | /// crud services. 10 | abstract class BaseCrudService extends BaseService { 11 | BaseCrudService(super.client); 12 | 13 | /// The base url path that is used by the service. 14 | String get baseCrudPath; 15 | 16 | /// The factory function (eg. `fromJson()`) that will be used to 17 | /// decode the returned items from the crud endpoints. 18 | M itemFactoryFunc(Map json); 19 | 20 | /// Returns a list with all items batch fetched at once. 21 | Future> getFullList({ 22 | int batch = 500, 23 | String? expand, 24 | String? filter, 25 | String? sort, 26 | String? fields, 27 | Map query = const {}, 28 | Map headers = const {}, 29 | }) { 30 | final result = []; 31 | 32 | Future> request(int page) async { 33 | return getList( 34 | skipTotal: true, 35 | page: page, 36 | perPage: batch, 37 | filter: filter, 38 | sort: sort, 39 | fields: fields, 40 | expand: expand, 41 | query: query, 42 | headers: headers, 43 | ).then((list) { 44 | result.addAll(list.items); 45 | 46 | if (list.items.length == list.perPage) { 47 | return request(page + 1); 48 | } 49 | 50 | return result; 51 | }); 52 | } 53 | 54 | return request(1); 55 | } 56 | 57 | /// Returns paginated items list. 58 | Future> getList({ 59 | int page = 1, 60 | int perPage = 30, 61 | bool skipTotal = false, 62 | String? expand, 63 | String? filter, 64 | String? sort, 65 | String? fields, 66 | Map query = const {}, 67 | Map headers = const {}, 68 | }) { 69 | final enrichedQuery = Map.of(query); 70 | enrichedQuery["page"] = page; 71 | enrichedQuery["perPage"] = perPage; 72 | enrichedQuery["filter"] ??= filter; 73 | enrichedQuery["sort"] ??= sort; 74 | enrichedQuery["expand"] ??= expand; 75 | enrichedQuery["fields"] ??= fields; 76 | enrichedQuery["skipTotal"] ??= skipTotal; 77 | 78 | return client 79 | .send>( 80 | baseCrudPath, 81 | query: enrichedQuery, 82 | headers: headers, 83 | ) 84 | .then((data) => ResultList.fromJson(data, itemFactoryFunc)); 85 | } 86 | 87 | /// Returns single item by its id. 88 | /// 89 | /// Throws 404 `ClientException` in case an empty `id` is provided. 90 | Future getOne( 91 | String id, { 92 | String? expand, 93 | String? fields, 94 | Map query = const {}, 95 | Map headers = const {}, 96 | }) async { 97 | if (id.isEmpty) { 98 | throw ClientException( 99 | url: client.buildURL("$baseCrudPath/"), 100 | statusCode: 404, 101 | response: { 102 | "code": 404, 103 | "message": "Missing required record id.", 104 | "data": {}, 105 | }, 106 | ); 107 | } 108 | 109 | final enrichedQuery = Map.of(query); 110 | enrichedQuery["expand"] ??= expand; 111 | enrichedQuery["fields"] ??= fields; 112 | 113 | return client 114 | .send>( 115 | "$baseCrudPath/${Uri.encodeComponent(id)}", 116 | query: enrichedQuery, 117 | headers: headers, 118 | ) 119 | .then(itemFactoryFunc); 120 | } 121 | 122 | /// Returns the first found list item by the specified filter. 123 | /// 124 | /// Internally it calls `getList()` and returns its first item. 125 | /// 126 | /// For consistency with `getOne`, this method will throw a 404 127 | /// `ClientException` if no item was found. 128 | Future getFirstListItem( 129 | String filter, { 130 | String? expand, 131 | String? fields, 132 | Map query = const {}, 133 | Map headers = const {}, 134 | }) { 135 | return getList( 136 | perPage: 1, 137 | skipTotal: true, 138 | filter: filter, 139 | expand: expand, 140 | fields: fields, 141 | query: query, 142 | headers: headers, 143 | ).then((result) { 144 | if (result.items.isEmpty) { 145 | throw ClientException( 146 | statusCode: 404, 147 | response: { 148 | "code": 404, 149 | "message": "The requested resource wasn't found.", 150 | "data": {}, 151 | }, 152 | ); 153 | } 154 | 155 | return result.items.first; 156 | }); 157 | } 158 | 159 | /// Creates a new item. 160 | Future create({ 161 | Map body = const {}, 162 | Map query = const {}, 163 | List files = const [], 164 | Map headers = const {}, 165 | String? expand, 166 | String? fields, 167 | }) { 168 | final enrichedQuery = Map.of(query); 169 | enrichedQuery["expand"] ??= expand; 170 | enrichedQuery["fields"] ??= fields; 171 | 172 | return client 173 | .send>( 174 | baseCrudPath, 175 | method: "POST", 176 | body: body, 177 | query: enrichedQuery, 178 | files: files, 179 | headers: headers, 180 | ) 181 | .then(itemFactoryFunc); 182 | } 183 | 184 | /// Updates an single item by its id. 185 | Future update( 186 | String id, { 187 | Map body = const {}, 188 | Map query = const {}, 189 | List files = const [], 190 | Map headers = const {}, 191 | String? expand, 192 | String? fields, 193 | }) { 194 | final enrichedQuery = Map.of(query); 195 | enrichedQuery["expand"] ??= expand; 196 | enrichedQuery["fields"] ??= fields; 197 | 198 | return client 199 | .send>( 200 | "$baseCrudPath/${Uri.encodeComponent(id)}", 201 | method: "PATCH", 202 | body: body, 203 | query: enrichedQuery, 204 | files: files, 205 | headers: headers, 206 | ) 207 | .then(itemFactoryFunc); 208 | } 209 | 210 | /// Deletes an single item by its id. 211 | Future delete( 212 | String id, { 213 | Map body = const {}, 214 | Map query = const {}, 215 | Map headers = const {}, 216 | }) { 217 | return client.send( 218 | "$baseCrudPath/${Uri.encodeComponent(id)}", 219 | method: "DELETE", 220 | body: body, 221 | query: query, 222 | headers: headers, 223 | ); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /lib/src/services/batch_service.dart: -------------------------------------------------------------------------------- 1 | import "package:http/http.dart" as http; 2 | 3 | import "../client.dart"; 4 | import "../dtos/batch_result.dart"; 5 | import "base_service.dart"; 6 | 7 | /// The service that handles the **Batch/transactional APIs**. 8 | /// 9 | /// Usually shouldn't be initialized manually and instead 10 | /// [PocketBase.createBatch()] should be used. 11 | class BatchService extends BaseService { 12 | final List<_BatchRequest> _requests = []; 13 | final Map _subs = {}; 14 | final dummyClient = PocketBase("/"); 15 | 16 | BatchService(super.client); 17 | 18 | /// Starts constructing a batch request entry for the specified collection. 19 | SubBatchService collection(String collectionIdOrName) { 20 | var subService = _subs[collectionIdOrName]; 21 | 22 | if (subService == null) { 23 | subService = SubBatchService(this, collectionIdOrName); 24 | _subs[collectionIdOrName] = subService; 25 | } 26 | 27 | return subService; 28 | } 29 | 30 | /// Sends the batch requests. 31 | Future> send({ 32 | Map body = const {}, 33 | Map query = const {}, 34 | Map headers = const {}, 35 | }) { 36 | final files = []; 37 | final jsonBody = >[]; 38 | 39 | for (var i = 0; i < _requests.length; i++) { 40 | final req = _requests[i]; 41 | 42 | jsonBody.add({ 43 | "method": req.method, 44 | "url": req.url, 45 | "headers": req.headers, 46 | "body": req.body, 47 | }); 48 | 49 | for (final reqFile in req.files) { 50 | // note: MultipartFile doesn't allow changing the field name 51 | files.add(http.MultipartFile( 52 | "requests.$i.${reqFile.field}", 53 | reqFile.finalize(), 54 | reqFile.length, 55 | filename: reqFile.filename, 56 | contentType: reqFile.contentType, 57 | )); 58 | } 59 | } 60 | 61 | final enrichedBody = Map.of(body); 62 | enrichedBody["requests"] = jsonBody; 63 | 64 | return client 65 | .send>( 66 | "/api/batch", 67 | method: "POST", 68 | files: files, 69 | headers: headers, 70 | query: query, 71 | body: enrichedBody, 72 | ) 73 | .then((data) => data 74 | .map((elem) => 75 | BatchResult.fromJson(elem as Map? ?? {})) 76 | .toList()); 77 | } 78 | } 79 | 80 | class SubBatchService { 81 | final BatchService _batch; 82 | final String _collectionIdOrName; 83 | 84 | SubBatchService(this._batch, this._collectionIdOrName); 85 | 86 | /// Registers a record upsert request into the current batch queue. 87 | /// 88 | /// The request will be executed as update if `bodyParams` have a 89 | /// valid existing record `id` value, otherwise - create. 90 | void upsert({ 91 | Map body = const {}, 92 | Map query = const {}, 93 | List files = const [], 94 | Map headers = const {}, 95 | String? expand, 96 | String? fields, 97 | }) { 98 | final enrichedQuery = Map.of(query); 99 | enrichedQuery["expand"] ??= expand; 100 | enrichedQuery["fields"] ??= fields; 101 | 102 | final request = _BatchRequest( 103 | method: "PUT", 104 | files: files, 105 | url: _batch.dummyClient 106 | .buildURL( 107 | "/api/collections/${Uri.encodeComponent(_collectionIdOrName)}/records", 108 | enrichedQuery, 109 | ) 110 | .toString(), 111 | headers: headers, 112 | body: body, 113 | ); 114 | 115 | _batch._requests.add(request); 116 | } 117 | 118 | /// Registers a record create request into the current batch queue. 119 | void create({ 120 | Map body = const {}, 121 | Map query = const {}, 122 | List files = const [], 123 | Map headers = const {}, 124 | String? expand, 125 | String? fields, 126 | }) { 127 | final enrichedQuery = Map.of(query); 128 | enrichedQuery["expand"] ??= expand; 129 | enrichedQuery["fields"] ??= fields; 130 | 131 | final request = _BatchRequest( 132 | method: "POST", 133 | files: files, 134 | url: _batch.dummyClient 135 | .buildURL( 136 | "/api/collections/${Uri.encodeComponent(_collectionIdOrName)}/records", 137 | enrichedQuery, 138 | ) 139 | .toString(), 140 | headers: headers, 141 | body: body, 142 | ); 143 | 144 | _batch._requests.add(request); 145 | } 146 | 147 | /// Registers a record update request into the current batch queue. 148 | void update( 149 | String recordId, { 150 | Map body = const {}, 151 | Map query = const {}, 152 | List files = const [], 153 | Map headers = const {}, 154 | String? expand, 155 | String? fields, 156 | }) { 157 | final enrichedQuery = Map.of(query); 158 | enrichedQuery["expand"] ??= expand; 159 | enrichedQuery["fields"] ??= fields; 160 | 161 | final request = _BatchRequest( 162 | method: "PATCH", 163 | files: files, 164 | url: _batch.dummyClient 165 | .buildURL( 166 | "/api/collections/${Uri.encodeComponent(_collectionIdOrName)}/records/${Uri.encodeComponent(recordId)}", 167 | enrichedQuery, 168 | ) 169 | .toString(), 170 | headers: headers, 171 | body: body, 172 | ); 173 | 174 | _batch._requests.add(request); 175 | } 176 | 177 | /// Registers a record delete request into the current batch queue. 178 | void delete( 179 | String recordId, { 180 | Map body = const {}, 181 | Map query = const {}, 182 | Map headers = const {}, 183 | }) { 184 | final request = _BatchRequest( 185 | method: "DELETE", 186 | url: _batch.dummyClient 187 | .buildURL( 188 | "/api/collections/${Uri.encodeComponent(_collectionIdOrName)}/records/${Uri.encodeComponent(recordId)}", 189 | query, 190 | ) 191 | .toString(), 192 | headers: headers, 193 | body: body, 194 | ); 195 | 196 | _batch._requests.add(request); 197 | } 198 | } 199 | 200 | class _BatchRequest { 201 | String method; 202 | String url; 203 | Map headers; 204 | Map body; 205 | List files; 206 | 207 | _BatchRequest({ 208 | String? method, 209 | String? url, 210 | Map? headers, 211 | Map? body, 212 | List? files, 213 | }) : method = method ?? "", 214 | url = url ?? "", 215 | headers = headers ?? {}, 216 | body = body ?? {}, 217 | files = files ?? []; 218 | } 219 | -------------------------------------------------------------------------------- /lib/src/sse/sse_client.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | import "dart:convert"; 3 | 4 | import "package:http/http.dart" as http; 5 | 6 | import "../client_exception.dart"; 7 | 8 | import "sse_message.dart"; 9 | 10 | /// Very rudimentary streamed response http client wrapper compatible 11 | /// with the SSE message format. 12 | /// 13 | /// The client supports auto reconnect based on the `retry` event message value 14 | /// (default to max 5 attempts with 5s cool down in between). 15 | /// 16 | /// Example usage: 17 | /// 18 | /// ```dart 19 | /// final sse = SseClient("https://example.com") 20 | /// 21 | /// // subscribe to any message 22 | /// sse.onMessage.listen((msg) { 23 | /// print(msg); 24 | /// }); 25 | /// 26 | /// // subscribe to specific event(s) only 27 | /// sse.onMessage.where((msg) => msg.event == "PB_CONNECT").listen((msg) { 28 | /// print(msg); 29 | /// }); 30 | /// 31 | /// // close the connection and clean up any resources associated with it 32 | /// sse.close(); 33 | /// ``` 34 | class SseClient { 35 | /// List with default stepped retry timeouts (in ms). 36 | static const List defaultRetryTimeouts = [ 37 | 200, 38 | 300, 39 | 500, 40 | 1000, 41 | 1200, 42 | 1500, 43 | 2000, 44 | ]; 45 | 46 | Timer? _retryTimer; 47 | int _retryAttempts = 0; 48 | num _maxRetry = double.infinity; 49 | 50 | /// Indicates whether the client was closed. 51 | bool get isClosed => _isClosed; 52 | bool _isClosed = false; 53 | 54 | /// Callback function that is triggered on client close. 55 | late final void Function()? _onClose; 56 | 57 | /// Callback function that is triggered on each error connect attempt. 58 | late final void Function(dynamic err)? _onError; 59 | 60 | /// The local streamed http response subscription. 61 | StreamSubscription? _responseStreamSubscription; 62 | 63 | /// The stream where you"ll receive the parsed SSE event messages. 64 | Stream get onMessage => _messageStreamController.stream; 65 | final _messageStreamController = StreamController.broadcast(); 66 | 67 | /// The regex used to parse a single line of the streamed response message. 68 | final _lineRegex = RegExp(r"^(\w+)[\s\:]+(.*)?$"); 69 | 70 | final String _url; 71 | 72 | late final http.Client _httpClient; 73 | 74 | /// Initializes the client and connects to the provided url. 75 | SseClient( 76 | this._url, { 77 | num maxRetry = double.infinity, 78 | void Function()? onClose, 79 | void Function(dynamic err)? onError, 80 | 81 | /// The underlying http client that will be used to send the request. 82 | /// This is used primarily for the unit tests. 83 | http.Client Function()? httpClientFactory, 84 | }) { 85 | _maxRetry = maxRetry; 86 | _onClose = onClose; 87 | _onError = onError; 88 | _httpClient = httpClientFactory?.call() ?? http.Client(); 89 | _init(); 90 | } 91 | 92 | /// Closes the client and cleans up any resources associated with it. 93 | /// 94 | /// The method is also called internally on disconnect after 95 | /// all allowed retry attempts have failed. 96 | /// 97 | /// NB! After calling this method the client cannot be used anymore. 98 | void close() { 99 | if (isClosed) { 100 | return; // already closed 101 | } 102 | 103 | _isClosed = true; 104 | 105 | _retryTimer?.cancel(); 106 | 107 | _responseStreamSubscription?.cancel(); 108 | 109 | if (!_messageStreamController.isClosed) { 110 | _messageStreamController.close(); 111 | } 112 | 113 | _httpClient.close(); 114 | 115 | _onClose?.call(); 116 | } 117 | 118 | void _init() async { 119 | if (isClosed) { 120 | return; // already closed 121 | } 122 | 123 | var sseMessage = SseMessage(); 124 | 125 | final url = Uri.parse(_url); 126 | final request = http.Request("GET", url); 127 | try { 128 | final response = await _httpClient.send(request); 129 | 130 | if (response.statusCode >= 400) { 131 | final responseStr = await response.stream.bytesToString(); 132 | final responseData = responseStr != "" ? jsonDecode(responseStr) : null; 133 | throw ClientException( 134 | url: url, 135 | statusCode: response.statusCode, 136 | response: responseData is Map ? responseData : {}, 137 | ); 138 | } 139 | 140 | // resets 141 | _retryAttempts = 0; 142 | sseMessage = SseMessage(); 143 | await _responseStreamSubscription?.cancel(); 144 | 145 | _responseStreamSubscription = response.stream 146 | .transform(const Utf8Decoder()) 147 | .transform(const LineSplitter()) 148 | .listen( 149 | (line) { 150 | // message end detected 151 | if (line.isEmpty) { 152 | _messageStreamController.add(sseMessage); 153 | sseMessage = SseMessage(); // reset for the next chunk 154 | return; 155 | } 156 | 157 | final match = _lineRegex.firstMatch(line); 158 | if (match == null) { 159 | // ignore invalid lines 160 | // (some servers may send a different formatted line as a ping) 161 | return; 162 | } 163 | 164 | final field = match.group(1) ?? ""; 165 | final value = match.group(2) ?? ""; 166 | 167 | if (field == "id") { 168 | sseMessage.id = value; 169 | return; 170 | } 171 | 172 | if (field == "event") { 173 | sseMessage.event = value; 174 | return; 175 | } 176 | 177 | if (field == "retry") { 178 | sseMessage.retry = int.tryParse(value) ?? 0; 179 | return; 180 | } 181 | 182 | if (field == "data") { 183 | sseMessage.data = value; 184 | return; 185 | } 186 | }, 187 | onError: (dynamic err) { 188 | // usually triggered on abruptly connection termination 189 | // (eg. when the server goes down) 190 | _onError?.call(err); 191 | _reconnect(sseMessage.retry); 192 | }, 193 | onDone: () { 194 | // usually triggered on graceful connection termination 195 | // (eg. when the server stops streaming in case on idle client) 196 | _onError?.call(null); 197 | _reconnect(sseMessage.retry); 198 | }, 199 | ); 200 | } catch (err) { 201 | // most likely the client failed to establish a connection with the server 202 | _onError?.call(err); 203 | _reconnect(sseMessage.retry); 204 | } 205 | } 206 | 207 | void _reconnect([int retryTimeout = 0]) { 208 | if (_retryAttempts >= _maxRetry) { 209 | // no more retries 210 | close(); 211 | return; 212 | } 213 | 214 | if (retryTimeout <= 0) { 215 | if (_retryAttempts > defaultRetryTimeouts.length - 1) { 216 | retryTimeout = defaultRetryTimeouts.last; 217 | } else { 218 | retryTimeout = defaultRetryTimeouts[_retryAttempts]; 219 | } 220 | } 221 | 222 | // cancel previous timer (if any) 223 | _retryTimer?.cancel(); 224 | 225 | _retryTimer = Timer(Duration(milliseconds: retryTimeout), () { 226 | _retryAttempts++; 227 | _init(); 228 | }); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /test/dtos/record_model_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: deprecated_member_use_from_same_package 2 | 3 | import "package:pocketbase/pocketbase.dart"; 4 | import "package:test/test.dart"; 5 | 6 | void main() { 7 | group("RecordModel", () { 8 | test("fromJson() and toJson()", () { 9 | final json = { 10 | "id": "test_id", 11 | "created": "test_created", 12 | "updated": "test_updated", 13 | "collectionId": "test_collectionId", 14 | "collectionName": "test_collectionName", 15 | "expand": { 16 | "one": RecordModel({"id": "1"}).toJson(), 17 | "many": [ 18 | RecordModel({"id": "2"}).toJson(), 19 | RecordModel.fromJson({ 20 | "id": "3", 21 | "expand": { 22 | "recursive": RecordModel({"id": "4"}).toJson(), 23 | }, 24 | }).toJson(), 25 | ], 26 | }, 27 | "a": 1, 28 | "b": "test", 29 | "c": true, 30 | }; 31 | 32 | final model = RecordModel.fromJson(json); 33 | 34 | expect(model.data, json); 35 | expect(model.id, "test_id"); 36 | expect(model.created, "test_created"); 37 | expect(model.updated, "test_updated"); 38 | expect(model.collectionId, "test_collectionId"); 39 | expect(model.collectionName, "test_collectionName"); 40 | expect(model.expand["one"]?.length, 1); 41 | expect(model.expand["one"]?[0].id, "1"); 42 | expect(model.expand["many"]?.length, 2); 43 | expect(model.expand["many"]?[0].id, "2"); 44 | expect(model.expand["many"]?[1].id, "3"); 45 | expect(model.expand["many"]?[1].expand["recursive"]?.length, 1); 46 | expect(model.expand["many"]?[1].expand["recursive"]?[0].id, "4"); 47 | 48 | // to json 49 | expect(model.toJson(), json); 50 | }); 51 | 52 | test("set()", () { 53 | final model = RecordModel({ 54 | "a": 123, 55 | }) 56 | ..set("a", 456) 57 | ..set("b", 789); 58 | 59 | expect(model.data["a"], 456); 60 | expect(model.data["b"], 789); 61 | }); 62 | 63 | test("get()", () { 64 | final model = RecordModel({ 65 | "a": null, 66 | "b": 1.5, 67 | "c": "test", 68 | "d": false, 69 | "e": ["1", "2", "3"], 70 | "f": {"test": 123}, 71 | "g": [ 72 | {"test": 123} 73 | ], 74 | }); 75 | 76 | expect(model.get("unknown"), null); 77 | expect(model.get("unknown", "missing!"), "missing!"); 78 | expect(model.get("b"), 1.5); 79 | expect(model.get("b"), 1.5); 80 | expect(model.get("b"), 1); 81 | expect(model.get("c"), "test"); 82 | expect(model.get>("e"), ["1", "2", "3"]); 83 | expect(model.get>("f"), {"test": 123}); 84 | expect(model.get("f.test"), 123); 85 | expect(model.get("f.test_2", -1), -1); 86 | expect(model.get("f").toJson(), {"test": 123}); 87 | // lists 88 | expect(model.get>("missing"), []); 89 | expect(model.get>("b"), [1]); 90 | expect(model.get>("d"), [0]); 91 | expect(model.get>("missing"), []); 92 | expect(model.get>("e"), [1, 2, 3]); 93 | expect( 94 | model.get>("g").map((r) => r.toJson()).toList(), [ 95 | {"test": 123} 96 | ]); 97 | expect( 98 | model.get>("g").map((r) => r?.toJson()).toList(), [ 99 | {"test": 123} 100 | ]); 101 | expect( 102 | model.get>("f").map((r) => r.toJson()).toList(), [ 103 | {"test": 123} 104 | ]); 105 | expect(model.get>("d"), [0]); 106 | // existing field as nullable type 107 | expect(model.get("b"), 1.5); 108 | expect(model.get("c"), "test"); 109 | expect(model.get("d"), false); 110 | expect(model.get?>("e"), ["1", "2", "3"]); 111 | expect(model.get?>("f"), {"test": 123}); 112 | expect(model.get("f.test"), 123); 113 | expect(model.get("f")?.toJson(), {"test": 123}); 114 | // non-existing field as nullable type 115 | expect(model.get("b_2"), null); 116 | expect(model.get("c_2"), null); 117 | expect(model.get("d_2"), null); 118 | expect(model.get?>("e_2"), null); 119 | expect(model.get?>("f_2"), null); 120 | expect(model.get("f.test_2"), null); 121 | expect(model.get("f_2"), null); 122 | }); 123 | 124 | test("getStringValue()", () { 125 | final model = RecordModel({ 126 | "a": null, 127 | "b": 1, 128 | "c": "test", 129 | "d": false, 130 | "e": ["1", 2, 3], 131 | "f": {"test": 123}, 132 | }); 133 | expect(model.getStringValue("a"), ""); 134 | expect(model.getStringValue("b"), "1"); 135 | expect(model.getStringValue("c"), "test"); 136 | expect(model.getStringValue("d"), "false"); 137 | expect(model.getStringValue("e"), "[1, 2, 3]"); 138 | expect(model.getStringValue("f"), "{test: 123}"); 139 | }); 140 | 141 | test("getListValue()", () { 142 | final model = RecordModel({ 143 | "a": null, 144 | "b": 1, 145 | "c": "test", 146 | "d": false, 147 | "e": ["1", 2, 3], 148 | }); 149 | expect(model.getListValue("a"), []); // invalid type 150 | expect(model.getListValue("a"), []); 151 | expect(model.getListValue("b"), [true]); 152 | expect(model.getListValue("b"), [1]); 153 | expect(model.getListValue("b"), [1]); 154 | expect(model.getListValue("c"), ["test"]); 155 | expect(model.getListValue("d"), [false]); 156 | expect(model.getListValue("e"), ["1", 2, 3]); 157 | }); 158 | 159 | test("getBoolValue()", () { 160 | final model = RecordModel({ 161 | "a": null, 162 | "b1": 1, 163 | "b2": 0, 164 | "b3": -1, 165 | "c1": "", 166 | "c2": "false", 167 | "c3": "0", 168 | "c4": "test", 169 | "d": false, 170 | "e1": [], 171 | "e2": ["1", 2, 3], 172 | "f1": {}, 173 | "f2": {"test": 123}, 174 | }); 175 | expect(model.getBoolValue("a"), false); 176 | expect(model.getBoolValue("b1"), true); 177 | expect(model.getBoolValue("b2"), false); 178 | expect(model.getBoolValue("b3"), true); 179 | expect(model.getBoolValue("c1"), false); 180 | expect(model.getBoolValue("c2"), false); 181 | expect(model.getBoolValue("c3"), false); 182 | expect(model.getBoolValue("c4"), true); 183 | expect(model.getBoolValue("d"), false); 184 | expect(model.getBoolValue("e1"), false); 185 | expect(model.getBoolValue("e2"), true); 186 | expect(model.getBoolValue("f1"), false); 187 | expect(model.getBoolValue("f2"), true); 188 | }); 189 | 190 | test("getIntValue()", () { 191 | final model = RecordModel({ 192 | "a": null, 193 | "b1": 1, 194 | "b2": 2.4, 195 | "c1": "", 196 | "c2": "false", 197 | "c3": "123", 198 | "c4": "test", 199 | "d1": false, 200 | "d2": true, 201 | "e1": [], 202 | "e2": ["1", 2, 3], 203 | "f1": {}, 204 | "f2": {"a": 123, "b": 456}, 205 | }); 206 | expect(model.getIntValue("a"), 0); 207 | expect(model.getIntValue("b1"), 1); 208 | expect(model.getIntValue("b2"), 2); 209 | expect(model.getIntValue("c1"), 0); 210 | expect(model.getIntValue("c2"), 0); 211 | expect(model.getIntValue("c3"), 123); 212 | expect(model.getIntValue("c4"), 0); 213 | expect(model.getIntValue("d1"), 0); 214 | expect(model.getIntValue("d2"), 1); 215 | expect(model.getIntValue("e1"), 0); 216 | expect(model.getIntValue("e2"), 3); 217 | expect(model.getIntValue("f1"), 0); 218 | expect(model.getIntValue("f2"), 2); 219 | }); 220 | 221 | test("getDoubleValue()", () { 222 | final model = RecordModel({ 223 | "a": null, 224 | "b1": 1, 225 | "b2": 2.4, 226 | "c1": "", 227 | "c2": "false", 228 | "c3": "123.4", 229 | "c4": "test", 230 | "d1": false, 231 | "d2": true, 232 | "e1": [], 233 | "e2": ["1", 2, 3], 234 | "f1": {}, 235 | "f2": {"a": 123, "b": 456}, 236 | }); 237 | expect(model.getDoubleValue("a"), 0); 238 | expect(model.getDoubleValue("b1"), 1); 239 | expect(model.getDoubleValue("b2"), 2.4); 240 | expect(model.getDoubleValue("c1"), 0); 241 | expect(model.getDoubleValue("c2"), 0); 242 | expect(model.getDoubleValue("c3"), 123.4); 243 | expect(model.getDoubleValue("c4"), 0); 244 | expect(model.getDoubleValue("d1"), 0); 245 | expect(model.getDoubleValue("d2"), 1); 246 | expect(model.getDoubleValue("e1"), 0); 247 | expect(model.getDoubleValue("e2"), 3); 248 | expect(model.getDoubleValue("f1"), 0); 249 | expect(model.getDoubleValue("f2"), 2); 250 | }); 251 | }); 252 | } 253 | -------------------------------------------------------------------------------- /lib/src/caster.dart: -------------------------------------------------------------------------------- 1 | import "./dtos/record_model.dart"; 2 | 3 | /// Extracts a single value from [data] by a dot-notation path 4 | /// and tries to cast it to the specified generic type. 5 | /// 6 | /// If explicitly set, returns [defaultValue] on missing path. 7 | /// 8 | /// Example: 9 | /// 10 | /// ```dart 11 | /// final data = {"a": {"b": [{"b1": 1}, {"b2": 2}, {"b3": 3}]}}; 12 | /// final result1 = extract(data, "a.b.2.b3"); // 3 13 | /// final result2 = extract(data, "a.b.c", "missing"); // "missing" 14 | /// ``` 15 | T extract( 16 | Map data, 17 | String path, [ 18 | dynamic defaultValue, 19 | ]) { 20 | final rawValue = _extractNestedValue(data, path, defaultValue); 21 | 22 | return cast(rawValue); 23 | } 24 | 25 | Type identityType() => T; 26 | 27 | /// Attempts to cast the provided value into the specified type. 28 | T cast(dynamic rawValue) { 29 | switch (T) { 30 | case const (String): 31 | return toString(rawValue) as T; 32 | case const (bool): 33 | return toBool(rawValue) as T; 34 | case const (int): 35 | return toInt(rawValue) as T; 36 | case const (num): 37 | case const (double): 38 | return toDouble(rawValue) as T; 39 | case const (RecordModel): 40 | return toRecordModel(rawValue) as T; 41 | case const (List): 42 | return toList(rawValue) as T; 43 | case const (List): 44 | case const (List): 45 | return toList(rawValue) as T; 46 | case const (List): 47 | case const (List): 48 | return toList(rawValue) as T; 49 | case const (List): 50 | case const (List): 51 | return toList(rawValue) as T; 52 | case const (List): 53 | case const (List): 54 | return toList(rawValue) as T; 55 | case const (List): 56 | case const (List): 57 | return toList(rawValue) as T; 58 | case const (List): 59 | case const (List): 60 | return toList(rawValue) as T; 61 | default: 62 | if (rawValue is T) { 63 | return rawValue; 64 | } 65 | 66 | // check against the nullable types 67 | if (null is T) { 68 | if (T == identityType()) { 69 | return toString(rawValue) as T; 70 | } 71 | if (T == identityType()) { 72 | return toBool(rawValue) as T; 73 | } 74 | if (T == identityType()) { 75 | return toInt(rawValue) as T; 76 | } 77 | if (T == identityType() || T == identityType()) { 78 | return toDouble(rawValue) as T; 79 | } 80 | if (T == identityType()) { 81 | return toRecordModel(rawValue) as T; 82 | } 83 | if (T == identityType?>()) { 84 | return toList(rawValue) as T; 85 | } 86 | if (T == identityType?>()) { 87 | return toList(rawValue) as T; 88 | } 89 | if (T == identityType?>()) { 90 | return toList(rawValue) as T; 91 | } 92 | if (T == identityType?>()) { 93 | return toList(rawValue) as T; 94 | } 95 | if (T == identityType?>()) { 96 | return toList(rawValue) as T; 97 | } 98 | if (T == identityType?>()) { 99 | return toList(rawValue) as T; 100 | } 101 | if (T == identityType?>()) { 102 | return toList(rawValue) as T; 103 | } 104 | } 105 | 106 | throw StateError("Invalid or unknown type value"); 107 | } 108 | } 109 | 110 | /// Returns [rawValue] as `String`. 111 | /// 112 | /// For `null` values empty string is returned. 113 | /// `toString()` is used for any other non-`String` value. 114 | String toString(dynamic rawValue) { 115 | if (rawValue == null) { 116 | return ""; 117 | } 118 | 119 | return rawValue is String ? rawValue : rawValue.toString(); 120 | } 121 | 122 | /// Casts and returns [rawValue] as `List`. 123 | /// 124 | /// Non-List values will be casted to `T` and returned as wrapped `List` 125 | /// as long as the casted value is not `null`. 126 | /// For example `toList(true)` will be returned as `[1]`, but 127 | /// `toList(null)` will be returned as `[]`. 128 | List toList(dynamic rawValue) { 129 | if (rawValue == null) { 130 | return []; 131 | } 132 | 133 | if (rawValue is List) { 134 | return rawValue.map((item) => cast(item)).toList(); 135 | } 136 | 137 | final casted = cast(rawValue); 138 | if (casted != null) { 139 | return [casted]; 140 | } 141 | 142 | return []; 143 | } 144 | 145 | /// Returns [rawValue] as **bool**. 146 | /// 147 | /// For non-bool values the following casting rules are applied: 148 | /// - `null` - always returned as `false` 149 | /// - `num` - `false` if `0`, otherwise `true` 150 | /// - `String` - `false` if one of `"", "false", "0"`, otherwise - `true` 151 | /// - `List` and `Map` - `true` if `length > 0`, otherwise - `false` 152 | /// - `false` for any other type 153 | bool toBool(dynamic rawValue) { 154 | if (rawValue == null) { 155 | return false; 156 | } 157 | 158 | if (rawValue is bool) { 159 | return rawValue; 160 | } 161 | 162 | if (rawValue is num) { 163 | return rawValue != 0; 164 | } 165 | 166 | if (rawValue is String) { 167 | final falsyValues = ["", "false", "0"]; 168 | 169 | return !falsyValues.contains(rawValue.toLowerCase()); 170 | } 171 | 172 | if (rawValue is Iterable) { 173 | return rawValue.isNotEmpty; 174 | } 175 | 176 | if (rawValue is Map) { 177 | return rawValue.isNotEmpty; 178 | } 179 | 180 | return false; 181 | } 182 | 183 | /// Returns [rawValue] as **int**. 184 | /// 185 | /// For non-num values the following casting rules are applied: 186 | /// - `null` - always returned as `0` 187 | /// - `String` - the non-null result of `int.tryParse()`, otherwise -`0`. 188 | /// - `bool` - `false` -> `0`, `true` -> `1` 189 | /// - `List` and `Map` - returns the length of the List/Map. 190 | /// - `0` for any other type 191 | int toInt(dynamic rawValue) { 192 | if (rawValue == null) { 193 | return 0; 194 | } 195 | 196 | if (rawValue is int) { 197 | return rawValue; 198 | } 199 | 200 | if (rawValue is double) { 201 | return rawValue.toInt(); 202 | } 203 | 204 | if (rawValue is String) { 205 | return int.tryParse(rawValue) ?? 0; 206 | } 207 | 208 | if (rawValue is bool) { 209 | return rawValue ? 1 : 0; 210 | } 211 | 212 | if (rawValue is Iterable) { 213 | return rawValue.length; 214 | } 215 | 216 | if (rawValue is Map) { 217 | return rawValue.length; 218 | } 219 | 220 | return 0; 221 | } 222 | 223 | /// Returns [rawValue] as **double**. 224 | /// 225 | /// For non-num values the following casting rules are applied: 226 | /// - `null` - always returned as `0` 227 | /// - `String` - the non-null result of `double.tryParse()`, otherwise -`0`. 228 | /// - `bool` - `false` -> `0`, `true` -> `1` 229 | /// - `List` and `Map` - returns the length of the List/Map. 230 | /// - `0` for any other type 231 | double toDouble(dynamic rawValue) { 232 | if (rawValue == null) { 233 | return 0; 234 | } 235 | 236 | if (rawValue is double) { 237 | return rawValue; 238 | } 239 | 240 | if (rawValue is int) { 241 | return rawValue.toDouble(); 242 | } 243 | 244 | if (rawValue is String) { 245 | return double.tryParse(rawValue) ?? 0; 246 | } 247 | 248 | if (rawValue is bool) { 249 | return rawValue ? 1 : 0; 250 | } 251 | 252 | if (rawValue is Iterable) { 253 | return rawValue.length.toDouble(); 254 | } 255 | 256 | if (rawValue is Map) { 257 | return rawValue.length.toDouble(); 258 | } 259 | 260 | return 0; 261 | } 262 | 263 | RecordModel toRecordModel(dynamic rawValue) { 264 | if (rawValue is RecordModel) { 265 | return rawValue; 266 | } 267 | 268 | if (rawValue is Map) { 269 | return RecordModel.fromJson(rawValue); 270 | } 271 | 272 | return RecordModel(); 273 | } 274 | 275 | List toRecordModels(dynamic rawValue) { 276 | if (rawValue is List) { 277 | return rawValue; 278 | } 279 | 280 | if (rawValue is RecordModel) { 281 | return [rawValue]; 282 | } 283 | 284 | if (rawValue is List) { 285 | return rawValue.map(toRecordModel).toList(); 286 | } 287 | 288 | if (rawValue is Map) { 289 | return [RecordModel.fromJson(rawValue)]; 290 | } 291 | 292 | return []; 293 | } 294 | 295 | dynamic _extractNestedValue( 296 | Map data, 297 | String path, [ 298 | dynamic defaultValue, 299 | ]) { 300 | final parts = path.split("."); 301 | 302 | dynamic result = data; 303 | 304 | for (final part in parts) { 305 | if (result is Iterable) { 306 | result = _iterableToMap(result); 307 | } 308 | 309 | if (result is! Map || !result.containsKey(part)) { 310 | return defaultValue; 311 | } 312 | 313 | result = result[part]; 314 | } 315 | 316 | return result; 317 | } 318 | 319 | Map _iterableToMap(Iterable data) { 320 | final result = {}; 321 | 322 | for (var i = 0; i < data.length; i++) { 323 | result[i.toString()] = data.elementAt(i); 324 | } 325 | 326 | return result; 327 | } 328 | -------------------------------------------------------------------------------- /lib/src/services/realtime_service.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | import "dart:convert"; 3 | 4 | import "../client.dart"; 5 | import "../sse/sse_client.dart"; 6 | import "../sse/sse_message.dart"; 7 | import "base_service.dart"; 8 | 9 | /// The definition of a realtime subscription callback function. 10 | typedef SubscriptionFunc = void Function(SseMessage e); 11 | typedef UnsubscribeFunc = Future Function(); 12 | 13 | /// The service that handles the **Realtime APIs**. 14 | /// 15 | /// Usually shouldn't be initialized manually and instead 16 | /// [PocketBase.realtime] should be used. 17 | class RealtimeService extends BaseService { 18 | RealtimeService(super.client); 19 | 20 | SseClient? _sse; 21 | String _clientId = ""; 22 | final _subscriptions = >{}; 23 | 24 | /// Returns the established SSE connection client id (if any). 25 | String get clientId => _clientId; 26 | 27 | /// An optional hook that is invoked when the realtime client disconnects 28 | /// either when unsubscribing from all subscriptions or when the 29 | /// connection was interrupted or closed by the server. 30 | /// 31 | /// It receives the subscriptions map before the disconnect 32 | /// (could be used to determine whether the disconnect was caused by 33 | /// unsubscribing or network/server error). 34 | /// 35 | /// If you want to listen for the opposite, aka. when the client 36 | /// connection is established, subscribe to the `PB_CONNECT` event. 37 | void Function(Map>)? onDisconnect; 38 | 39 | /// Register the subscription listener. 40 | /// 41 | /// You can subscribe multiple times to the same topic. 42 | /// 43 | /// If the SSE connection is not started yet, 44 | /// this method will also initialize it. 45 | /// 46 | /// Here is an example listening to the connect/reconnect events: 47 | /// 48 | /// ```dart 49 | /// pb.realtime.subscribe("PB_CONNECT", (e) { 50 | /// print("Connected: $e"); 51 | /// }); 52 | /// ``` 53 | Future subscribe( 54 | String topic, 55 | SubscriptionFunc listener, { 56 | String? expand, 57 | String? filter, 58 | String? fields, 59 | Map query = const {}, 60 | Map headers = const {}, 61 | }) async { 62 | var key = topic; 63 | 64 | // merge query parameters 65 | final enrichedQuery = Map.of(query); 66 | if (expand?.isNotEmpty ?? false) { 67 | enrichedQuery["expand"] ??= expand; 68 | } 69 | if (filter?.isNotEmpty ?? false) { 70 | enrichedQuery["filter"] ??= filter; 71 | } 72 | if (fields?.isNotEmpty ?? false) { 73 | enrichedQuery["fields"] ??= fields; 74 | } 75 | 76 | // serialize and append the topic options (if any) 77 | final options = {}; 78 | if (enrichedQuery.isNotEmpty) { 79 | options["query"] = enrichedQuery; 80 | } 81 | if (headers.isNotEmpty) { 82 | options["headers"] = headers; 83 | } 84 | if (options.isNotEmpty) { 85 | final encoded = 86 | "options=${Uri.encodeQueryComponent(jsonEncode(options))}"; 87 | key += (key.contains("?") ? "&" : "?") + encoded; 88 | } 89 | 90 | if (!_subscriptions.containsKey(key)) { 91 | _subscriptions[key] = []; 92 | } 93 | _subscriptions[key]?.add(listener); 94 | 95 | // start a new sse connection 96 | if (_sse == null) { 97 | await _connect(); 98 | } else if (_clientId.isNotEmpty && _subscriptions[key]?.length == 1) { 99 | // otherwise - just persist the updated subscriptions 100 | // (if it is the first for the topic) 101 | await _submitSubscriptions(); 102 | } 103 | 104 | return () async { 105 | return unsubscribeByTopicAndListener(topic, listener); 106 | }; 107 | } 108 | 109 | /// Unsubscribe from all subscription listeners with the specified topic. 110 | /// 111 | /// If [topic] is not set, then this method will unsubscribe 112 | /// from all active subscriptions. 113 | /// 114 | /// This method is no-op if there are no active subscriptions. 115 | /// 116 | /// The related sse connection will be autoclosed if after the 117 | /// unsubscribe operation there are no active subscriptions left. 118 | Future unsubscribe([String topic = ""]) async { 119 | var needToSubmit = false; 120 | 121 | if (topic.isEmpty) { 122 | // remove all subscriptions 123 | _subscriptions.clear(); 124 | } else { 125 | final subs = _getSubscriptionsByTopic(topic); 126 | 127 | for (final key in subs.keys) { 128 | _subscriptions.remove(key); 129 | needToSubmit = true; 130 | } 131 | } 132 | 133 | // no other subscriptions -> close the sse connection 134 | if (!_hasNonEmptyTopic()) { 135 | return _disconnect(); 136 | } 137 | 138 | // otherwise - notify the server about the subscription changes 139 | if (_clientId.isNotEmpty && needToSubmit) { 140 | return _submitSubscriptions(); 141 | } 142 | } 143 | 144 | /// Unsubscribe from all subscription listeners starting with 145 | /// the specified topic prefix. 146 | /// 147 | /// This method is no-op if there are no active subscriptions 148 | /// with the specified topic prefix. 149 | /// 150 | /// The related sse connection will be autoclosed if after the 151 | /// unsubscribe operation there are no active subscriptions left. 152 | Future unsubscribeByPrefix(String topicPrefix) async { 153 | final beforeLength = _subscriptions.length; 154 | 155 | // remove matching subscriptions 156 | _subscriptions.removeWhere((key, func) { 157 | // "?" so that it can be used as end delimiter for the prefix 158 | return "$key?".startsWith(topicPrefix); 159 | }); 160 | 161 | // no changes 162 | if (beforeLength == _subscriptions.length) { 163 | return; 164 | } 165 | 166 | // no other subscriptions -> close the sse connection 167 | if (!_hasNonEmptyTopic()) { 168 | return _disconnect(); 169 | } 170 | 171 | // otherwise - notify the server about the subscription changes 172 | if (_clientId.isNotEmpty) { 173 | return _submitSubscriptions(); 174 | } 175 | } 176 | 177 | /// Unsubscribe from all subscriptions matching the specified topic 178 | /// and listener function. 179 | /// 180 | /// This method is no-op if there are no active subscription with 181 | /// the specified topic and listener. 182 | /// 183 | /// The related sse connection will be autoclosed if after the 184 | /// unsubscribe operation there are no active subscriptions left. 185 | Future unsubscribeByTopicAndListener( 186 | String topic, 187 | SubscriptionFunc listener, 188 | ) async { 189 | var needToSubmit = false; 190 | 191 | final subs = _getSubscriptionsByTopic(topic); 192 | 193 | for (final key in subs.keys) { 194 | if (_subscriptions[key]?.isEmpty ?? true) { 195 | continue; // nothing to unsubscribe from 196 | } 197 | 198 | final beforeLength = _subscriptions[key]?.length ?? 0; 199 | 200 | _subscriptions[key]?.removeWhere((fn) => fn == listener); 201 | 202 | final afterLength = _subscriptions[key]?.length ?? 0; 203 | 204 | // no changes 205 | if (beforeLength == afterLength) { 206 | continue; 207 | } 208 | 209 | // mark for subscriptions change submit if there are no other listeners 210 | if (!needToSubmit && afterLength == 0) { 211 | needToSubmit = true; 212 | } 213 | } 214 | 215 | // no other subscriptions -> close the sse connection 216 | if (!_hasNonEmptyTopic()) { 217 | return _disconnect(); 218 | } 219 | 220 | // otherwise - notify the server about the subscription changes 221 | // (if there are no other subscriptions in the topic) 222 | if (_clientId.isNotEmpty && needToSubmit) { 223 | return _submitSubscriptions(); 224 | } 225 | } 226 | 227 | Map> _getSubscriptionsByTopic(String topic) { 228 | final result = >{}; 229 | 230 | // "?" so that it can be used as end delimiter for the topic 231 | topic = topic.contains("?") ? topic : "$topic?"; 232 | 233 | _subscriptions.forEach((key, value) { 234 | if ("$key?".startsWith(topic)) { 235 | result[key] = value; 236 | } 237 | }); 238 | 239 | return result; 240 | } 241 | 242 | bool _hasNonEmptyTopic() { 243 | for (final key in _subscriptions.keys) { 244 | if (_subscriptions[key]?.isNotEmpty ?? false) { 245 | return true; // has at least one listener 246 | } 247 | } 248 | 249 | return false; 250 | } 251 | 252 | Future _connect() { 253 | _disconnect(); 254 | 255 | final completer = Completer(); 256 | 257 | final url = client.buildURL("/api/realtime").toString(); 258 | 259 | _sse = SseClient( 260 | url, 261 | httpClientFactory: client.httpClientFactory, 262 | onClose: () { 263 | if (_clientId.isNotEmpty && onDisconnect != null) { 264 | onDisconnect?.call(_subscriptions); 265 | } 266 | 267 | _disconnect(); 268 | 269 | if (!completer.isCompleted) { 270 | completer 271 | .completeError(StateError("failed to establish SSE connection")); 272 | } 273 | }, 274 | onError: (err) { 275 | if (_clientId.isNotEmpty && onDisconnect != null) { 276 | _clientId = ""; 277 | onDisconnect?.call(_subscriptions); 278 | } 279 | }, 280 | ); 281 | 282 | // bind subscriptions listener 283 | _sse?.onMessage.listen((msg) { 284 | if (!_subscriptions.containsKey(msg.event)) { 285 | return; 286 | } 287 | 288 | _subscriptions[msg.event]?.forEach((fn) { 289 | fn.call(msg); 290 | }); 291 | }); 292 | 293 | // resubmit local subscriptions on first reconnect 294 | _sse?.onMessage.where((msg) => msg.event == "PB_CONNECT").listen(( 295 | msg, 296 | ) async { 297 | _clientId = msg.id; 298 | await _submitSubscriptions(); 299 | 300 | if (!completer.isCompleted) { 301 | completer.complete(); 302 | } 303 | }, onError: (dynamic err) { 304 | _disconnect(); 305 | 306 | if (!completer.isCompleted) { 307 | completer.completeError( 308 | err is Object 309 | ? err 310 | : StateError("failed to establish SSE connection"), 311 | ); 312 | } 313 | }); 314 | 315 | return completer.future; 316 | } 317 | 318 | void _disconnect() { 319 | _sse?.close(); 320 | _sse = null; 321 | _clientId = ""; 322 | } 323 | 324 | Future _submitSubscriptions() { 325 | return client.send( 326 | "/api/realtime", 327 | method: "POST", 328 | body: { 329 | "clientId": _clientId, 330 | "subscriptions": _subscriptions.keys.toList(), 331 | }, 332 | ); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /test/services/crud_suite.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: lines_longer_than_80_chars 2 | 3 | import "dart:convert"; 4 | 5 | import "package:http/http.dart" as http; 6 | import "package:http/testing.dart"; 7 | import "package:pocketbase/pocketbase.dart"; 8 | import "package:test/test.dart"; 9 | 10 | void crudServiceTests( 11 | BaseCrudService Function(PocketBase client) serviceFactory, 12 | String expectedPath, 13 | ) { 14 | group("BaseCrudService", () { 15 | test("getFullList() with last items.length < perPage", () async { 16 | final mock = MockClient((request) async { 17 | expect(request.method, "GET"); 18 | expect(request.headers["test"], "789"); 19 | 20 | // page1 21 | if (request.url.queryParameters["page"] == "1") { 22 | expect( 23 | request.url.toString(), 24 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=1&perPage=2&filter=f%3D123&sort=s%3D456&expand=rel&fields=a&skipTotal=true", 25 | ); 26 | 27 | return http.Response( 28 | jsonEncode({ 29 | "page": 1, 30 | "perPage": 2, 31 | "totalItems": -1, 32 | "totalPages": -1, 33 | "items": [ 34 | {"id": "1"}, 35 | {"id": "2"}, 36 | ], 37 | }), 38 | 200, 39 | ); 40 | } 41 | 42 | // page2 43 | expect( 44 | request.url.toString(), 45 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=2&perPage=2&filter=f%3D123&sort=s%3D456&expand=rel&fields=a&skipTotal=true", 46 | ); 47 | 48 | return http.Response( 49 | jsonEncode({ 50 | "page": 2, 51 | "perPage": 2, 52 | "totalItems": -1, 53 | "totalPages": -1, 54 | "items": [ 55 | {"id": "3"}, 56 | ], 57 | }), 58 | 200, 59 | ); 60 | }); 61 | 62 | final client = PocketBase("/base", httpClientFactory: () => mock); 63 | 64 | final result = await serviceFactory(client).getFullList( 65 | batch: 2, 66 | expand: "rel", 67 | fields: "a", 68 | filter: "f=123", 69 | sort: "s=456", 70 | query: { 71 | "a": ["1", null, 2], 72 | "b": "@demo", 73 | }, 74 | headers: { 75 | "test": "789", 76 | }, 77 | ); 78 | 79 | expect(result, isA>()); 80 | expect(result.length, 3); 81 | }); 82 | 83 | test("getFullList() with last items.length = perPage", () async { 84 | final mock = MockClient((request) async { 85 | expect(request.method, "GET"); 86 | expect(request.headers["test"], "789"); 87 | 88 | // page1 89 | if (request.url.queryParameters["page"] == "1") { 90 | expect( 91 | request.url.toString(), 92 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=1&perPage=2&filter=f%3D123&sort=s%3D456&expand=rel&fields=a&skipTotal=true", 93 | ); 94 | 95 | return http.Response( 96 | jsonEncode({ 97 | "page": 1, 98 | "perPage": 2, 99 | "totalItems": -1, 100 | "totalPages": -1, 101 | "items": [ 102 | {"id": "1"}, 103 | {"id": "2"}, 104 | ], 105 | }), 106 | 200, 107 | ); 108 | } 109 | 110 | // page2 111 | if (request.url.queryParameters["page"] == "2") { 112 | expect( 113 | request.url.toString(), 114 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=2&perPage=2&filter=f%3D123&sort=s%3D456&expand=rel&fields=a&skipTotal=true", 115 | ); 116 | 117 | return http.Response( 118 | jsonEncode({ 119 | "page": 2, 120 | "perPage": 2, 121 | "totalItems": -1, 122 | "totalPages": -1, 123 | "items": [ 124 | {"id": "3"}, 125 | {"id": "4"}, 126 | ], 127 | }), 128 | 200, 129 | ); 130 | } 131 | 132 | // page3 133 | expect( 134 | request.url.toString(), 135 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=3&perPage=2&filter=f%3D123&sort=s%3D456&expand=rel&fields=a&skipTotal=true", 136 | ); 137 | 138 | return http.Response( 139 | jsonEncode({ 140 | "page": 3, 141 | "perPage": 2, 142 | "totalItems": -1, 143 | "totalPages": -1, 144 | // no items 145 | }), 146 | 200, 147 | ); 148 | }); 149 | 150 | final client = PocketBase("/base", httpClientFactory: () => mock); 151 | 152 | final result = await serviceFactory(client).getFullList( 153 | batch: 2, 154 | expand: "rel", 155 | fields: "a", 156 | filter: "f=123", 157 | sort: "s=456", 158 | query: { 159 | "a": ["1", null, 2], 160 | "b": "@demo", 161 | }, 162 | headers: { 163 | "test": "789", 164 | }, 165 | ); 166 | 167 | expect(result, isA>()); 168 | expect(result.length, 4); 169 | }); 170 | 171 | test("getList()", () async { 172 | final mock = MockClient((request) async { 173 | expect(request.method, "GET"); 174 | expect( 175 | request.url.toString(), 176 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=2&perPage=15&filter=f123&sort=s456&expand=rel&fields=a&skipTotal=false", 177 | ); 178 | expect(request.headers["test"], "789"); 179 | 180 | return http.Response( 181 | jsonEncode({ 182 | "page": 2, 183 | "perPage": 15, 184 | "totalItems": 17, 185 | "totalPages": 2, 186 | "items": [ 187 | {"id": "1"}, 188 | {"id": "2"}, 189 | ], 190 | }), 191 | 200); 192 | }); 193 | 194 | final client = PocketBase("/base", httpClientFactory: () => mock); 195 | 196 | final result = await serviceFactory(client).getList( 197 | page: 2, 198 | perPage: 15, 199 | filter: "f123", 200 | sort: "s456", 201 | expand: "rel", 202 | fields: "a", 203 | query: { 204 | "a": ["1", null, 2], 205 | "b": "@demo", 206 | }, 207 | headers: { 208 | "test": "789", 209 | }, 210 | ); 211 | 212 | expect(result.page, 2); 213 | expect(result.perPage, 15); 214 | expect(result.totalItems, 17); 215 | expect(result.totalPages, 2); 216 | expect(result.items, isA>()); 217 | expect(result.items.length, 2); 218 | }); 219 | 220 | test("getOne()", () async { 221 | final mock = MockClient((request) async { 222 | expect(request.method, "GET"); 223 | expect( 224 | request.url.toString(), 225 | "/base/api/$expectedPath/%40id123?a=1&a=2&b=%40demo&expand=rel", 226 | ); 227 | expect(request.headers["test"], "789"); 228 | 229 | return http.Response(jsonEncode({"id": "@id123"}), 200); 230 | }); 231 | 232 | final client = PocketBase("/base", httpClientFactory: () => mock); 233 | 234 | final result = await serviceFactory(client).getOne( 235 | "@id123", 236 | expand: "rel", 237 | query: { 238 | "a": ["1", null, 2], 239 | "b": "@demo", 240 | }, 241 | headers: { 242 | "test": "789", 243 | }, 244 | ); 245 | 246 | await expectLater(client.send(""), throwsA(isA())); 247 | 248 | expect(result, isA()); 249 | // ignore: avoid_dynamic_calls 250 | expect((result as dynamic).id, "@id123"); 251 | }); 252 | 253 | test("getOne() with empty id", () async { 254 | final client = PocketBase("/base"); 255 | 256 | await expectLater( 257 | serviceFactory(client).getOne(""), throwsA(isA())); 258 | }); 259 | 260 | test("getFirstListItem()", () async { 261 | final mock = MockClient((request) async { 262 | expect(request.method, "GET"); 263 | expect( 264 | request.url.toString(), 265 | "/base/api/$expectedPath?a=1&a=2&b=%40demo&page=1&perPage=1&filter=test%3D123&expand=rel&fields=a&skipTotal=true", 266 | ); 267 | expect(request.headers["test"], "789"); 268 | 269 | return http.Response( 270 | jsonEncode({ 271 | "page": 1, 272 | "perPage": 1, 273 | "totalItems": 1, 274 | "totalPages": 1, 275 | "items": [ 276 | {"id": "1"}, 277 | {"id": "2"}, 278 | ], 279 | }), 280 | 200); 281 | }); 282 | 283 | final client = PocketBase("/base", httpClientFactory: () => mock); 284 | 285 | final result = await serviceFactory(client).getFirstListItem( 286 | "test=123", 287 | expand: "rel", 288 | fields: "a", 289 | query: { 290 | "a": ["1", null, 2], 291 | "b": "@demo", 292 | }, 293 | headers: { 294 | "test": "789", 295 | }, 296 | ); 297 | 298 | expect(result, isA()); 299 | // ignore: avoid_dynamic_calls 300 | expect((result as dynamic).id, "1"); 301 | }); 302 | 303 | test("create()", () async { 304 | final mock = MockClient((request) async { 305 | expect(request.method, "POST"); 306 | expect(request.body, contains("--dart-http-boundar")); 307 | expect( 308 | request.body, 309 | contains('content-disposition: form-data; name="@jsonPayload"\r\n'), 310 | ); 311 | expect( 312 | request.body, 313 | contains('{"test_body":123}\r\n'), 314 | ); 315 | expect( 316 | request.body, 317 | contains('content-disposition: form-data; name="test_file"'), 318 | ); 319 | expect( 320 | request.url.toString(), 321 | "/base/api/$expectedPath?a=1&a=2&b=%40demo", 322 | ); 323 | expect(request.headers["test"], "789"); 324 | 325 | return http.Response(jsonEncode({"id": "@id123"}), 200); 326 | }); 327 | 328 | final client = PocketBase("/base", httpClientFactory: () => mock); 329 | 330 | final result = await serviceFactory(client).create( 331 | query: { 332 | "a": ["1", null, 2], 333 | "b": "@demo", 334 | }, 335 | body: { 336 | "test_body": 123, 337 | }, 338 | files: [http.MultipartFile.fromString("test_file", "456")], 339 | headers: { 340 | "test": "789", 341 | }, 342 | ); 343 | 344 | expect(result, isA()); 345 | // ignore: avoid_dynamic_calls 346 | expect((result as dynamic).id, "@id123"); 347 | }); 348 | 349 | test("update()", () async { 350 | final mock = MockClient((request) async { 351 | expect(request.method, "PATCH"); 352 | expect(request.body, contains("--dart-http-boundar")); 353 | expect( 354 | request.body, 355 | contains('content-disposition: form-data; name="@jsonPayload"\r\n'), 356 | ); 357 | expect( 358 | request.body, 359 | contains('{"test_body":123}\r\n'), 360 | ); 361 | expect( 362 | request.body, 363 | contains('content-disposition: form-data; name="test_file"'), 364 | ); 365 | expect( 366 | request.url.toString(), 367 | "/base/api/$expectedPath/%40id123?a=1&a=2&b=%40demo", 368 | ); 369 | expect(request.headers["test"], "789"); 370 | 371 | return http.Response(jsonEncode({"id": "@id123"}), 200); 372 | }); 373 | 374 | final client = PocketBase("/base", httpClientFactory: () => mock); 375 | 376 | final result = await serviceFactory(client).update( 377 | "@id123", 378 | query: { 379 | "a": ["1", null, 2], 380 | "b": "@demo", 381 | }, 382 | body: { 383 | "test_body": 123, 384 | }, 385 | files: [http.MultipartFile.fromString("test_file", "456")], 386 | headers: { 387 | "test": "789", 388 | }, 389 | ); 390 | 391 | expect(result, isA()); 392 | // ignore: avoid_dynamic_calls 393 | expect((result as dynamic).id, "@id123"); 394 | }); 395 | 396 | test("delete()", () async { 397 | final mock = MockClient((request) async { 398 | expect(request.method, "DELETE"); 399 | expect( 400 | request.url.toString(), 401 | "/base/api/$expectedPath/%40id123?a=1&a=2&b=%40demo", 402 | ); 403 | expect(request.headers["test"], "789"); 404 | expect(request.headers["content-type"], "application/json"); 405 | expect(request.body, jsonEncode({"test": 123})); 406 | 407 | return http.Response("", 204); 408 | }); 409 | 410 | final client = PocketBase("/base", httpClientFactory: () => mock); 411 | 412 | await serviceFactory(client).delete( 413 | "@id123", 414 | query: { 415 | "a": ["1", null, 2], 416 | "b": "@demo", 417 | }, 418 | body: { 419 | "test": 123, 420 | }, 421 | headers: { 422 | "test": "789", 423 | }, 424 | ); 425 | }); 426 | }); 427 | } 428 | --------------------------------------------------------------------------------