Uniqorn

Uniqorn

Serverless Java REST API

Overview

Uniqorn is a lightweight serverless Java runtime for deploying REST APIs with minimal overhead and native compatibility with AI Agents.

It allows developers to focus on business logic by handling technical concerns such as compilation, deployment, and infrastructure management. Storage, security, and other services are configured globally and can be used within custom APIs, eliminating the need to implement them manually.

Uniqorn runs on the Aeonics framework, ensuring minimal dependencies and efficient processing while maintaining a predictable execution model.

Uniqorn is under development and some breaking changes may happen on minor releases. However, we are dedicated to providing the best seamless experience for your APIs.

Screenshot

Showcase

The Uniqorn framework is straightforward and provides a minimal set of classes to handle common operations. It avoids annotations, preprocessors, and reflection to ensure that the code executed is the one you wrote.

import uniqorn.*;

public class Custom implements Supplier<Api> {
	public Api get() {
		return new Api("/api/test", "GET")
			.parameter("name")
			.process(data -> {
				return JSON.object()
					.put("hello", data.get("name"));
			});
	}
}

See the Code Structure section for more details.

Features

Uniqorn is both a platform and a framework, giving you control over API development without the usual hassle. Some features are built into the runtime and configurable via the web interface, no coding required. Others are exposed through the framework, providing utilities to streamline development while keeping things predictable.

Platform

  • Runtime Management: Handles execution, memory allocation, and scheduling to keep APIs running efficiently without manual tuning.
  • Networking & Routing: Manages HTTP handling, connectivity, and API endpoint routing, so you don't have to wire things up manually.
  • Security Policies: Centralized setup for authentication, encryption, and access control, ensuring consistency across all APIs.
  • Storage Abstraction: Define and manage storage independently from your code, keeping APIs clean and decoupled.
  • Traffic Smoothing: Built-in queuing absorbs traffic spikes and optimizes workload distribution without extra infrastructure.
  • Logging & Monitoring: Shared observability framework providing insights into API performance, errors, and system behavior.

Framework

  • Data Oriented: Data is accessible using a JSON-like structure with plenty of built-in coercions, so you don't waste time on boilerplate parsing or POJO mapping.
  • Built-in Parameter Validation: Automatically enforces input constraints, sanitizing API parameters to prevent bad data and security risks.
  • Intuitive Utility Wrappers: Simplifies interactions with platform features like storage, security, and logging so you can focus on logic, not plumbing.
  • Effortless HTTP Calls: Provides a clean, no-fuss way to fetch external data and define webhooks.
  • Optional Inline API Docs: Generate API documentation directly from code, keeping it always up-to-date with minimal effort.
  • Event-Driven Execution: Supports asynchronous patterns out of the box, making it easy to handle background tasks and real-time events.
  • Controlled Concurrency: Simple mechanisms to define parallelism limits, preventing resource starvation and ensuring predictable performance.
  • Fluent API Design: Reduces redundancy with a chainable, expressive syntax for cleaner, more readable code.

Getting Started

This section walks you through setting up your first API.

The approach might differ from what you're used to, but don't worry, you'll get the hang of it quickly.

Basic Concepts

Uniqorn provides a structured way to develop and deploy APIs while keeping things lightweight and flexible. Let's introduce the fundamental principles of how your environment is organized and how APIs are managed.

Instance & Authentication

When you subscribe to a plan, Uniqorn automatically provisions a dedicated instance for you. The details to access this instance are sent via email, including the connection credentials. Depending on your plan, your instance may run on a shared infrastructure (for trial and personal plans) or on fully dedicated infrastructure (for team and enterprise plans).

We will never-ever ask for your credentials in any way, your privacy matters.
If you do not receive our emails, please check your spam filter.
Be sure to always check the sender of the email to detect fraudulent phishing attempts.

For security reasons, logging in requires Multi-Factor Authentication (MFA). You will need to configure an authenticator app such as Google Authenticator, Authy, or any compatible alternative at your first connection. This extra layer of security ensures that access to your environment is protected.

Screenshot

Workspaces & Organization

Once inside your personal instance, you can organize your work using workspaces. A workspace is a logical grouping of resources that allows you to structure your APIs and services in a way that makes sense for your use case. Workspaces can represent different projects, API versions, staging environments, or any other logical separation you need. However, workspaces do not impose physical isolation. All APIs and resources within your instance share the same execution environment, meaning they can interact with each other and have the possibility to produce side effects, for better or worst.

If complete separation is required, such as between development and production environments, it is recommended to use separate instances (another subscription) rather than relying solely on workspaces.

Screenshot

Configuring Resources & Deploying APIs

Within each workspace, you can configure various static resources and deploy API endpoints. Static resources include database connections, storage locations, and other necessary components that support your APIs. These configurations remain available globally within your instance, ensuring that APIs across different workspaces can access them as needed.

Deploying an API is a straightforward process. You have the option to:

  • import an existing code file,
  • define an API using an OpenAPI specification,
  • or write the code manually in the editor.
Regardless of the method used, each deployment requires a review step before the API is published and made available. APIs can also be enabled or disabled on demand without deleting them, allowing for quick modifications and testing without affecting the overall setup.

Screenshot

Live Code Execution

When you publish an API, the raw code is dynamically compiled and deployed instantly, requiring no additional build steps or restarts. This ensures that changes take effect immediately without downtime. If an error occurs or an update needs to be made, you can simply modify the endpoint and republish it. There is no built-in rollback mechanism per-se, but if a previous version needs to be restored, you can reload a manually tagged version of the code.

It may happen that your instance is stuck because of infitinite loops or other mistakes. You have the possibility to trigger a full instance restart to reset the system and perform the required changes.
If you encounter unrecoverable issues, please contact us.

Versioning APIs

Uniqorn provides a simple tagging mechanism for versioning APIs. At any point, you can manually tag a version of your API to save its current state. This allows you to return to that version later if needed. However, this is not a replacement for traditional version control tools like Git, as it does not track diffs or maintain a full history of modifications. Instead, it serves as a lightweight way to bookmark significant versions of your API for future reference.

See API Versioning for more details.

Screenshot

Key Considerations

Since all APIs within an instance share the same execution environment, it is important to consider potential interactions and side effects. Workspaces provide an organizational structure, but they do not enforce runtime isolation. If strict separation is required, using separate instances for different environments is the recommended approach.

For managing API updates, the ability to quickly republish an endpoint simplifies error recovery, but there is no automated rollback. Users are encouraged to tag stable versions to facilitate manual rollbacks if necessary. While Uniqorn streamlines deployment and management, it does not replace traditional development workflows, so integrating with external version control systems remains a best practice for tracking changes comprehensively. The entire Uniqorn system is also API-based so it is fairly straightforward to setup a CI/CD system to automate the deployment process.

Code Structure

Let's see how Uniqorn APIs are structured and the different components involved in defining an endpoint. While this does not replace full Javadoc documentation, it provides enough detail to help you understand the overall design and quickly get started.

import uniqorn.*;

public class Custom implements Supplier<Api> {
	private AtomicInteger counter = new AtomicInteger(0);
	public Api get() {
		return new Api("/api/hello", "GET")
			.parameter("name")
			.description("This API will greet the user and return a counter value")
			.allowUser("Bob")
			.concurrency(2)
			.process(data -> {
				return JSON.object()
					.put("hello", data.get("name"))
					.put("counter", counter.incrementAndGet());
			});
	}
}

In the following sections we will dissect each part of the code to know more about how it works and implications.

Imports and Common Dependencies (mandatory)

Every API implementation starts with the uniqorn.* import, notice that there is no package defined, on purpose.

import uniqorn.*;

When you use this import, Uniqorn automatically includes commonly used Java packages and framework utilities. This reduces boilerplate, so you don't have to manually import standard libraries like java.util.* unless you need something beyond the default set.

Defining an API Supplier (mandatory)

Uniqorn follows a supplier-based approach for defining APIs. Instead of enforcing strict class naming conventions, you implement a Supplier<Api> to return the actual API instance:

public class Custom implements Supplier<Api> {
	public Api get() {

Unlike traditional Java frameworks, the class name is irrelevant, different APIs can use the same or different class names without conflict. This structure allows you to define protected member variables, helper methods, or inner classes inside the API definition. These internal elements remain isolated to the specific API and are not shared with other APIs.

public class Custom implements Supplier<Api> {
	private AtomicInteger counter = new AtomicInteger(0);
	public Api get() {

If you need to share data or functionality between multiple APIs, refer to the States & Sharing Data section of the documentation.

The API Endpoint (mandatory)

The API instance is created with a path and HTTP method:

		return new Api("/api/test", "GET")

You have complete control over the path structure and HTTP method. Paths can include named parameters in curly brackets, such as {name}, which will automatically be mapped to incoming request parameters.

You can use any HTTP method you like, although it must match exactly what the request contains in a case-sensitive fashion.

Declaring Parameters (optional)

Uniqorn supports fluent API design, allowing for cleaner and more readable code. However, you are free to structure the code differently if you prefer.

To define input parameters, use:

			.parameter("name", (value) -> value.size() > 0)

The framework automatically resolves parameters based on the incoming request type. Whether the request is GET, POST, queryString, multipart, urlencoded, or json, Uniqorn extracts the correct values seamlessly. Parameters are identified by name and can optionally include a validation function to enforce constraints.

Adding Inline Documentation (optional)

For better organization and clarity, you can embed documentation directly into the API definition:

			.summary("My first API")
			.description("This API will greet the user if the name is provided")
			.returns("A JSON object with the 'hello' property set with the provided name")

These descriptions help maintain clear, structured code without relying on external documentation tools. The API documentation can be shared with your consumers and always reflect the latest deployed version.

Managing Security (optional)

Uniqorn provides built-in access control mechanisms, allowing you to allow or deny specific users, groups, or roles at the API level. Access control elements (users, roles, groups) are managed centrally within your Uniqorn instance, ensuring consistency across all endpoints.

			.allowRole("manager") .denyRole("customer")
			.allowGroup("admins") .denyGroup("users")
			.allowUser("Bob")     .denyUser("Alice")

You can specify multiple roles, groups or users at once and use any combination of the above methods. The final granting decision is:

  1. If any deny clause matches, then access is denied.
  2. If any allow clause was specified but none matched, then access is denied.
  3. Otherwise (if an allow clause matches or there was no allow clause), access is granted.

Controlling Concurrency (optional)

Uniqorn allows you to limit how many requests can be processed in parallel for a specific API. This ensures controlled execution and prevents excessive resource usage.

			.concurrency(4)

By default, there is no concurrency limit, APIs can handle unlimited parallel requests as permitted by system resources. Setting a hard limit on concurrency can be useful in cases where you need to prevent race conditions by ensuring an API is accessed by only one or a few users at a time. If the API performs resource-intensive operations and should not be overloaded, or if you want to throttle access to prevent excessive use, you can also define the concurrency limit.

For a strict single-user access, set .concurrency(1).
For unmetered access, do not specify the concurrency limit, or set .concurrency(-1)

Defining API Logic (mandatory)

The core of the API is the process function, which defines the actual logic executed when the endpoint is called. This function receives the incoming request parameters as a JSON object and should return a JSON object as a response.

			.process(data -> {
				return JSON.object()
					.put("hello", data.get("name"));
			});

The data parameter contains all resolved input parameters, regardless of whether they come from query parameters, request bodies, or other sources. Uniqorn automatically handles data extraction and validation.

You can have access to the current authenticated user using .process((data, user) -> {}) if needed.

Restrictions

Code restrictions apply only if you do not have a dedicated environment. Team and Enterprise plans do not have code restrictions.

To ensure runtime stability and security, Uniqorn may enforces a set of language restrictions on user-submitted code. The restriction mechanism is based on simple word matching. If any of the forbidden words below appear anywhere in the code (even as part of a longer identifier), the compilation is rejected with:

HTTP/1.1 400 Bad Request Content-Type: application/json { "error": { "code": 400, "message": "Use of restricted language features: [keyword]" } }

This is a fast, deterministic check and not a full parser, so be mindful that partial matches are also caught regardless of the context. (e.g. "Socket" blocks "ServerSocket" as well).

Forbidden Keywords
Reflection & Low-Level Access reflect Method Field Constructor Modifier AccessibleObject InvocationHandler Proxy Class ClassLoader Unsafe ServiceLoader Module com.sun Native JNI MXBean SecurityManager Permission InitialContext JNDI RMI AccessController Instrumentation MethodHandle jdk
Runtime & Process Control Runtime Shutdown ShutdownHook ProcessBuilder ProcessHandle System
File System Access File Files Path Paths FileSystem RandomAccessFile Console InputStream OutputStream
Threading & Concurrency Thread ThreadLocal Executor Executors Callable Future ForkJoinPool Semaphore Mutex ReentrantLock Lock Condition CyclicBarrier CountDownLatch
Network Access Socket Datagram Multicast Channel URL URI
Scripting & Code Execution ScriptEngine Interpreter GroovyShell JavaScript JavaCompiler ToolProvider FileManager
Serialization Serializable Externalizable readObject writeObject resolveClass
Language Keywords goto invoke eval
Framework Internals .jit .manager Registry Factory Manager Item Entity Template

If you encounter cases where you need some of the restricted keywords, please contact us.

Authentication

Uniqorn's authentication system is designed to provide secure access to both the User Panel and the User APIs. It ensures a clear separation between managing the platform and consuming deployed APIs.

Screenshot

Panel Authentication

The User Panel is where users deploy APIs, manage instances, and configure access control. Authentication follows a two-step process: users log in with a username and password, which are sent via email upon registration, and must also provide a multifactor TOTP (Time-Based One-Time Password) for added security.

The User Panel defines three user types:

  • Managers have full control over your instance, including creating users and assigning privileges.
  • Contributors can deploy and manage APIs but have restricted administrative capabilities.
  • Consumers can use APIs only (see next section).

From the User Panel, managers can define new roles, groups, and users that apply to your instance only.

API Authentication

APIs deployed through the User Panel require authentication via Bearer tokens, which must be included in the Authorization header of each request. This simple mechanism is designed for automated, system-to-system communication, avoiding the complexity of OAuth or OpenID flows.

GET /api/example HTTP/1.1 Host: demo.uniqorn.dev Authorization: Bearer YOUR_API_TOKEN

Tokens are always bound to a consumer user and are managed through the User Panel, where they can be revoked or rotated when necessary.

The security model allows for a flexible combination of groups and other custom roles. In each API endpoint, you can define whether access is granted or denied to a specific subset:

new Api("/api/test", "GET")
	.allowRole("Maintenance")
	.denyGroup("Subcontractors")
	.process(data -> {
		return "OK";
	});

In this example, access is granted to all users that have the "Maintenance" role, except if they are part of the "Subcontractors" group.

API Versioning

Uniqorn includes built-in API versioning to help developers manage changes, track history, and safely roll back when needed. Every API has a single live version, referred to as the head. This is the version currently served when requests hit the endpoint.

This mechanism combines three essential features (ease of use, release management, and code backup) into a single, accessible concept. It requires no extra tools or configuration, and it allows developers to confidently iterate, knowing they can always restore a previous version if needed.

Screenshot

Create and Restore Versions

At any time, users can create a version of the current live API by tagging it. This effectively snapshots the current code and metadata. The version is saved and becomes part of the API's version history. This mechanism is useful for:

Version tagging is instantaneous and does not interrupt the live service.

Once created, versions are immutable, they cannot be modified or deleted. However, any version can be restored to become the new head. Restoring a version creates a new head copy from the tagged version.

Limits

Uniqorn enforces various limits based on the subscription plan, ensuring efficient resource allocation while maintaining flexibility where needed. These limits fall into two categories: instance-level restrictions and API call rate limits.

Screenshot

Instance-Level Limits

Each Uniqorn instance operates within predefined constraints depending on the selected plan. The following elements have fixed limits:

  • Number of Logins: The number of people that can access the User Panel (both managers and contributors).
  • Number of APIs: The maximum number of APIs a user can deploy within a single instance.
  • Number of Workspaces: Workspaces serve as logical groups to organize APIs, and their count is limited per instance.
  • Number of Versions per API: Each API can maintain multiple versions, but there is a cap on how many can be stored.
  • Number of API Consumers: The number of API consumer accounts that can use your APIs.
  • Local Storage Space: The amount of persistent storage space attached to your instance.
  • Security Groups: The maximum number of custom security groups to customize access to yout APIs.
  • Security Roles: The maximum number of custom security roles to customize access to yout APIs.
  • Storage connections: The maximum number of storage connections.
  • Database connections: The maximum number of database connections.

Practical constraints may apply to some other elements based on system performance and reasonable usage expectations.

API Call Rate Limits

Each API has a maximum number of calls allowed per hour. If this limit is exceeded, Uniqorn automatically rejects further requests with a 429 Too Many Requests error.

HTTP/1.1 429 Too Many Requests Content-Type: application/json { "error": { "code": 429, "message": "Call rate limit exceeded" } }

Limits Recap

TrialPersonalTeamEnterprise
Logins1110Unlimited
APIs220200Unlimited
Workspaces1580Unlimited
Versions1520Unlimited
Consumers150UnlimitedUnlimited
Storage0.1GB2GB50GB140GB
Rate10/h500/h10,000/hUnlimited
Groups510200Unlimited
Roles510200Unlimited
Storages2550Unlimited
Databases1550Unlimited

Troubleshooting

When working with live APIs running on a server, traditional debugging tools like an IDE's debugger are not an option. Instead, you need real-time insights into your API's execution flow, errors, and performance. The system provides a built-in management interface that allows you to log messages, inspect runtime variables, analyze call stacks, and monitor performance metrics, all without disrupting the running application.

This section covers the three primary ways to troubleshoot your API: Logging, Debugging, and Metrics. Each of these tools serves a distinct purpose, helping you diagnose issues efficiently in a live environment.

Screenshot

Logging

Logging is the simplest and most effective way to track what your API is doing. By writing logs, you create a live record of execution flow, errors, and important state changes. These logs can be visualized directly from the management interface, allowing you to monitor real-time behavior without modifying the API's response. Proper logging helps you understand what happened before an error occurred and provides essential context for troubleshooting unexpected behavior.

Logs are streamed live through a WebSocket connection. You can visualize it in the management interface or consume it from any compatible log management system.

Logging API

The Api class exposes 2 methods to record logs:

Api.log(int level, String message, Object...data);
Api.log(int level, Exception error);

Uniqorn logging uses a level-based system where the severity of a log entry ranges between 0 (detailed logs) and 1000 (critical issues). Messages below the configured log level are ignored, ensuring that only relevant logs appear when debugging production environments.

Reference values for the log level are:

  • 1000 (severe): Indicates a serious failure that requires immediate attention. This level is used for unrecoverable errors, such as system crashes, database corruption, or data loss. Developers can use this level for business-critical failures, like failed financial transactions or security breaches.
  • 900 (warning): Represents errors that do not halt execution but may lead to problems. This includes degraded performance, deprecated API usage, or excessive resource consumption. Business logic may use this for cases like failed authentication attempts or temporary service disruptions.
  • 800 (info): Logs normal system behavior, such as API start-up, shutdown, or important state changes. This level is useful for business events, such as successful user logins, purchases, or account updates.
  • 700 (config): Logs configuration-related events, such as loaded settings, environment variables, or changes in runtime behavior. Useful for tracking how the system is initialized and whether any dynamic configuration updates occur.
  • 500 (fine): A more detailed debugging level that provides insights into the general execution path of the application. Useful for tracking function calls, loop iterations, or performance markers.
  • 400 (finer): Logs finer details of execution, such as parameter values, cache hits/misses, or database query execution times. Useful for troubleshooting performance bottlenecks.
  • 300 (finest): Logs the most granular details, usually for deep troubleshooting. This level is used to trace function arguments, individual computations, or low-level operations.

Logging levels are not restricted to the predefined reference values, you can use any intermediate level to fine-tune log granularity according to your needs. For instance, setting a custom level like 450 allows you to log messages that fall between "finer" and "fine" without affecting lower or higher severity logs.

However, it is important to consider that every log operation incurs a small processing overhead, even if the message is ultimately filtered out by the log level settings. While this impact is negligible for occasional logs, excessive logging can slow down API performance. To maintain efficiency, it is recommended to remove verbose logging or troubleshooting logs unless they are strictly necessary.

If a log message contains {} placeholders, they will be dynamically replaced by the provided data arguments. If more data is provided than placeholders, the extra values are ignored.

Api.log(800, "System activated"); // Simple log message
Api.log(500, "User {} has logged in", userId); // Log message with parameter injection

try { ... }
catch(Exception error) {
	Api.log(900, error); // Log error details with the stack trace
}

{"date": 1741425960834, "level": 800, "type": "uniqorn.Api", "message": "System activated"} {"date": 1741425960849, "level": 500, "type": "uniqorn.Api", "message": "User 41ae9da2-a875558911263000 has logged in"} {"date": 1741426369953, "level": 900, "type": "uniqorn.Api", "message": "java.lang.Exception: Invalid data at _m_1907317704888500_//_m_1907317704888500_.Custom.lambda$get$0(Custom.java:8) at Loader-uniqorn/uniqorn/uniqorn.Api.lambda$process$1(Api.java:136) at Loader-uniqorn/uniqorn/local.Endpoints.lambda$static$0(Endpoints.java:121)"}

Debugging

Since attaching an interactive debugger is not possible in a serverless context, debugging in Uniqorn relies on live variable inspection and call stack tracing. The management interface allows you to visualize of your API's state at specific execution points, displaying variable values and execution history. This method provides a practical alternative to stepping through execution as you would in a local debugger.

When an error occurs or unexpected behavior is encountered, logging alone may not be sufficient. Debugging allows you to capture variable states and the call stack at any execution point, offering deeper insights into your API's behavior without modifying log levels.

Debug API

The Api class offers one method to expose debug information:

Api.debug(String key, Object...data);

Each debug entry is assigned a tag, which allows filtering and categorization in the management interface. As the number of debug points grows, identifying which output corresponds to which line can become tedious, hence, tagging each log entry helps quickly locate relevant sections of your code.

Debugging automatically captures:

  • The stack trace at the time of execution, with some non-essential entries automatically removed to focus on relevant parts.
  • Provided variables, converted to a human-readable JSON format for easier inspection.

Debug information is streamed live through a WebSocket connection, making it accessible in the management interface or from any compatible system.

new Api("/api/test", "GET")
	.process(data -> {
		...
		Api.debug("Checkpoint A", userId, userName);
		...
	});

{ "stack": [ "_m_1907317704888500_//_m_1907317704888500_.Custom.lambda$get$0(Custom.java:8)", "Loader-uniqorn/uniqorn/uniqorn.Api.lambda$process$1(Api.java:136)", "Loader-uniqorn/uniqorn/local.Endpoints.lambda$static$0(Endpoints.java:121)" ], "values": [ 8755589, "John Doe" ] }

This output allows you to trace execution flow and inspect the values of key variables in real time, making it a powerful tool for diagnosing issues without disrupting the running API.

Debugging is a costly operation, do not forget to comment or remove debug code when your API is ready.

Metrics

Monitoring and performance troubleshooting requires more than just error logs, it demands an understanding of how the system is behaving over time. Built-in metrics provide insights into API request rates, execution times, error frequencies, and resource consumption. These metrics help identify performance bottlenecks, unexpected spikes in traffic, and potential scaling issues. By interpreting these real-time statistics, you can optimize your API's performance and ensure it runs efficiently under varying loads.

In addition to built-in metrics, you can declare custom counters that can track your own indicators.

Custom Metrics

Custom metrics in Uniqorn are named counters that automatically track two values:

  • The hit count: how many times the metric was updated.
  • The aggregated sum: a cumulative value representing a numeric quantity (optional).

These metrics are automatically reset every hour, ensuring that values represent short-term trends rather than long-term accumulation. This rolling behavior is ideal for real-time dashboards and alerting based on recent activity.

To increment the hit count without a value, use:

Api.metrics("custom.metric.name");

If you want to keep track of a quantity beyond a simple count, use:

Api.metrics("checkout.revenue", amount);

Best Practices

  • Use clear and consistent names for custom metrics to make interpretation easier across your team.
  • Avoid flooding: do not create metrics with dynamic names (e.g., metrics("user." + id)), as this leads to unbounded cardinality and storage issues.
  • Keep it lightweight: compute advanced aggregates (e.g., averages, percentiles) and time resolutions externally in your visualization or analytics layer. Doing this inside the API code can create unnecessary processing overhead and reduce performance.

How-To's

This part of the documentation provides more examples and guidance with the most common operations.

Error Handling

When building an API, proper error handling is essential to ensure meaningful responses to clients.

HTTP response codes indicate whether a request was successful or if an error occurred. Here's a quick recap of relevant status code ranges:

  • 2xx (Success): The request was successful (e.g., 200 OK, 204 No Content).
  • 3xx (Redirection): The client must take additional action (e.g., 301 Moved Permanently).
  • 4xx (Client Errors): The request is incorrect or unauthorized (e.g., 400 Bad Request, 403 Forbidden, 404 Not Found).
  • 5xx (Server Errors): The server encountered an error while processing the request (e.g., 500 Internal Server Error, 503 Service Unavailable).

Unhandled Errors

The API process function allows any exception to be thrown to reduce the boilerplate try/catch code.

If your API method throws an exception and you do not handle it, the framework will automatically return a 500 Internal Server Error, which is vague and unhelpful. Moreover, it could potentially leak some sensitive information to the caller.

new Api("/api/test", "GET")
	.process(data -> {
		int result = 10 / 0; // This will cause a division by zero exception
		return JSON.object().put("result", result);
	});

Response:

HTTP/1.1 500 Internal Server Error Content-Type: application/json { "error": { "code": 500, "message": "/ by zero" } }

This is not ideal because (1) the client may not get useful information about what went wrong, and (2) it does not differentiate between a developer mistake and an expected failure.

Controlled Errors

To emit controlled error responses, you should use Api.error(int code, String message) or Api.error(int code, Data data). These methods will throw a recognized exception, stopping further execution and ensuring the correct HTTP status code is returned.

new Api("/api/test", "GET")
	.process(data -> {
		if (data.isNull("name"))
			Api.error(400, "Missing name parameter");
		return null;
	});

Response:

HTTP/1.1 400 Bad Request Content-Type: application/json { "error": { "code": 400, "message": "Missing name parameter" } }

If you need more control over the returned data, you can use the other variant and supply your own response.

new Api("/api/test", "GET")
	.process(data -> {
		if (data.isNull("name"))
			Api.error(400, JSON.object()
				.put("parameter", "name")
				.put("cause", "Cannot be null"));
		return null;
	});

Response:

HTTP/1.1 400 Bad Request Content-Type: application/json { "parameter": "name", "cause": "Cannot be null" }

Graceful Handling

The API process function has a failsafe mechanism to return a 500 error in case an exception happens. Although, it is always best to surround sensitive operations in a try/catch block. This will ensure your API fails gracefully rather than exposing internal server errors and allows you to control what message the client receives.

new Api("/api/test", "GET")
	.process(data -> {
		try {
			int result = Integer.parseInt(data.get("size")); // Might throw NumberFormatException
		} catch (NumberFormatException e) {
			Api.error(400, "Invalid number");
		}
		return null;
	});

Environment Parameters

Environment parameters allow users to define global configuration values in the User Panel, making them accessible in code without hardcoding sensitive or duplicate information. These parameters are read-only in the code, ensuring security and consistency.

If you need read-write global variables, see States & Sharing Data.

Screenshot

Environment parameters helps improve reusability as values are defined once and reused where needed. When a value needs to change, you do not have to redeploy your APIs.

new Api("/api/test", "POST")
	.process(data -> {
		...
		String apiKey = Api.env("API_KEY").asString();
		...
	});

If an environment variable is not set, Api.env() returns an empty data object. You can check if the value is empty using:

Data timeout = Api.env("TIMEOUT");
if (timeout.isEmpty()) { ... }

Parameter Validation

Proper parameter validation ensures that your API only processes valid and expected inputs, preventing unnecessary errors and improving security. The framework automatically collects and populates declared API parameters while ignoring any extra, unsolicited data.

There is no parameter validation rule by default. If needed, you should supply the validation rule in your code.

How Parameter Validation Works

  • You declare required parameters using the parameter(String name) function.
  • You can add a validation predicate as a second argument to enforce constraints.
  • If a parameter fails validation, the API does not execute the processing function and instead returns a 400 Bad Request with a structured error message.

HTTP/1.1 400 Bad Request Content-Type: application/json { "error": { "code": 400, "message": "Parameter validation failed for '[parameter-name]'" } }

The Input class contains various built-in validation rules that you can apply to your parameters.

Basic Validation

new Api("/api/test", "GET")
	.parameter("name", Input.isNotEmpty);

This parameter uses one of the built-in validation rules from the Input class. Check the Javadoc for more possibilities.

Combining Multiple Validations

You can chain multiple predefined validation helpers to enforce stricter rules.

new Api("/api/test", "GET")
	.parameter("email", Input.isEmpty.or(Input.isEmail));

In this case, we allow an optional parameter, but if a value is provided, it must be an email address.

Custom Validation

You can provide a custom validation function if the built-in ones are not enough.

new Api("/api/test", "GET")
	.parameter("age", age -> age.asNumber() > 18);

This validation method leverages the native JSON type coercion to a numeric value using asNumber() and checks if the value is above 18.

File Upload

Api consumers can upload files to your endpoint using classic multipart/form-data uploads. Files are treated like any other input parameter, meaning you must declare them explicitly in the parameter list and validate them as file inputs.

new Api("/api/test", "POST")
	.parameter("image", Input.isFile);

File Data Structure

When a file is uploaded, it is represented in the data object as a JSON structure:

{ "name": "example.jpg", "mime": "image/jpeg", "content": "ÿØÿà..JFIF..." }

  • name: the original file name supplied by the user
  • mime: the file's MIME type as it was provided in the request
  • content: the binary content (stored as a string)

Since the file's content is stored as a binary string, you must convert it into raw bytes for further processing:

byte[] content = file.asString("content").getBytes(StandardCharsets.ISO_8859_1);

Using any other charset may alter the binary data and corrupt the uploaded file. Always use ISO-8859-1 to ensure the bytes remain intact.

Files are loaded directly into memory as JSON structures and are not offloaded to disk.
This means that file uploads are limited by available RAM. Larger files will be rejected by the system automatically.

Processing File Uploads

In the API process function, you can process it like a JSON object. Do not forget to enforce parameter validation:

new Api("/api/test", "POST")
	.parameter("image", Input.isFile
		.and(Input.hasFileExtension(".jpg"))
		.and(Input.hasMimeType("image/jpeg"))
		.and(Input.maxSize(512000)))
	.process(data -> {
		byte[] jpg = data.get("image").asString("content").getBytes(StandardCharsets.ISO_8859_1);
		...
	});

File Download

By default, Uniqorn APIs work with JSON data, a flexible and widely used format. However, there are cases where you need to return files instead of JSON, such as XML documents, ZIP archives, PDFs, or images.

To achieve this, Uniqorn uses a special JSON response format to use in your APIs that instructs the system to handle the response as a file:

{ "isHttpResponse": true, "code": 200, "headers": {}, "mime": "application/xml", "body": "<tag>>value</tag>" }

  • isHttpResponse: Marks the response as an HTTP response override.
  • code: Specifies the HTTP status code (optional).
  • headers: Allows custom response headers (optional).
  • mime: Defines the file's MIME type (equivalent to setting the Content-Type header).
  • body: Contains the actual file content.

When sending binary files, you need to convert the content to a binary-safe string using ISO-8859-1 encoding. This prevents corruption when transmitting raw bytes.

new Api("/api/test", "GET")
	.process(data -> {
		byte[] pdf = ...; // Your binary file content
		return JSON.object()
			.put("isHttpResponse", true)
			.put("mime", "application/pdf")
			.put("body", new String(pdf, StandardCharsets.ISO_8859_1));
	});

Http Fetch

Uniqorn provides a simple and efficient way to make HTTP requests using the Http class. You can use it to send or fetch data from remote endpoints effortlessly.

There are two main methods:

  • get(): Used for retrieving data without a request body.
  • post(): Used for sending data in the request body.

Both methods support optional overloads for sending additional data parameters, specifying custom headers, choosing a specific HTTP method, and defining a timeout (in milliseconds).

Http.get(String url);
Http.get(String url, Data queryString);
Http.get(String url, Data queryString, Data headers);
Http.get(String url, Data queryString, Data headers, String method);
Http.get(String url, Data queryString, Data headers, String method, int timeout);

Http.post(String url);
Http.post(String url, Data body);
Http.post(String url, Data body, Data headers);
Http.post(String url, Data body, Data headers, String method);
Http.post(String url, Data body, Data headers, String method, int timeout);

Handling Responses and Errors

If the HTTP call succeeds, the response is returned as a JSON string object. If the response MIME type is application/json, it is automatically parsed into a full JSON object.

If the request fails with an HTTP status ≥400, an Http.Error is thrown, containing both the status code and response body.

If the call fails due to connectivity issues or other causes, a runtime exception is thrown.
The most basic example is:

Data response = Http.get("https://api.example.com/data");
System.out.println(response.asString());

The response headers are not available when using the Http class. We focus on the content itself.

If the response is advertized as JSON format, it will be automatically parsed into a Data object. Otherwise it will be a simple string.
An example of a request with proper error handling is as follows:

try {
	Data response = Http.post("https://api.example.com/data", 
		JSON.object().put("amount", 42), // the data to send
		JSON.object().put("Authorization", "Bearer my-token"), // additional headers
		"PUT", // http method
		5000 // timeout
	);
}
catch (Http.Error error) { ... }
catch (Exception other) { ... }
			

Note that the Http class is synchronous and will wait for completion. This is because the context of your API is already asynchronous, so there is no added value to complicate things further. However, if you absolutely need an asynchronous behavior, you can trigger a Background Operation.

If you want to upload a file to the remote endpoint, you can use the same JSON file structure as for a File Upload and specify a Content-Type: multipart/form-data header.

Data response = Http.post("https://api.example.com/data", 
	JSON.object().put("image", JSON.object()
		.put("name", "picture.jpg")
		.put("mime", "image/jpeg")
		.put("content", ...)),
	JSON.object().put("Content-Type", "multipart/form-data")
);

Storage

In Uniqorn, a storage is a built-in abstraction over an object store that provides a familiar file-system-like interface. It allows APIs to read and write persistent data using a classic directory and file structure. This is useful for storing structured or unstructured data that needs to survive across deployments, restarts, or scaling operations.

Screenshot

Storage support both direct mapping to data structures, and pure binary data (images,...)

Storage is not thread safe. You should handle concurrent write operations using a synchronized block, or an atomic operation.

Connecting a Storage

Before using a storage in your API code, it must be defined and connected through the User Panel. Compatible providers may be added over time.
Once configured, it becomes available by name within your code:

Storage.Type store = Api.storage("my-storage");

The default Local Storage is always available to store data closest to your instance.

Operations

The storage interface offers a few straightforward operations to get(path), put(path, content), or remove(path) content. You can check the Javadoc for more details.

For convenience, if you are dealing with structured data, you can store and fetch Data structures directly:

Api.storage("my-storage")
	.put("/path/to/file", JSON.object().put("key", "value"));
Data content = Api.storage("my-storage").getData("/path/to/file");

Listing Storage Entries

To explore stored files, you can use the tree(path) method to view the immediate contents of a folder (useful to build a tree view), or the list(path) method to retrieve the entire nested structure recursively. However, it's important to understand that pagination is not supported in these methods. The storage interface is intentionally minimal and consistent across backends, and pagination is highly implementation-specific. If the underlying system offers pagination, only the first page is returned by default. This design choice favors simplicity and determinism.

If a directory contains a large number of entries, the system will return as many as it can, but there is no guarantee beyond that.

If you're finding that a single directory is becoming overloaded with too many entries, it's often a sign that the data should be better organized, either split into subfolders or compiled in an index-file that contains the exhaustive list of entries.
The limitation is not just technical, but conceptual: storage is optimized for clarity and manageability.

When to Use Persistent Storage

Use storage when you need to retain files or data across deployments or reboots. Storage is ideal when you need to read or write data using a known identifier (typically a path) without needing to search or filter the content. The access pattern is direct: you put some content at a path and later retrieve it from the same path.

If, on the other hand, your use case involves searching based on the content of the data, such as querying for all entries matching a condition or filtering by fields, then a Database is more appropriate.

If data does not need to survive a reboot, you can use States which is best for short-lived, memory-resident data.

Database Query

In Uniqorn, the Database interface provides an abstraction over a traditional relational database, giving your API code direct access to query structured data using plain SQL. It is designed to be simple, robust, and easily integrated into your application logic without the need for complex ORM layers.

Before using a database in your API code, it must be defined and connected through the User Panel. Compatible providers may be added over time.
Once configured, it becomes available by name within your code:

Database.Type db = Api.database("my-database");

The system handles connection pooling automatically and ensures that reconnections are managed transparently, even in case of dropped or expired connections.

Executing Queries

The most straightforward way to perform a query is:

Data rows = Api.database("my-database")
	.query("SELECT * FROM table WHERE field = ?", value);
for (Data row : rows) {
	long id = row.asLong("id");
	...
}
  • Queries are written in plain SQL, this gives you the uttermost flexibility, but comes with tradeoffs regarding vendor-specific subtleties.
  • Parameters are passed separately to prevent SQL injection.
  • The result is returned as a Data object, typically an array of rows, each represented as a map of key/value pairs. All matching records are returned at once, no cursor or pagination is implemented at the interface level. If your query returns too many rows, it may cause memory pressure, so it's recommended to use LIMIT or filters appropriately.

Always use parameterized queries. Injecting parameters directly into the SQL string is not safe and can expose your API to SQL injection attacks.

Detailed query options and other metadata discovery methods are present in the Javadoc.

When to Use a Database

Use a database when you need to retain structured data across deployments or reboots. Databases are ideal when you need to query for all entries matching a condition or filtering by fields.

If, on the other hand, your use case involves storing and fetching data based on a simple identifier, maybe a Storage is more appropriate.

If data does not need to survive a reboot, you can use States which is best for short-lived, memory-resident data.

States & Sharing Data

When building APIs, you may need to share data between successive calls to the same endpoint or across different endpoints. While you can always use persistent Storage (database, file system, etc.), Uniqorn provides in-memory state sharing for temporary data storage.

Local vs. Global State

Uniqorn offers two types of transient storage:

  • Local state via State.local(). Data stored here is only accessible within the same API.
  • Global state via State.global(). Shared across all APIs in the instance.

These states are transient, they do not persist beyond the lifecycle of the instance. If you need to store data permanently, use a database or other storage.

Using Local and Global States

Local state is useful when an endpoint needs to store values that are specific to its execution but don't need to be shared with other APIs. Global state allows APIs to share data across endpoints.

You can eventually scope the state for a specific user, and set an automatic expiration time.

new Api("/api/test", "GET")
	.process((data, user) -> {
		// set a local state for the user with an expiration time of 5s
		State.local("key", "value", user, 5000);
		
		// set a global state not bound to any user, and does not expire
		State.global("key", "value");
		
		return JSON.object()
			.put("local-per-user", State.local("key", user))
			.put("global", State.global("common"));
	});

You can store any object type in the state. When fetching the value, the result is auto-casted to the receiving type. If it does not match, it will throw an exception.

State.local("key", 42); // the value type is an integer
int value = State.local("key"); // auto-casting to int
String value2 = State.local("key"); // throws a ClassCastException

State.local("key", "fourty-two"); // overwrite the value with a different type

Be careful when storing or modifying states because multiple modifications may happen at the same time.
Have a look at Atomic Operations for more info.

Chain Endpoints

In certain cases, it's useful to call (or relay to) another API endpoint without performing a full HTTP roundtrip. Instead of making an external HTTP request, Uniqorn allows you to internally chain endpoints using the Api.chain() method.

Why use endpoint chaining?

  • Avoid extra network overhead: Calls remain within the application.
  • Reuse existing logic: No need to duplicate endpoint functionality.
  • Improve modularity: Split logic into smaller, maintainable endpoints.
  • Group atomic operations: Combine multiple calls into one request.

Caution, if you chain endpoints, it becomes difficult to implement a rollback mechanism. Always check if there are side effects or consequences when one of the chained calls fails.

The Api.chain() method allows you to call an internal API endpoint as if it were a function. The result is returned as a Data object.

new Api("/api/relay", "GET")
	.process(data -> {
		return Api.chain("/api/target");
	});

If you need to specify input parameters or change the authenticated user, use one of the overloads to fine-tune the target API call.
Example using chaining for aggregation of data:

new Api("/api/user-profile", "GET")
	.parameter("userId")
	.process(data -> {
		Data user = Api.chain("/api/user", "GET", data);
		Data settings = Api.chain("/api/settings", "GET", data);

		return JSON.object()
			.put("user", user)
			.put("settings", settings);
	});

The target endpoint does not know if it is chained or called normally, be sure to use try...catch to handle exceptions.

Atomic Operations

When working with shared data, concurrency can lead to race conditions, causing inconsistent or lost updates. One way to solve this problem is to use .concurrency(1) but this applies to the entire API. To ensure atomicity on selected operations, Uniqorn provides an easy-to-use method:

Api.atomic(() -> { /* Your code here */ });

This ensures that only one atomic block runs at a time, preventing conflicts in shared data or operations.

When to Use Atomic Execution?

Without atomic execution, multiple concurrent requests could modify shared data incorrectly. Consider this non-atomic counter update:

new Api("/api/increment", "POST")
	.process(data -> {
		int count = Api.global("counter");
		// <-- concurrency issue here
		Api.global("counter", count + 1);
		return JSON.object().put("counter", count);
	});

If two requests execute at the same time, both might read the same initial value, leading to lost updates. In this case you could use an AtomicInteger but it may not be so easy in all cases.

To make sure operations execute sequentially, wrap them in Api.atomic():

new Api("/api/increment", "POST")
	.process(data -> {
		...
		int counter = Api.atomic(() -> {
			int count = Api.global("counter");
			Api.global("counter", count + 1);
			return count;
		});
		
		return JSON.object().put("counter", counter);
	});

Note that the Api.atomic() method may return a value, or not depending on your needs.

When Not to Use Atomic Execution?

Atomic operations have an overhead cost in terms of processing power and lock out all other API from your instance so you can safely use States & Sharing Data. This is a strong bottleneck if not managed carefully.

If you are reading a value without modifying it, or if you are performing independent operations, then you should avoid using the atomic operations.

Background Operations

When building an API, there are situations where a request triggers an operation that doesn't need to complete before returning a response. For example, logging user activity, sending an email, or processing a large dataset might take a few seconds, but the client doesn't need to wait for these tasks to finish.

To handle these cases, Uniqorn provides a simple way to defer tasks using Api.defer(). This allows you to run background operations without blocking the API response, ensuring a smoother user experience and preventing unnecessary delays.

Using Background Tasks

The Api.defer() method accepts a block of code that should be executed asynchronously. Unlike a normal method call, which runs immediately and returns a result, a deferred task is scheduled for execution in the background, and the main API code proceeds without waiting for the completion of the background task.

This approach is useful when you need to:

  • Perform a non-urgent operation (e.g., logging, analytics, sending notifications).
  • Improve response time by offloading heavy tasks.
  • Avoid request timeouts caused by long-running operations.

For example, this simple API endpoint sends a notification to users. Instead of making the client wait until the email is sent, we defer the operation:

new Api("/api/notify", "POST")
	.parameter("email", Input.isEmail)
	.parameter("text", Input.isNotEmpty)
	.process(data -> {
		// send the email in the background
		Api.defer(() -> {
			sendEmail(data.get("email"), data.get("text"));
		});
		
		// the response is sent immediately
		return JSON.object().put("success", true);
	});

With this approach, the API immediately completes while the actual email is sent asynchronously. The client never experiences a delay, even if sending the email takes a few seconds.

Passing Data to Deferred Tasks

Because deferred tasks run outside of the API request's execution scope, you cannot rely on State.local(). The local state is tied to the request lifecycle, and once the request ends, it is no longer accessible.

For example, this would not work as expected:

new Api("/api/test", "GET")
	.process(data -> {
		Api.defer(() -> {
			String userId = State.local("userId"); // this will not work
			doSomething(userId);
		});
		
		return null;
	});

Instead, always pass necessary data explicitly when deferring tasks:

new Api("/api/test", "GET")
	.process(data -> {
		String userId = State.local("userId");
		
		Api.defer(() -> {
			doSomething(userId); // works correctly
		});
		
		return null;
	});

Handling Errors

Since deferred operations run independently of the API request, any errors they produce will not be sent back to the client.

If an exception occurs inside a background task and isn't handled, it will fail silently.

If you need to handle failures, be sure to wrap deferred operations in a try-catch block:

Api.defer(() -> {
	try {
		doSomething();
	} catch(Exception e) {
		handleError();
	}
});

When Not to Use Background Tasks

While deferring operations can significantly improve API responsiveness, there are situations where using a background task may be inappropriate or even counterproductive.

  • When you need the task to complete before responding:
    new Api("/api/checkout", "POST")
    	.process(data -> {
    		Api.defer(() -> processPayment(data)); // Dangerous: no confirmation
    		
    		return "Payment request received.";
    	});
  • When you need immediate feedback on success or failure, such as operations that will perform a validation against input data, checking credentials or other verifications:
    new Api("/api/signup", "POST")
    	.parameter("email")
    	.process(data -> {
    		Api.defer(() -> {
    			String email = data.asString("email");
    			if (checkIfValid(email)) // Dangerous: check email before
    				sendEmail(email);
    		});
    		
    		return "Signup email sent.";
    	});
  • When you expect strict execution order, since background tasks are asynchronous, you have no guarantee about their execution order:
    new Api("/api/test", "GET")
    	.process(data -> {
    		Api.defer(() -> stepOne());  
    		Api.defer(() -> stepTwo()); // Might execute before stepOne()
    		
    		return "Steps scheduled.";
    	});
  • When the deferred task is computationally expensive and may stack up on the server:
    new Api("/api/test", "GET")
    	.process(data -> {
    		Api.defer(() -> veryLongIntensiveTask());
    		
    		return "Process started.";
    	});

Deferring operations is a powerful technique, but it is not a shortcut to infinite scalability. While background tasks create the impression of an immediate response, they still run on the same server and contribute to its overall workload. If heavily used, background processing will affect other API requests, lead to resource starvation, or introduce uncontrolled growth if tasks pile up faster than they are processed.

Scheduling a task in the background is inherently heavier than running it directly, so you should consider acceptable response time and balance it with the need for a background task.

new Api("/api/test", "GET")
	.process(data -> {
		Api.defer(() -> veryQuickTask()); // Overkill for a quick operation
		
		return "Process started.";
	});