OData V4 Protocol Plugin for ObjectStack
This plugin provides full OData V4 protocol support for ObjectStack applications. It automatically generates OData metadata (EDMX) from ObjectStack metadata and provides a RESTful API for querying and manipulating data.
- ✅ Service Document (
/) - Discover available entity sets - ✅ Metadata Document (
/$metadata) - EDMX schema generation - ✅ Entity Queries - Query entity sets with OData query options
- ✅ Query Options - Support for
$filter,$select,$orderby,$top,$skip,$count,$expand - ✅ CRUD Operations - Full Create, Read, Update, Delete support
- ✅ CORS Support - Configurable Cross-Origin Resource Sharing
- ✅ Type Mapping - Automatic ObjectQL to EDM type conversion
- ✅ No Direct DB Access - All operations through ObjectStackRuntimeProtocol
pnpm add @objectql/protocol-odata-v4import { ObjectKernel } from '@objectstack/runtime';
import { ODataV4Plugin } from '@objectql/protocol-odata-v4';
const kernel = new ObjectKernel([
new ODataV4Plugin({
port: 8080,
basePath: '/odata',
namespace: 'MyApp',
enableCORS: true
})
]);
await kernel.start();interface ODataV4PluginConfig {
/** Port to listen on (default: 8080) */
port?: number;
/** Base path for OData endpoints (default: '/odata') */
basePath?: string;
/** Enable CORS (default: true) */
enableCORS?: boolean;
/** Service namespace (default: 'ObjectStack') */
namespace?: string;
}GET {basePath}/
Returns a list of available entity sets.
curl http://localhost:8080/odata/Response:
{
"@odata.context": "http://localhost:8080/odata/$metadata",
"value": [
{ "name": "users", "kind": "EntitySet", "url": "users" },
{ "name": "projects", "kind": "EntitySet", "url": "projects" }
]
}GET {basePath}/$metadata
Returns the EDMX metadata document describing the data model.
curl http://localhost:8080/odata/\$metadataResponse: XML/EDMX schema
GET {basePath}/{EntitySet}
Query all entities in a set.
curl http://localhost:8080/odata/usersResponse:
{
"@odata.context": "http://localhost:8080/odata/$metadata#users",
"value": [
{ "id": "1", "name": "Alice", "email": "alice@example.com" },
{ "id": "2", "name": "Bob", "email": "bob@example.com" }
]
}Filter results based on criteria.
curl "http://localhost:8080/odata/users?\$filter=name eq 'Alice'"Sort results.
curl "http://localhost:8080/odata/users?\$orderby=name desc"Pagination.
curl "http://localhost:8080/odata/users?\$top=10&\$skip=20"Include count in response.
curl "http://localhost:8080/odata/users?\$count=true"Response includes @odata.count field:
{
"@odata.context": "http://localhost:8080/odata/$metadata#users",
"@odata.count": 42,
"value": [...]
}You can also get just the count:
curl "http://localhost:8080/odata/users/\$count"Response: 42 (plain text)
Expand related entities (navigation properties).
Single property:
curl "http://localhost:8080/odata/orders?\$expand=customer"Multiple properties:
curl "http://localhost:8080/odata/orders?\$expand=customer,shipper"With filter:
curl "http://localhost:8080/odata/orders?\$expand=items(\$filter=status eq 'active')"With select:
curl "http://localhost:8080/odata/orders?\$expand=customer(\$select=name,email)"Supported expand options:
$filter- Filter expanded entities$select- Select specific fields from expanded entities$orderby- Sort expanded entities$top- Limit number of expanded entities
Current Limitations:
⚠️ Nested expand (e.g.,$expand=owner($expand=department)) is not yet supported- Only single-level relationship expansion is available
- See Phase 2 Roadmap for planned nested expand support
Response includes expanded entities:
{
"@odata.context": "http://localhost:8080/odata/$metadata#orders",
"value": [
{
"_id": "order1",
"total": 100,
"customer": "user123",
"customer@expanded": {
"_id": "user123",
"name": "Alice",
"email": "alice@example.com"
}
}
]
}GET {basePath}/{EntitySet}('{id}')
Retrieve a single entity by ID.
curl http://localhost:8080/odata/users\('123'\)POST {basePath}/{EntitySet}
Create a new entity.
curl -X POST http://localhost:8080/odata/users \
-H "Content-Type: application/json" \
-d '{"name":"Charlie","email":"charlie@example.com"}'PUT/PATCH {basePath}/{EntitySet}('{id}')
Update an existing entity.
curl -X PUT http://localhost:8080/odata/users\('123'\) \
-H "Content-Type: application/json" \
-d '{"name":"Charlie Updated"}'DELETE {basePath}/{EntitySet}('{id}')
Delete an entity.
curl -X DELETE http://localhost:8080/odata/users\('123'\)The OData V4 plugin supports batch requests for executing multiple operations in a single HTTP request. Batch operations can include both read operations and changesets (for transactional writes).
POST {basePath}/$batch
Batch requests use multipart MIME format with a boundary delimiter.
Example: Mixed Read and Write Operations
POST /odata/$batch HTTP/1.1
Content-Type: multipart/mixed; boundary=batch_123
--batch_123
Content-Type: application/http
GET /odata/users HTTP/1.1
--batch_123
Content-Type: application/http
GET /odata/users('user1') HTTP/1.1
--batch_123
Content-Type: multipart/mixed; boundary=changeset_456
--changeset_456
Content-Type: application/http
POST /odata/users HTTP/1.1
Content-Type: application/json
{"name":"Alice","email":"alice@example.com"}
--changeset_456
Content-Type: application/http
POST /odata/projects HTTP/1.1
Content-Type: application/json
{"name":"Project Alpha","owner":"user1"}
--changeset_456--
--batch_123--Changesets are groups of write operations (POST, PATCH, PUT, DELETE) that are executed atomically. Either all operations in a changeset succeed, or all fail and are rolled back.
Example: Atomic Multi-Entity Creation
POST /odata/$batch HTTP/1.1
Content-Type: multipart/mixed; boundary=batch_123
--batch_123
Content-Type: multipart/mixed; boundary=changeset_456
--changeset_456
Content-Type: application/http
POST /odata/users HTTP/1.1
Content-Type: application/json
{"name":"Alice","email":"alice@example.com"}
--changeset_456
Content-Type: application/http
POST /odata/users HTTP/1.1
Content-Type: application/json
{"name":"Bob","email":"bob@example.com"}
--changeset_456
Content-Type: application/http
POST /odata/users HTTP/1.1
Content-Type: application/json
{"name":"Charlie","email":"charlie@example.com"}
--changeset_456--
--batch_123--Atomic Behavior:
- If any operation in the changeset fails, all operations are rolled back
- No partial commits - it's all or nothing
- Ideal for maintaining data consistency
The plugin provides detailed error information when batch operations fail:
Error Response Format:
{
"error": {
"code": "CHANGESET_FAILED",
"message": "Changeset operation 2/5 failed: Validation failed for field 'email'",
"details": {
"completedOperations": 1,
"totalOperations": 5,
"rollbackAttempted": true
}
}
}Error Details:
code- Error code identifying the failure typemessage- Detailed error message including which operation faileddetails.completedOperations- Number of operations completed before failuredetails.totalOperations- Total number of operations in the changesetdetails.rollbackAttempted- Whether rollback was attempted
-
Use Changesets for Related Writes
- Group related POST/PATCH/DELETE operations in a changeset
- Ensures data consistency across multiple entities
-
Separate Read and Write Operations
- Put read operations (GET) outside changesets
- Only write operations (POST, PATCH, PUT, DELETE) should be in changesets
-
Handle Errors Gracefully
- Check for
CHANGESET_FAILEDerror code - Use the
detailsobject to understand what failed - Implement retry logic if appropriate
- Check for
-
Limit Batch Size
- Keep batch requests reasonable in size
- Consider breaking very large batches into multiple requests
Example: JavaScript Client
async function createMultipleUsers(users) {
const boundary = `batch_${Date.now()}`;
const changesetBoundary = `changeset_${Date.now()}`;
let body = '';
// Start changeset
body += `--${boundary}\r\n`;
body += `Content-Type: multipart/mixed; boundary=${changesetBoundary}\r\n\r\n`;
// Add each user creation
users.forEach(user => {
body += `--${changesetBoundary}\r\n`;
body += `Content-Type: application/http\r\n\r\n`;
body += `POST /odata/users HTTP/1.1\r\n`;
body += `Content-Type: application/json\r\n\r\n`;
body += JSON.stringify(user) + '\r\n';
});
// Close changeset and batch
body += `--${changesetBoundary}--\r\n`;
body += `--${boundary}--\r\n`;
const response = await fetch('http://localhost:8080/odata/$batch', {
method: 'POST',
headers: {
'Content-Type': `multipart/mixed; boundary=${boundary}`
},
body
});
if (!response.ok) {
const error = await response.json();
console.error('Batch operation failed:', error);
if (error.error?.code === 'CHANGESET_FAILED') {
console.error(`Failed at operation ${error.error.details.completedOperations + 1}/${error.error.details.totalOperations}`);
console.error(`Rollback attempted: ${error.error.details.rollbackAttempted}`);
}
throw new Error(error.error?.message || 'Batch operation failed');
}
return response;
}
// Usage
const users = [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Charlie', email: 'charlie@example.com' }
];
try {
await createMultipleUsers(users);
console.log('All users created successfully');
} catch (error) {
console.error('Failed to create users:', error);
}ObjectQL field types are automatically mapped to OData EDM types:
| ObjectQL Type | EDM Type |
|---|---|
| text, textarea, email, url | Edm.String |
| number, currency, percent | Edm.Double |
| autonumber | Edm.Int32 |
| boolean | Edm.Boolean |
| date | Edm.Date |
| datetime | Edm.DateTimeOffset |
| select | Edm.String |
| lookup, master_detail | Edm.String (ID reference) |
This plugin follows the ObjectStack protocol plugin pattern:
- RuntimePlugin Interface - Implements the standard plugin lifecycle
- ObjectStackRuntimeProtocol Bridge - Uses the protocol bridge for all kernel interactions
- No Direct DB Access - All data operations through the bridge layer
- Lifecycle Management - Proper initialization and cleanup
export class ODataV4Plugin implements RuntimePlugin {
async install(ctx: RuntimeContext) {
// Initialize protocol bridge
this.protocol = new ObjectStackRuntimeProtocol(ctx.engine);
}
async onStart(ctx: RuntimeContext) {
// Start HTTP server
// Use this.protocol.getMetaTypes(), this.protocol.findData(), etc.
}
async onStop(ctx: RuntimeContext) {
// Stop HTTP server
}
}See the multi-protocol-server example for a complete working example.
MIT