Attachment API Specification
Attachment API Specification
Version: 1.0.0
This document specifies how to handle file uploads, image uploads, and attachment field types in ObjectQL APIs.
Table of Contents
- Overview
- Field Types
- Data Format
- Upload API
- CRUD Operations with Attachments
- Download & Access
- Best Practices
- Examples
Overview
ObjectQL supports two attachment-related field types:
file: General file attachments (documents, PDFs, archives, etc.)image: Image files with optional image-specific metadata (including user avatars, product photos, galleries, etc.)
All attachment fields store metadata as JSON in the database, while the actual file content is stored in a configurable storage backend (local filesystem, S3, cloud storage, etc.).
Note: User profile pictures (avatars) should use the image type with appropriate constraints (e.g., multiple: false, size limits, aspect ratio requirements). UI frameworks can identify avatar fields by naming conventions (e.g., profile_picture, avatar) to apply specific rendering (circular cropping, etc.).
Design Principles
- Metadata-Driven: File metadata (URL, size, type) is stored in the database
- Storage-Agnostic: Actual files can be stored anywhere (local, S3, CDN)
- URL-Based: Files are referenced by URLs for maximum flexibility
- Type-Safe: Full TypeScript support for attachment data structures
- Validation: Built-in file type, size, and extension validation
Field Types
file Field Type
General-purpose file attachment field.
Properties:
| Property | Type | Description |
|---|---|---|
type | 'file' | Required. Field type identifier |
label | string | Display label |
required | boolean | Whether file is mandatory |
multiple | boolean | Allow multiple file uploads (array) |
accept | string[] | Allowed file extensions (e.g., ['.pdf', '.docx']) |
max_size | number | Maximum file size in bytes |
min_size | number | Minimum file size in bytes |
Example Definition:
# expense.object.yml
fields:
receipt:
type: file
label: Receipt Attachment
required: true
accept: ['.pdf', '.jpg', '.png']
max_size: 5242880 # 5MB
supporting_docs:
type: file
label: Supporting Documents
multiple: true
accept: ['.pdf', '.docx', '.xlsx']
max_size: 10485760 # 10MBimage Field Type
Image-specific attachment field with additional metadata support.
Properties:
| Property | Type | Description |
|---|---|---|
type | 'image' | Required. Field type identifier |
label | string | Display label |
required | boolean | Whether image is mandatory |
multiple | boolean | Allow multiple images (gallery) |
accept | string[] | Allowed image formats (default: ['.jpg', '.jpeg', '.png', '.gif', '.webp']) |
max_size | number | Maximum file size in bytes |
max_width | number | Maximum image width in pixels |
max_height | number | Maximum image height in pixels |
min_width | number | Minimum image width in pixels |
min_height | number | Minimum image height in pixels |
Example Definition:
# product.object.yml
fields:
product_image:
type: image
label: Product Image
required: true
accept: ['.jpg', '.png', '.webp']
max_size: 2097152 # 2MB
max_width: 2000
max_height: 2000
gallery:
type: image
label: Product Gallery
multiple: true
max_size: 5242880 # 5MB per image
# User avatar (profile picture)
profile_picture:
type: image
label: Profile Picture
multiple: false # Single image only
max_size: 1048576 # 1MB
max_width: 500
max_height: 500
accept: ['.jpg', '.png', '.webp']Data Format
Attachment fields store structured JSON data containing file metadata. The actual file content is stored separately.
Single File Format
For non-multiple file/image fields, the data is stored as a single object:
interface AttachmentData {
/** Unique identifier for this file */
id?: string;
/** File name (e.g., "invoice.pdf") */
name: string;
/** Publicly accessible URL to the file */
url: string;
/** File size in bytes */
size: number;
/** MIME type (e.g., "application/pdf", "image/jpeg") */
type: string;
/** Original filename as uploaded by user */
original_name?: string;
/** Upload timestamp (ISO 8601) */
uploaded_at?: string;
/** User ID who uploaded the file */
uploaded_by?: string;
}Example:
{
"id": "file_abc123",
"name": "receipt_2024.pdf",
"url": "https://cdn.example.com/files/receipt_2024.pdf",
"size": 245760,
"type": "application/pdf",
"original_name": "Receipt - Jan 2024.pdf",
"uploaded_at": "2024-01-15T10:30:00Z",
"uploaded_by": "user_xyz"
}Multiple Files Format
For multiple: true fields, the data is an array of attachment objects:
type MultipleAttachmentData = AttachmentData[];Example:
[
{
"id": "img_001",
"name": "product_front.jpg",
"url": "https://cdn.example.com/images/product_front.jpg",
"size": 156789,
"type": "image/jpeg",
"uploaded_at": "2024-01-15T10:30:00Z"
},
{
"id": "img_002",
"name": "product_back.jpg",
"url": "https://cdn.example.com/images/product_back.jpg",
"size": 142356,
"type": "image/jpeg",
"uploaded_at": "2024-01-15T10:31:00Z"
}
]Image-Specific Metadata
Image fields can include additional metadata:
interface ImageAttachmentData extends AttachmentData {
/** Image width in pixels */
width?: number;
/** Image height in pixels */
height?: number;
/** Thumbnail URL (if generated) */
thumbnail_url?: string;
/** Alternative sizes/versions */
variants?: {
small?: string;
medium?: string;
large?: string;
};
}Example:
{
"id": "img_abc123",
"name": "product_hero.jpg",
"url": "https://cdn.example.com/images/product_hero.jpg",
"size": 523400,
"type": "image/jpeg",
"width": 1920,
"height": 1080,
"thumbnail_url": "https://cdn.example.com/images/product_hero_thumb.jpg",
"variants": {
"small": "https://cdn.example.com/images/product_hero_small.jpg",
"medium": "https://cdn.example.com/images/product_hero_medium.jpg",
"large": "https://cdn.example.com/images/product_hero_large.jpg"
}
}Upload API
ObjectQL provides dedicated endpoints for file uploads using multipart/form-data.
Upload Endpoint
POST /api/files/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>Request Format
Use standard multipart form data with the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | The file to upload |
object | string | No | Object name (for context/validation) |
field | string | No | Field name (for validation against field config) |
folder | string | No | Logical folder/path for organization |
Response Format
Success Response (200 OK):
{
"data": {
"id": "file_abc123",
"name": "invoice.pdf",
"url": "https://cdn.example.com/files/invoice.pdf",
"size": 245760,
"type": "application/pdf",
"uploaded_at": "2024-01-15T10:30:00Z",
"uploaded_by": "user_xyz"
}
}Error Response (400 Bad Request):
{
"error": {
"code": "FILE_VALIDATION_ERROR",
"message": "File validation failed",
"details": {
"file": "invoice.exe",
"reason": "File type not allowed. Allowed types: .pdf, .jpg, .png"
}
}
}Upload Examples
Using cURL:
curl -X POST https://api.example.com/api/files/upload \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@/path/to/invoice.pdf" \
-F "object=expense" \
-F "field=receipt"Using JavaScript Fetch:
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const formData = new FormData();
formData.append('file', file);
formData.append('object', 'expense');
formData.append('field', 'receipt');
const response = await fetch('/api/files/upload', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
},
body: formData
});
const { data: uploadedFile } = await response.json();
console.log('Uploaded:', uploadedFile);
// { id: 'file_abc123', url: '...', ... }Using Axios:
import axios from 'axios';
const formData = new FormData();
formData.append('file', file);
formData.append('object', 'expense');
formData.append('field', 'receipt');
const response = await axios.post('/api/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': 'Bearer ' + token
}
});
const uploadedFile = response.data.data;Batch Upload
For uploading multiple files at once:
POST /api/files/upload/batch
Content-Type: multipart/form-dataRequest:
curl -X POST https://api.example.com/api/files/upload/batch \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "files=@/path/to/image1.jpg" \
-F "files=@/path/to/image2.jpg" \
-F "files=@/path/to/image3.jpg" \
-F "object=product" \
-F "field=gallery"Response:
{
"data": [
{
"id": "img_001",
"name": "image1.jpg",
"url": "https://cdn.example.com/images/image1.jpg",
"size": 156789,
"type": "image/jpeg"
},
{
"id": "img_002",
"name": "image2.jpg",
"url": "https://cdn.example.com/images/image2.jpg",
"size": 142356,
"type": "image/jpeg"
},
{
"id": "img_003",
"name": "image3.jpg",
"url": "https://cdn.example.com/images/image3.jpg",
"size": 198234,
"type": "image/jpeg"
}
]
}CRUD Operations with Attachments
Creating Records with Attachments
Step 1: Upload the file(s)
// Upload file first
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const uploadedFile = (await uploadResponse.json()).data;Step 2: Create record with file metadata
// Create expense record with the uploaded file
const createResponse = await fetch('/api/objectql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
op: 'create',
object: 'expense',
args: {
expense_number: 'EXP-2024-001',
amount: 125.50,
description: 'Office supplies',
receipt: uploadedFile // Reference to uploaded file
}
})
});
const expense = (await createResponse.json()).data;Complete Example (JSON-RPC):
{
"op": "create",
"object": "expense",
"args": {
"expense_number": "EXP-2024-001",
"amount": 125.50,
"category": "office_supplies",
"description": "Office supplies - printer paper",
"receipt": {
"id": "file_abc123",
"name": "receipt.pdf",
"url": "https://cdn.example.com/files/receipt.pdf",
"size": 245760,
"type": "application/pdf"
}
}
}Creating with Multiple Attachments
{
"op": "create",
"object": "product",
"args": {
"name": "Premium Laptop",
"price": 1299.99,
"description": "High-performance laptop",
"gallery": [
{
"id": "img_001",
"name": "laptop_front.jpg",
"url": "https://cdn.example.com/images/laptop_front.jpg",
"size": 156789,
"type": "image/jpeg",
"width": 1920,
"height": 1080
},
{
"id": "img_002",
"name": "laptop_back.jpg",
"url": "https://cdn.example.com/images/laptop_back.jpg",
"size": 142356,
"type": "image/jpeg",
"width": 1920,
"height": 1080
}
]
}
}Updating Attachments
Replace entire attachment:
{
"op": "update",
"object": "expense",
"args": {
"id": "exp_xyz",
"data": {
"receipt": {
"id": "file_new123",
"name": "updated_receipt.pdf",
"url": "https://cdn.example.com/files/updated_receipt.pdf",
"size": 198234,
"type": "application/pdf"
}
}
}
}Add to multiple attachments (array):
// First, fetch the current record
const currentRecord = await fetch('/api/objectql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
op: 'findOne',
object: 'product',
args: 'product_123'
})
}).then(r => r.json());
// Upload new image
const newImage = await uploadFile(file);
// Update with appended gallery
await fetch('/api/objectql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
op: 'update',
object: 'product',
args: {
id: 'product_123',
data: {
gallery: [...currentRecord.data.gallery, newImage]
}
}
})
});Remove attachment:
{
"op": "update",
"object": "expense",
"args": {
"id": "exp_xyz",
"data": {
"receipt": null
}
}
}Querying Records with Attachments
Attachments are returned as part of the record data:
{
"op": "find",
"object": "expense",
"args": {
"fields": ["id", "expense_number", "amount", "receipt"],
"filters": [["status", "=", "approved"]]
}
}Response:
{
"data": [
{
"id": "exp_001",
"expense_number": "EXP-2024-001",
"amount": 125.50,
"receipt": {
"id": "file_abc123",
"name": "receipt.pdf",
"url": "https://cdn.example.com/files/receipt.pdf",
"size": 245760,
"type": "application/pdf"
}
},
{
"id": "exp_002",
"expense_number": "EXP-2024-002",
"amount": 89.99,
"receipt": null // No receipt attached
}
]
}Filtering by Attachment Presence
Check if a file is attached:
{
"op": "find",
"object": "expense",
"args": {
"filters": [["receipt", "!=", null]]
}
}Check if no file is attached:
{
"op": "find",
"object": "expense",
"args": {
"filters": [["receipt", "=", null]]
}
}Download & Access
Direct URL Access
Files are accessed directly via their url property:
const expense = await fetchExpense('exp_123');
const receiptUrl = expense.receipt.url;
// Download file
window.open(receiptUrl, '_blank');
// Or display image
document.getElementById('receipt-img').src = receiptUrl;Secure Download Endpoint
For files requiring authentication:
GET /api/files/:fileId
Authorization: Bearer <token>Example:
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
https://api.example.com/api/files/file_abc123 \
--output receipt.pdfThumbnail/Preview Endpoint
For images, request specific sizes:
GET /api/files/:fileId/thumbnail?size=small|medium|large
GET /api/files/:fileId/preview?width=200&height=200Example:
<!-- Display thumbnail -->
<img src="/api/files/img_abc123/thumbnail?size=medium" alt="Product" />
<!-- Display custom size -->
<img src="/api/files/img_abc123/preview?width=300&height=300" alt="Product" />Best Practices
Security
- Validate File Types: Always specify
acceptto restrict file types - Enforce Size Limits: Set appropriate
max_sizeto prevent abuse - Scan for Malware: Integrate virus scanning for uploaded files
- Use Signed URLs: For sensitive files, use time-limited signed URLs
- Authenticate Downloads: Require authentication for private files
Performance
- Use CDN: Store files on CDN for fast global access
- Generate Thumbnails: Pre-generate image thumbnails for galleries
- Lazy Load Images: Load images on-demand in lists
- Compress Images: Automatically compress uploaded images
- Cache Metadata: Cache file metadata to reduce database queries
Storage
- Organize by Object: Store files in folders by object type
- Use Object Storage: Use S3, GCS, or Azure Blob for scalability
- Implement Cleanup: Delete orphaned files periodically
- Version Files: Keep file versions for audit trails
- Backup Regularly: Include files in backup strategy
User Experience
- Show Upload Progress: Display progress bars for large files
- Preview Before Upload: Show image previews before submission
- Validate Client-Side: Check file type/size before upload
- Provide Feedback: Clear error messages for upload failures
- Support Drag & Drop: Enable drag-and-drop file upload
Examples
Complete Upload & Create Flow
/**
* Upload a file and create an expense record
*/
async function createExpenseWithReceipt(expenseData, receiptFile) {
// Step 1: Upload the receipt file
const formData = new FormData();
formData.append('file', receiptFile);
formData.append('object', 'expense');
formData.append('field', 'receipt');
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + getAuthToken()
},
body: formData
});
if (!uploadResponse.ok) {
throw new Error('File upload failed');
}
const uploadedFile = (await uploadResponse.json()).data;
// Step 2: Create expense record with file metadata
const createResponse = await fetch('/api/objectql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + getAuthToken()
},
body: JSON.stringify({
op: 'create',
object: 'expense',
args: {
...expenseData,
receipt: uploadedFile
}
})
});
if (!createResponse.ok) {
throw new Error('Failed to create expense');
}
return (await createResponse.json()).data;
}
// Usage
const file = document.getElementById('receipt-input').files[0];
const expense = await createExpenseWithReceipt({
expense_number: 'EXP-2024-001',
amount: 125.50,
category: 'office_supplies',
description: 'Printer paper and toner'
}, file);
console.log('Created expense:', expense);Product Gallery Management
/**
* Upload multiple images and create product
*/
async function createProductWithGallery(productData, imageFiles) {
// Upload all images
const uploadPromises = Array.from(imageFiles).map(async (file) => {
const formData = new FormData();
formData.append('file', file);
formData.append('object', 'product');
formData.append('field', 'gallery');
const response = await fetch('/api/files/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getAuthToken() },
body: formData
});
return (await response.json()).data;
});
const uploadedImages = await Promise.all(uploadPromises);
// Create product with gallery
const response = await fetch('/api/objectql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + getAuthToken()
},
body: JSON.stringify({
op: 'create',
object: 'product',
args: {
...productData,
gallery: uploadedImages
}
})
});
return (await response.json()).data;
}
// Usage
const files = document.getElementById('gallery-input').files;
const product = await createProductWithGallery({
name: 'Premium Laptop',
price: 1299.99,
description: 'High-performance laptop'
}, files);Update User Avatar
/**
* Update user profile picture
*/
async function updateUserAvatar(userId, avatarFile) {
// Upload avatar
const formData = new FormData();
formData.append('file', avatarFile);
formData.append('object', 'user');
formData.append('field', 'profile_picture');
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getAuthToken() },
body: formData
});
const uploadedAvatar = (await uploadResponse.json()).data;
// Update user record
const updateResponse = await fetch('/api/objectql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + getAuthToken()
},
body: JSON.stringify({
op: 'update',
object: 'user',
args: {
id: userId,
data: {
profile_picture: uploadedAvatar
}
}
})
});
return (await updateResponse.json()).data;
}
// Usage
const avatarFile = document.getElementById('avatar-input').files[0];
const updatedUser = await updateUserAvatar('user_123', avatarFile);React Component Example
import React, { useState } from 'react';
import { ObjectQLClient } from '@objectql/sdk';
interface UploadReceiptProps {
expenseId?: string;
onSuccess?: (expense: any) => void;
}
export const UploadReceipt: React.FC<UploadReceiptProps> = ({
expenseId,
onSuccess
}) => {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file
if (!file.type.match(/^(application\/pdf|image\/(jpeg|png))$/)) {
setError('Only PDF, JPG, and PNG files are allowed');
return;
}
if (file.size > 5 * 1024 * 1024) {
setError('File size must be less than 5MB');
return;
}
setUploading(true);
setError(null);
try {
// Upload file
const formData = new FormData();
formData.append('file', file);
formData.append('object', 'expense');
formData.append('field', 'receipt');
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + getAuthToken()
},
body: formData
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
const uploadedFile = (await uploadResponse.json()).data;
// Update expense with receipt
const client = new ObjectQLClient();
const expense = await client.update('expense', expenseId!, {
receipt: uploadedFile
});
onSuccess?.(expense);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileChange}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
};Error Codes
| Code | Description |
|---|---|
FILE_TOO_LARGE | File exceeds max_size limit |
FILE_TOO_SMALL | File is smaller than min_size |
FILE_TYPE_NOT_ALLOWED | File extension not in accept list |
IMAGE_DIMENSIONS_INVALID | Image dimensions don't meet requirements |
UPLOAD_FAILED | General upload failure |
STORAGE_QUOTA_EXCEEDED | Storage quota exceeded |
FILE_NOT_FOUND | Requested file doesn't exist |
FILE_ACCESS_DENIED | User doesn't have permission to access file |
TypeScript Definitions
/**
* Attachment field data structure
*/
export interface AttachmentData {
id?: string;
name: string;
url: string;
size: number;
type: string;
original_name?: string;
uploaded_at?: string;
uploaded_by?: string;
}
/**
* Image-specific attachment data
*/
export interface ImageAttachmentData extends AttachmentData {
width?: number;
height?: number;
thumbnail_url?: string;
variants?: {
small?: string;
medium?: string;
large?: string;
};
}
/**
* Upload response
*/
export interface UploadResponse {
data: AttachmentData;
}
/**
* Batch upload response
*/
export interface BatchUploadResponse {
data: AttachmentData[];
}Server Implementation
Setting up File Storage
ObjectQL provides a flexible file storage abstraction that supports multiple backends.
Using Local File Storage
import { ObjectStackKernel } from '@objectstack/runtime';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import { ObjectQLPlugin } from '@objectql/core';
import { SqlDriver } from '@objectql/driver-sql';
const kernel = new ObjectStackKernel([
appConfig,
new SqlDriver({ client: 'sqlite3', connection: { filename: ':memory:' } }),
new ObjectQLPlugin(),
new HonoServerPlugin({
port: 3000,
fileStorage: {
type: 'local',
baseDir: './uploads',
baseUrl: 'http://localhost:3000/api/files'
}
})
]);
await kernel.start();Using Memory Storage (For Testing)
const kernel = new ObjectStackKernel([
appConfig,
new SqlDriver({ client: 'sqlite3', connection: { filename: ':memory:' } }),
new ObjectQLPlugin(),
new HonoServerPlugin({
port: 3000,
fileStorage: {
type: 'memory',
baseUrl: 'http://localhost:3000/api/files'
}
})
]);Custom Storage Implementation
You can implement custom storage backends by implementing the IFileStorage interface:
import { IFileStorage, AttachmentData, FileStorageOptions } from '@objectql/types';
class S3FileStorage implements IFileStorage {
async save(
file: Buffer,
filename: string,
mimeType: string,
options?: FileStorageOptions
): Promise<AttachmentData> {
// Upload to S3
const key = `${options?.folder || 'uploads'}/${Date.now()}-${filename}`;
await s3.putObject({
Bucket: 'my-bucket',
Key: key,
Body: file,
ContentType: mimeType
}).promise();
return {
id: key,
name: filename,
url: `https://my-bucket.s3.amazonaws.com/${key}`,
size: file.length,
type: mimeType,
uploaded_at: new Date().toISOString(),
uploaded_by: options?.userId
};
}
async get(fileId: string): Promise<Buffer | null> {
// Download from S3
const result = await s3.getObject({
Bucket: 'my-bucket',
Key: fileId
}).promise();
return result.Body as Buffer;
}
async delete(fileId: string): Promise<boolean> {
// Delete from S3
await s3.deleteObject({
Bucket: 'my-bucket',
Key: fileId
}).promise();
return true;
}
getPublicUrl(fileId: string): string {
return `https://my-bucket.s3.amazonaws.com/${fileId}`;
}
}
// Use custom storage
const fileStorage = new S3FileStorage();
const handler = createNodeHandler(app, { fileStorage });For S3 integration, implement a custom FileStorage class following the pattern above.
API Endpoints
The file upload/download functionality is automatically available when using createNodeHandler:
- POST /api/files/upload - Single file upload
- POST /api/files/upload/batch - Batch file upload
- GET /api/files/:fileId - Download file
File Validation
File validation is automatically enforced based on field configuration:
# expense.object.yml
fields:
receipt:
type: file
label: Receipt
accept: ['.pdf', '.jpg', '.png'] # Only these extensions
max_size: 5242880 # 5MB max
min_size: 1024 # 1KB minValidation errors return standardized responses:
{
"error": {
"code": "FILE_TOO_LARGE",
"message": "File size (6000000 bytes) exceeds maximum allowed size (5242880 bytes)",
"details": {
"file": "receipt.pdf",
"size": 6000000,
"max_size": 5242880
}
}
}Environment Variables
Configure file storage behavior using environment variables:
| Variable | Description | Default |
|---|---|---|
OBJECTQL_UPLOAD_DIR | Directory for local file storage | ./uploads |
OBJECTQL_BASE_URL | Base URL for file access | http://localhost:3000/api/files |
Related Documentation
- Object Definition Specification - Field type definitions
- API Reference - Complete API documentation
- Validation Rules - File validation configuration
- Server Integration - Setting up file storage
Last Updated: January 2026
API Version: 1.0.0