Baserow Dart Client

A Dart client library for interacting with the Baserow API. This package provides a simple and intuitive way to work with Baserow databases, tables, and rows in your Dart and Flutter applications.

Features

  • ๐Ÿ“ค File uploads
    • Upload files to Baserow
    • Support for images with thumbnails
    • Detailed file metadata
  • ๐Ÿ” Authentication support
    • API token authentication
    • JWT authentication with refresh capabilities
  • ๐Ÿ“š Database management
    • List all accessible databases
    • View database details
  • ๐Ÿ“‹ Table operations
    • List tables in a database
    • View table structure and fields
    • Create new tables with initial data
    • Create and manage table fields
  • ๐Ÿ“ Row operations
    • List rows in a table
    • Create new rows
    • Update existing rows
    • Delete rows
    • Batch operations for creating/updating/deleting multiple rows
  • ๐Ÿ”„ Real-time updates via WebSocket
    • Subscribe to table changes
    • Subscribe to workspace events
    • Subscribe to application events
    • Subscribe to user events
    • Automatic reconnection handling
  • ๐Ÿ› ๏ธ Type-safe data models for Baserow entities
  • โšก Efficient HTTP connection management
  • ๐Ÿงช Comprehensive testing utilities

Installation

Add this package to your project's pubspec.yaml:

dependencies:
  baserow: ^0.1.0

Then run:

dart pub get

Usage

Authentication

API Token Authentication

import 'package:baserow/baserow.dart';

// Create a client instance with API token
final client = BaserowClient(
  config: BaserowConfig(
    baseUrl: 'https://api.baserow.io',
    token: 'YOUR_API_TOKEN',
  ),
);

JWT Authentication

import 'package:baserow/baserow.dart';

// Create a client instance
final client = BaserowClient(
  config: BaserowConfig(
    baseUrl: 'https://api.baserow.io',
    authType: BaserowAuthType.jwt,
  ),
);

// Login to obtain JWT tokens
final authResponse = await client.login(
  'your.email@example.com',
  'your_password',
);
print('JWT Token: ${authResponse.token}');
print('Refresh Token: ${authResponse.refreshToken}');

// Create a new client with the JWT token
final authenticatedClient = BaserowClient(
  config: BaserowConfig(
    baseUrl: 'https://api.baserow.io',
    token: authResponse.token,
    authType: BaserowAuthType.jwt,
  ),
);

// You can verify if the token is still valid
final isValid = await authenticatedClient.verifyToken(authResponse.token);

// When the token expires, you can refresh it
final newToken = await authenticatedClient.refreshToken(authResponse.refreshToken);

// Create a new client with the refreshed token
final refreshedClient = BaserowClient(
  config: BaserowConfig(
    baseUrl: 'https://api.baserow.io',
    token: newToken,
    authType: BaserowAuthType.jwt,
  ),
);

// When you're done, you can logout to invalidate the tokens
await authenticatedClient.logout();
// After logout, the client's tokens are cleared and the refresh timer is stopped

The JWT authentication flow provides more security features compared to API tokens, including:

  • Token expiration and refresh capabilities
  • Token verification
  • User information included in the auth response (authResponse.user)

Listing Databases and Tables

// List all accessible databases
final databases = await client.listDatabases();
for (final db in databases) {
  print('Database: ${db.name} (ID: ${db.id})');

  // List tables in the database
  final tables = await client.listTables(db.id);
  for (final table in tables) {
    print('Table: ${table.name} (ID: ${table.id})');
  }
}

File Uploads

// Upload a local file
final fileBytes = await File('image.png').readAsBytes();
final response = await client.uploadFile(fileBytes, 'image.png');

// Upload a file from a URL
final urlResponse = await client.uploadFileViaUrl('https://example.com/image.png');

// Access file information
print('File URL: ${response.url}');
print('File name: ${response.name}');
print('File size: ${response.size}');
print('MIME type: ${response.mimeType}');

// For images, you can access thumbnails and dimensions
if (response.isImage) {
  print('Image width: ${response.imageWidth}');
  print('Image height: ${response.imageHeight}');

  // Access thumbnails
  for (final entry in response.thumbnails.entries) {
    print('Thumbnail ${entry.key}:');
    print('  URL: ${entry.value.url}');
    print('  Width: ${entry.value.width}');
    print('  Height: ${entry.value.height}');
  }
}

Both upload methods return a FileUploadResponse that includes:

  • url: Direct URL to the uploaded file
  • name: The file name on the server
  • size: File size in bytes
  • mimeType: The file's MIME type
  • isImage: Whether the file is an image
  • imageWidth and imageHeight: Dimensions for image files
  • thumbnails: Map of available thumbnails with their URLs and dimensions
  • uploadedAt: Timestamp of when the file was uploaded

Database Tokens

Database tokens provide a way to access and manage table data with specific permissions. Each token is associated with a workspace and can have different permissions for creating, reading, updating, and deleting rows.

// List all database tokens
final tokens = await client.listDatabaseTokens();
for (final token in tokens) {
  print('Token: ${token.name}');
  print('Workspace: ${token.workspace}');
  print('Key: ${token.key}');

  // Check permissions
  final perms = token.permissions;
  print('Can create: ${perms.create}');
  print('Can read: ${perms.read}');
  print('Can update: ${perms.update}');
  print('Can delete: ${perms.delete}');
}

// Get a specific database token by ID
try {
  final token = await client.getDatabaseToken(123);
  print('Token: ${token.name}');
  print('Workspace: ${token.workspace}');
  print('Key: ${token.key}');
} on BaserowException catch (e) {
  if (e.message == 'ERROR_TOKEN_DOES_NOT_EXIST') {
    print('Token not found');
  } else if (e.message == 'ERROR_USER_NOT_IN_GROUP') {
    print('User not authorized to access this token');
  } else {
    print('Error: ${e.message}');
  }
}

// Delete a database token
try {
  await client.deleteDatabaseToken(123);
  print('Token deleted successfully');
} on BaserowException catch (e) {
  if (e.message == 'ERROR_TOKEN_DOES_NOT_EXIST') {
    print('Token not found');
  } else if (e.message == 'ERROR_USER_NOT_IN_GROUP') {
    print('User not authorized to delete this token');
  } else {
    print('Error: ${e.message}');
  }
}

// Check if a database token is valid
try {
  await client.checkDatabaseToken();
  print('Token is valid');
} on BaserowException catch (e) {
  if (e.message == 'ERROR_TOKEN_DOES_NOT_EXIST') {
    print('Token is invalid');
  } else {
    print('Error: ${e.message}');
  }
}

Permissions can be either:

  • Boolean values (true/false) for full access or no access
  • Lists of ["database", id, "table", id] for granular access to specific databases or tables

For example:

// Full access token
{
  "create": true,  // Can create rows in all tables
  "read": true,    // Can read rows from all tables
  "update": true,  // Can update rows in all tables
  "delete": true   // Can delete rows from all tables
}

// Limited access token
{
  "create": false,  // Cannot create rows
  "read": [["database", 1], ["table", 10]],  // Can only read from database 1 and table 10
  "update": false,  // Cannot update rows
  "delete": []     // Cannot delete rows
}

Workspaces

Workspaces are containers that can hold multiple applications like databases. Multiple users can have access to a workspace, and each user can have different permissions within the workspace.

// List all workspaces
final workspaces = await client.listWorkspaces();
for (final workspace in workspaces) {
  print('Workspace: ${workspace.name}');

  // Access workspace details
  print('ID: ${workspace.id}');
  print('Permissions: ${workspace.permissions}');
  print('Unread Notifications: ${workspace.unreadNotificationsCount}');
  print('AI Models Enabled: ${workspace.generativeAiModelsEnabled}');

  // List workspace users
  for (final user in workspace.users) {
    print('User: ${user.name} (${user.email})');
    print('Role: ${user.permissions}');
    print('Created on: ${user.createdOn}');
  }
}

The workspace listing provides:

  • Basic workspace information (ID, name, permissions)
  • List of workspace users with their details
  • User-specific information like unread notification count
  • Workspace settings like enabled AI models
  • Custom ordering of workspaces per user (configurable via the order_workspaces endpoint)

View Operations

Views provide different ways to display and interact with table data. Each table can have multiple views (grid, gallery, form, kanban, calendar, timeline) with their own settings.

// Create a new grid view
final gridView = await client.createView(
  tableId,
  name: "Main Grid",
  type: "grid",
  filterType: "AND",
  filtersDisabled: false,
);

// Create a public gallery view
final galleryView = await client.createView(
  tableId,
  name: "Public Gallery",
  type: "gallery",
  public: true,
);

// List all views for a table
final views = await client.listViews(tableId);
for (final view in views) {
  print('View: ${view.name} (Type: ${view.type})');
  print('Public: ${view.public}');
  print('Slug: ${view.slug}');
}

// Get a specific view
final view = await client.getView(viewId);
print('View name: ${view.name}');
print('Filter type: ${view.filterType}');
print('Filters disabled: ${view.filtersDisabled}');

// Update a view
final updatedView = await client.updateView(
  viewId,
  name: "Updated View",
  filterType: "OR",
  filtersDisabled: true,
);

// Delete a view
await client.deleteView(viewId);

Each view type has its own specific features:

  • Grid: Traditional spreadsheet-like view with rows and columns
  • Gallery: Card-based view ideal for visual content
  • Form: Customizable form for data entry
  • Kanban: Board view for organizing items into columns
  • Calendar: Date-based view for temporal data
  • Timeline: Time-based view for project planning

Views can be:

  • Public or private (controlled via the public parameter)
  • Collaborative or personal (set via ownershipType)
  • Filtered using various conditions (configured with filterType and filtersDisabled)

Table Operations

Creating and Ensuring Tables

You can create tables in two ways:

  1. Basic table creation:
// Create a new table
final table = await client.createTable(
  databaseId,
  name: "Customers",
  data: [
    ["Name", "Email", "Status"],           // Field names
    ["John Doe", "john@example.com", "Active"],  // Initial data
  ],
  firstRowHeader: true,  // Use first row as field names
);

// Create a table without initial data
final emptyTable = await client.createTable(
  databaseId,
  name: "Products",
);
  1. Using TableBuilder for declarative table creation and updates:
// Define a table structure with fields and views
final table = await client.ensureTable(
  databaseId,
  TableBuilder("Customers")
    ..withTextField("Name")
    ..withTextField("Email")
    ..withTextField("Status")
    ..withGridView("Main Grid")
    ..withData([
      ["John Doe", "john@example.com", "Active"],
      ["Jane Smith", "jane@example.com", "Pending"],
    ]),
);

// The ensureTable method will:
// 1. Create the table if it doesn't exist
// 2. Update the table if it exists and updateIfExists is true (default)
// 3. Return the existing table without changes if updateIfExists is false

// You can also use it to ensure a consistent data model across environments:
final customersTable = await client.ensureTable(
  databaseId,
  TableBuilder("Customers")
    ..withTextField("Name", required: true)
    ..withTextField("Email", required: true)
    ..withSelectField("Status", options: ["Active", "Pending", "Inactive"])
    ..withNumberField("Age", description: "Customer's age")
    ..withDateField("JoinDate")
    ..withGridView("All Customers")
    ..withGalleryView("Customer Cards")
    ..withFormView("New Customer"),
);

The TableBuilder provides a fluent interface for defining:

  • Table name and structure
  • Fields with their types and options
  • Views (Grid, Gallery, Form, etc.)
  • Initial data
  • Field validation (required fields, etc.)
  • Field descriptions and metadata

This is particularly useful for:

  • Setting up consistent table structures across different environments
  • Maintaining data models in version control
  • Automated table creation and updates in tests
  • Ensuring required fields and views exist

Managing Fields

// Create a text field
final nameField = await client.createField(
  tableId,
  name: "Name",
  type: "text",
  options: {"text_default": "New Customer"},
);

// Create a number field
final priceField = await client.createField(
  tableId,
  name: "Price",
  type: "number",
  options: {
    "number_decimal_places": 2,
    "number_negative": true,
  },
);

// List all fields in a table
final fields = await client.listFields(tableId);
for (final field in fields) {
  print('Field: ${field.name} (Type: ${field.type})');
}

// Update a field
final updatedField = await client.updateField(
  fieldId,
  name: "Full Name",
  description: "Customer's full name",
);

// Delete a field
await client.deleteField(fieldId);

Working with Rows

Getting a Single Row

// Get a single row by ID
final row = await client.getRow(tableId, rowId);
print('Row ID: ${row.id}');
print('Field value: ${row.fields['field_1']}');

// Get a row using human-readable field names
final row = await client.getRow(
  tableId,
  rowId,
  userFieldNames: true,
);
print('Name: ${row.fields['Name']}');
print('Email: ${row.fields['Email']}');

The getRow method allows you to:

  • Fetch a specific row by its ID
  • Use human-readable field names with userFieldNames: true
  • Access all field values through the fields property
  • Handle common errors like non-existent rows or permission issues

Field Name Formats

Baserow supports two formats for field names:

  • Default format: Uses field IDs (e.g., field_123)
  • User-friendly format: Uses human-readable field names (e.g., Name, Email)

You can enable user-friendly field names by setting userFieldNames: true in the relevant operations.

// List rows from a table with user-friendly field names
final rows = await client.listRows(
  tableId,
  options: ListRowsOptions(userFieldNames: true),
);

// Create a new row with user-friendly field names
final newRow = await client.createRow(
  tableId,
  {
    'Name': 'John Doe',
    'Email': 'john@example.com',
  },
  userFieldNames: true,
);

// Update an existing row with user-friendly field names
await client.updateRow(
  tableId,
  rowId,
  {
    'Name': 'Jane Doe',
    'Email': 'jane@example.com',
  },
  userFieldNames: true,
);

// Delete a single row
await client.deleteRow(tableId, rowId);  // with webhooks
await client.deleteRow(tableId, rowId, sendWebhookEvents: false);  // without webhooks

// Delete multiple rows in batch
await client.deleteRows(tableId, [123, 456]);  // with webhooks
await client.deleteRows(tableId, [123, 456], sendWebhookEvents: false);  // without webhooks

// Move a row to a new position
final movedRow = await client.moveRow(
  tableId,
  rowId,
  options: MoveRowOptions(
    beforeId: 456,  // Move before this row
    userFieldNames: true,  // Use human-readable field names
    sendWebhookEvents: true,  // Trigger webhooks after move
  ),
);

// Move a row to the end of the table
final movedToEnd = await client.moveRow(
  tableId,
  rowId,
);

MoveRowOptions

Options for customizing row move operations:

MoveRowOptions({
  bool userFieldNames = false,  // Use human-readable field names
  int? beforeId,               // ID of row to move before (null = move to end)
  bool sendWebhookEvents = true, // Whether to trigger webhooks after move
})

The move operation allows you to:

  • Move a row before another specific row using beforeId
  • Move a row to the end of the table by omitting beforeId
  • Control webhook event triggering with sendWebhookEvents
  • Use human-readable field names in the response with userFieldNames

Cleanup

Always close the client when you're done to free up resources:

client.close();

API Documentation

BaserowConfig {#baserowconfig}

Configuration class for the Baserow client.

BaserowConfig({
  required String baseUrl,  // The base URL of your Baserow instance
  String? token,           // Optional API token for authentication
})

BaserowClient {#baserowclient}

Main client class for interacting with the Baserow API.

ListRowsOptions

Options for customizing row listing operations:

ListRowsOptions({
  int? page,                    // The page number to fetch (1-based)
  int? size,                    // The number of rows per page
  String? search,               // Search term to filter rows
  List<String>? orderBy,        // Fields to order by, with optional direction prefix (+ or -)
  String filterType = 'AND',    // Filter type - AND/OR for combining multiple filters
  List<RowFilter>? filters,     // JSON format filters
  Map<String, Map<String, dynamic>>? fieldFilters,  // Individual field filters (supports strings and integers)
  List<String>? include,        // Fields to include in the response
  List<String>? exclude,        // Fields to exclude from the response
  bool includeFieldMetadata,    // Whether to include field metadata
  int? viewId,                  // Optional view ID to scope the request
  bool userFieldNames = false,  // Use human-readable field names instead of field_123
  Map<String, List<String>>? linkRowJoins,  // Link row field joins for related table data
})
Ordering Rows

You can order rows by multiple fields using the orderBy parameter:

// Order by single field descending
options: ListRowsOptions(
  orderBy: ['-name'],
)

// Order by multiple fields
options: ListRowsOptions(
  orderBy: ['+first_name', '-last_name', 'age'],
)

// With special characters in field names
options: ListRowsOptions(
  orderBy: ['First, Name', 'Last "Name"'],  // Will be properly escaped
)
Filtering Rows

There are two ways to filter rows:

  1. Using the JSON format with filters:
options: ListRowsOptions(
  filterType: 'OR',  // Use OR to match any filter, AND to match all
  filters: [
    RowFilter(
      field: 'age',
      operator: FilterOperator.higherThan,
      value: 18,
    ),
    RowFilter(
      field: 'status',
      operator: FilterOperator.equal,
      value: 'active',
    ),
  ],
)
  1. Using individual field filters:
options: ListRowsOptions(
  fieldFilters: {
    'status': {'equal': 'active'},
    'age': {'greater_than': 18},      // Integer values are supported directly
    'rating': {'equal': 5},           // No need to convert to strings
  },
)
Including/Excluding Fields

Control which fields are returned in the response:

options: ListRowsOptions(
  include: ['name', 'email'],  // Only include these fields
  exclude: ['sensitive_data'], // Exclude these fields
)

Fetch related table data through link row fields:

options: ListRowsOptions(
  linkRowJoins: {
    'company': ['name', 'address'],  // Get company name and address
    'department': ['title'],         // Get department title
  },
)

Filter rows using a search term:

options: ListRowsOptions(
  search: 'search term',  // Will search across all searchable fields
)

Methods

  • Future<List<Database>> listDatabases()

    • Lists all databases accessible to the authenticated user
  • Future<List<Table>> listTables(int databaseId)

    • Lists all tables in a specific database
  • Future<List<Map<String, dynamic>>> listRows(int tableId)

    • Lists all rows in a specific table
  • Future<Map<String, dynamic>> createRow(int tableId, Map<String, dynamic> fields)

    • Creates a new row in a table
  • Future<Map<String, dynamic>> updateRow(int tableId, int rowId, Map<String, dynamic> fields)

    • Updates an existing row in a table
  • Future<void> deleteRow(int tableId, int rowId, {bool sendWebhookEvents = true})

    • Deletes a row from a table
    • Optional sendWebhookEvents parameter controls webhook triggering (defaults to true)
  • Future<void> deleteRows(int tableId, List<int> rowIds, {bool sendWebhookEvents = true})

    • Deletes multiple rows from a table in batch mode
    • Takes a list of row IDs to delete
    • Optional sendWebhookEvents parameter controls webhook triggering (defaults to true)

Models {#models}

  • Database - Represents a Baserow database
  • Table - Represents a table within a database
  • Field - Represents a field within a table

Error Handling

The library throws BaserowException for API errors, which includes:

  • Error message
  • HTTP status code

Example error handling:

try {
  final databases = await client.listDatabases();
} on BaserowException catch (e) {
  print('Baserow API error: ${e.message} (Status: ${e.statusCode})');
} catch (e) {
  print('Unexpected error: $e');
}

Additional Information

Testing Support

The SDK provides built-in testing utilities to help you write tests for applications that use Baserow. These utilities make it easy to mock both REST API calls and WebSocket real-time events.

Installation

Add the SDK to your dev_dependencies in pubspec.yaml:

dev_dependencies:
  baserow: ^0.1.0
  test: ^1.24.0

Mocking REST API Calls

import 'package:baserow/baserow.dart';
import 'package:baserow/src/testing.dart';
import 'package:test/test.dart';

void main() {
  test('fetching rows', () async {
    // Create a mock client
    final mockClient = BaserowTestUtils.createMockClient();

    // Configure mock responses
    when(mockClient.listRows(1)).thenAnswer((_) async => RowsResponse(
          count: 1,
          next: null,
          previous: null,
          results: [
            Row(id: 1, order: 1, fields: {'name': 'Test Row'}),
          ],
        ));

    // Use the mock client
    final rows = await mockClient.listRows(1);
    expect(rows.results.first.fields['name'], equals('Test Row'));
  });
}

Testing Real-time Events {#testing-real-time-events}

import 'package:baserow/baserow.dart';
import 'package:baserow/src/testing.dart';
import 'package:test/test.dart';

void main() {
  test('receiving real-time updates', () async {
    // Create a mock WebSocket
    final mockWebSocket = BaserowTestUtils.createMockWebSocket();
    await mockWebSocket.connect();

    // Subscribe to table events
    final subscription = mockWebSocket.subscribeToTable(1);

    // Emit a test event
    mockWebSocket.emitTableEvent(
      1,
      'row_created',
      {
        'row_id': 1,
        'values': {'name': 'New Row'},
      },
    );

    // Verify the event was received
    await expectLater(
      subscription,
      emits(isA<BaserowTableEvent>()
          .having((e) => e.type, 'type', 'row_created')
          .having((e) => e.tableId, 'tableId', 1)),
    );
  });
}

User Stream {#user-stream}

The client provides a stream of the current user that you can listen to for authentication state changes:

// Get access to the user stream
Stream<User?> userStream = client.userStream;

// Listen for user changes
userStream.listen((user) {
  if (user != null) {
    print('User is logged in: ${user.name}');
    print('Email: ${user.email}');
  } else {
    print('User has logged out');
  }
});

The user stream emits:

  • A User object when the user logs in or when their information changes
  • null when the user logs out

This is particularly useful for:

  • Tracking authentication state changes
  • Updating UI based on user login status
  • Accessing current user information in real-time

Testing Error Handling {#testing-error-handling}

test('handling WebSocket errors', () async {
  final mockWebSocket = BaserowTestUtils.createMockWebSocket();
  await mockWebSocket.connect();

  var errorReceived = false;
  mockWebSocket.onError = (error) {
    errorReceived = true;
  };

  // Simulate an error
  mockWebSocket.emitError(Exception('Test error'));

  // Verify error was handled
  await Future.delayed(Duration.zero);
  expect(errorReceived, isTrue);
});

For more examples, check out the testing examples in the repository.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries

baserow
A Dart client for the Baserow API