# Platform API

The platform provides two kinds of APIs:

  1. REST API: call the platform over HTTP. Used by the frontend, mobile apps, third-party systems and scripts.
  2. Plugin utility library (tech.muyan:api): Java/Groovy utility classes that plugin source code uses to call platform capabilities, such as querying data, creating objects and running code asynchronously.

# Contents

  1. REST API
  2. Plugin Utility Library

# REST API

# Address Prefix

When deployed as described in Docker Deployment, the frontend and backend are served by the same nginx. nginx strips the /api prefix from requests starting with /api/ and forwards them to the backend. So when calling from outside, prefix every path below with /api. For example, the full address of the login endpoint is http://<server address>/api/auth/login.

TIP

Request addresses seen inside dynamic logic (for example the Webhook requestUrl) are the addresses the backend sees, without the /api prefix.

# Login

curl -X POST http://<server address>/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"[email protected]","password":"password"}'
1
2
3

A successful login returns HTTP 200 with the following response body (tokens truncated):

{
  "access_token": "eyJhbGciOiJIUzI1NiJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "username": "[email protected]",
  "roles": ["ROLE_DEVELOPER", "ROLE_ADMIN", "ROLE_USER"],
  "id": 1,
  "name": "čļ…įē§įŽĄį†å‘˜",
  "avatar": null,
  "extInfo": null
}
1
2
3
4
5
6
7
8
9
10
11
12
  • access_token: the access token. It is valid for 3600 seconds by default (expires_in), which can be changed with the backend setting security.jwt.expiration.
  • refresh_token: the refresh token, used to obtain a new access token. See Refreshing the Token.

A wrong password returns HTTP 400; a non-existent user returns HTTP 404:

{"success":false,"errCode":1010,"errMsg":"ErrorCode: 1010, ErrorMsg: UserPasswordMismatch, ..."}
{"success":false,"errCode":1005,"errMsg":"ErrorCode: 1005, ErrorMsg: NotFound, ..."}
1
2

# Passing the Token in Requests

Depending on the characteristics of the request, the backend reads the token from one of the following sources:

Priority Method Example
1 Authorization request header (recommended) Authorization: Bearer <access_token>
2 access_token in a form-encoded request body Content-Type: application/x-www-form-urlencoded, request body access_token=<token>&...
3 access_token URL parameter GET /api/data/DynamicLogic?access_token=<token>
4 access_token cookie Cookie: access_token=<token>
curl 'http://<server address>/api/data/DynamicLogic?max=10&offset=0' \
  -H 'Authorization: Bearer <access_token>'
1
2

Note

  • In the current version, method 2 (form-encoded request body) does not work in practice: the request returns HTTP 400 IllegalArgument ... has an invalid signature. Use one of the other three methods.
  • When the Authorization header starts with Bearer, only the header is read; for non-GET form-encoded requests only the request body is read, and the URL parameter and cookie are not checked.
  • A token in a URL parameter appears in the server access logs; when calling a dynamic service it is also written verbatim into the "Exec params" of the service execution record. Use the request header instead of the URL parameter whenever possible.

Responses when authentication fails:

  • No token: endpoints that require login (such as /auth/userInfo and /permissions/...) return HTTP 401 (errCode 1008 Unauthorized); endpoints that check permissions by domain class (such as /data/...) return HTTP 403 (errCode 1004 NoPermission).
  • Token cannot be parsed: HTTP 401 (errCode 1012 TokenInvalid); wrong signature: HTTP 400 (errCode 1001 IllegalArgument).
  • Token expired: HTTP 401 (errCode 1006 TokenExpired). In this case, first refresh the token.

# Refreshing the Token

After the access_token expires, you can exchange the refresh_token for a new access_token without entering the password again. The request parameters must be form-encoded (a JSON request body is not supported and returns 400):

curl -X POST http://<server address>/api/auth/access_token \
  -d 'grant_type=refresh_token&refresh_token=<refresh_token>'
1
2

On success, the response has the same structure as the login response, and its refresh_token is still the one passed in the request.

Security note

In the current version, the refresh_token has no expiration (the backend setting security.jwt.refreshExpiration is empty by default), and it can be used directly as an access_token to call endpoints. In addition, calling GET /auth/userInfo with an unexpired access_token returns a newly issued, non-expiring refresh_token in the response (/auth/access_token, by contrast, only returns the passed-in token as the refresh_token). Keep the refresh_token as safe as a password: do not write it to logs, URLs or any storage other than the frontend.

# Temporary Access via a Token in the URL

Opening an address such as http://<server address>/?access_token=<access_token> makes the frontend use that token to access the system directly, without the login page. This suits scenarios where users jump in from another system and view a page without logging in.

In this mode:

  • The frontend does not write the token to the browser's localStorage, so no login state remains after the page is closed.
  • When the page opens, the frontend calls GET /auth/userInfo. This endpoint uses the token in the URL to issue a new pair of access_token and refresh_token, which the frontend uses to renew the session while the page is open.

Risk

The token in the URL is not single-use. Anyone who gets the link can call /auth/userInfo before the token expires to obtain a new refresh_token, and the refresh_token currently does not expire (see the previous section). Therefore:

  • Only generate such links for dedicated accounts with minimal permissions, never for administrator accounts;
  • Only send links through trusted channels; do not post them on public pages, tickets or chat groups;
  • In the current version, neither login nor token access checks whether the account is disabled or locked, and changing the password does not invalidate issued tokens. After a link leaks, delete the account or change its username; to invalidate all issued tokens, you have to change the backend setting security.jwt.secret and restart.

# Main Routes

The table below lists the main built-in endpoints (all paths omit the /api prefix). <domain> is the short name of a domain class, such as DynamicLogic.

Method Path Description
POST /auth/login Log in, see above
POST /auth/access_token Exchange a refresh_token for a new token
GET /auth/userInfo Return the current user's information and issue a new pair of tokens
POST /auth/logout Log out. In the current version the call returns 403 and does not work
GET /data/<domain>?max=&offset= Paged query of an object list, returns {total, data, formHookDataList}. max defaults to 10, maximum 500
GET /data/<domain>/<id> Query a single object, returns {data, formHookData}, with the object in data
POST /data/<domain> Create an object; the request body is JSON
PUT /data/<domain>/<id> Update an object; the request body is JSON and only needs id and the fields to change
PUT /data/<domain>/batch Batch update; the request body is a JSON array
DELETE /data/<domain>/<id> Delete an object
POST /search/<domain>?offset=&max= Query an object list by conditions
POST /search/keyword/<domain>?q=&max= Query by keyword; the keyword goes in the URL parameter q
POST /action/byName/<action name> Execute an object action by name
POST /action/byId/<action id> Execute an object action by id
GET /permissions/<domain>/create Whether the current user can create objects of this type, returns {"create": true}
POST /permissions/<domain>/ Request body {"ids":[...]}, returns the view/update/delete permissions for each object
Any /service/<service name> Call a dynamic service, see Dynamic Service
GET / POST /webhook/<url> Call a Webhook, see Webhook
GET / POST /attachment/... Upload and download attachments
GET /dashboard/list, /dashboard/meta/<id>, /dashboard/widget/data/<id> Dashboards and their widget data
GET /config/system, /config/<key> Read system configuration

Examples of creating and updating an object (the ids in the examples come from a specific environment: logicType 12 is DYNAMIC_SERVICE_CORE_LOGIC, dynamicLogicEngine 1 is GROOVY_CODE, and 93 is the newly created dynamic logic. Look up the ids in your own environment first with GET /api/data/DynamicLogicType and GET /api/data/DynamicLogicEngine):

# Create
curl -X POST http://<server address>/api/data/DynamicLogic \
  -H 'Authorization: Bearer <access_token>' -H 'Content-Type: application/json' \
  -d '{"name":"æŸĨč¯ĸčŽžå¤‡čŋčĄŒįŠļ态","description":"æŒ‰čŽžå¤‡įŧ–åˇčŋ”å›žčŽžå¤‡čŋčĄŒįŠļ态","logicType":{"id":12},"dynamicLogicEngine":{"id":1},"code":"return [:]"}'

# Update: pass only id and the fields to change
curl -X PUT http://<server address>/api/data/DynamicLogic/93 \
  -H 'Authorization: Bearer <access_token>' -H 'Content-Type: application/json' \
  -d '{"id":93,"description":"æŒ‰čŽžå¤‡įŧ–åˇčŋ”å›žčŽžå¤‡čŋčĄŒįŠļæ€å’Œį´¯čŽĄčŋčĄŒæ—ļé•ŋ"}'
1
2
3
4
5
6
7
8
9

The create, update and delete endpoints respond with {"status": "...", "message": "...", "data": {...}, "date": <timestamp in milliseconds>}. Note that when creating or updating a single object fails data validation (for example, a required field is empty), the HTTP status code is still 200; only status is error and message contains the validation error. Callers must check the status field rather than the HTTP status code alone. The batch update endpoint PUT /data/<domain>/batch differs: if any record fails validation, it throws an exception directly and returns an error response.

Object-type fields are expressed as {"id": <id>}. Create, read, update and delete are controlled by the role requirements configured on the domain class, see Object Permission Control.

TIP

The route table already contains PATCH /data/<domain>/<id>, but the backend has no implementation for it and the call returns 403. Use PUT instead.

Besides the table above, plugins can register their own endpoints through a Custom Controller.

# Plugin Utility Library

Plugin source code calls platform capabilities through the tech.muyan:api library. The utility classes in the library are stubs: their method bodies only throw IllegalStateException ("This method will be provided by platform implementation dynamically"), and the real implementation is provided by the platform at runtime. Therefore it must be included as compileOnly and must not be packaged into the plugin; otherwise calls throw exceptions directly. The plugin template's codes/build.gradle is already configured:

dependencies {
  compileOnly 'tech.muyan:api:0.0.5'
  testCompileOnly 'tech.muyan:api:0.0.5'
  testRuntimeOnly 'tech.muyan:api:0.0.5'
}
1
2
3
4
5

Version notes

  • The plugin template uses 0.0.5 by default. The methods listed in this section are based on 0.0.5 and have been confirmed to exist at runtime on the current platform (1.0.0-beta18).
  • Custom Controller (MuyanDynamicController, the @Get/@Post/@Put/@Delete annotations and so on) and dynamic RPC (MuyanRpcService, @RpcClient, DynamicRpcClientService) are only provided in the 1.0.0 series of the api library, for example 1.0.0-11-35-SNAPSHOT in the libs-snapshot repository; they are not in 0.0.5. The AsyncHelper in the 1.0.0 series api library matches the platform runtime (it adds task(String, Runnable), supplyAsync and more), but its MessageHelper still only declares pushNotification and cannot be used either (see MessageHelper).
  • A few methods in the library do not exist in the current platform runtime. They are marked one by one below; do not use them.

Contents:

  1. StorageUtils
  2. QueryHelper
  3. SimpleQuery
  4. DomainHelper
  5. BeanContainer
  6. AsyncHelper
  7. MessageSeverity
  8. MessageHelper
  9. Plugin Extension Interfaces

# StorageUtils

Utility class for handling file storage.

# createStorageFileDomain

public static StorageFieldValue createStorageFileDomain(String fileName, String mimeType, InputStream inputStream)
1

Saves a file to platform storage and returns a StorageFieldValue object representing the file, which can be assigned to an object's file field.

  • Parameters:
    • fileName: file name
    • mimeType: MIME type of the file
    • inputStream: file content
  • Returns: a StorageFieldValue object

# QueryHelper

Runs code within a database session or transaction, or executes SQL directly.

public static <T> T withSession(Closure<T> closure)
public static <T> T withNewSession(Closure<T> closure)
public static <T> T withTransaction(Closure<T> closure)
public static <T> T withSql(Function<Sql, T> function)
1
2
3
4
  • withSession: runs the closure in the current database session.
  • withNewSession: runs the closure in a new database session.
  • withTransaction: runs the closure in a transaction.
  • withSql: runs the function with a groovy.sql.Sql object, suitable for native SQL.
import groovy.sql.Sql
import tech.muyan.utils.QueryHelper

List rows = QueryHelper.withSql { Sql sql ->
  sql.rows("select id, name from dynamic_logic where logic_type_id = ?", [12])
}
1
2
3
4
5
6

# SimpleQuery

A query class for querying objects by conditions.

SimpleQuery.of("WorkTask")
    .ge("scheduledStartTime", start)
    .listAll();
1
2
3

This query retrieves all WorkTask objects whose scheduled start time is greater than or equal to the given start time.

Multiple conditions can be chained together:

SimpleQuery.of("WorkTask")
    .eq("assignee", user)
    .ge("scheduledStartTime", start)
    .lt("scheduledEndTime", end)
    .eq("status", "ACTIVE")
    .listAll();
1
2
3
4
5
6

In the examples above:

  • "WorkTask" is the short name of the domain class.
  • "scheduledStartTime" is the name of a field in the domain class.
  • "assignee" is the user assigned to the WorkTask; the condition must use the user object itself, not user.id.

TIP

For object-type (DOMAIN_OBJECT) fields, use the object itself rather than object.id as the match condition.

# Condition Methods

The following methods all return the SimpleQuery itself and can be chained:

Method Description
eq(String fieldName, Object value) Equal to
ne(String fieldName, Object value) Not equal to
gt(String fieldName, Object value) Greater than
ge(String fieldName, Object value) Greater than or equal to
lt(String fieldName, Object value) Less than
le(String fieldName, Object value) Less than or equal to
iLike(String fieldName, String value) Case-insensitive pattern match
notILike(String fieldName, String value) Case-insensitive pattern non-match
in(String fieldName, Collection<?> value) In the collection
notIn(String fieldName, Collection<?> value) Not in the collection
isNull(String fieldName) Is null
notNull(String fieldName) Is not null
addConditions(List<QueryCondition> queryConditions) Add multiple conditions at once

# Executing the Query

Method Return type Description
get() T Return a single result
list(int offset, int limit) PaginationQueryResult<T> Paged query
list(int offset, int limit, List<String> orderBy) PaginationQueryResult<T> Paged and sorted
list(int offset, int limit, List<String> orderBy, boolean asc) PaginationQueryResult<T> Paged with sort direction
listAll() List<T> Return all results

PaginationQueryResult<T> extends List<T> and can be used directly as a list.

# Static Methods

Method Description
SimpleQuery<?> of(String domainName) Create a query by domain class short name
<T> SimpleQuery<T> of(Class<T> clazz) Create a query by class
<T> SimpleQuery<T> of(Class<T> clazz, boolean and) Create a query by class, specifying whether conditions are combined with AND or OR
Object getById(String domainName, Long id) Query by id
<T> T getById(Class<T> clazz, Long id) Query by id
List<Object> getByIds(String domainName, List<Long> ids) Query by a list of ids
<T> List<T> getByIds(Class<T> clazz, List<Long> ids) Query by a list of ids
List<Object> getAll(String domainName) Query all objects
<T> List<T> getAll(Class<T> clazz) Query all objects
long count(String domainName) Count objects
<T> long count(Class<T> clazz) Count objects

# DomainHelper

Utility class for creating, updating, deleting and rendering objects.

public static Object buildDomain(String domainName)
public static Object buildDomain(String domainName, Object properties)
public static void createDomain(Object requestData)
public static void updateDomain(Object requestData)
public static void deleteDomain(Object requestData)
public static Map<String, Object> render(Object domainObj, DomainObjectRenderType renderType)
public static <T> List<T> batchCreate(Class<T> domainClazz, List<T> domainObjects)
public static <T> List<T> batchCreate(String domainName, List<T> domainObjects)
1
2
3
4
5
6
7
8
  • buildDomain: constructs an object (without saving it) by domain class short name, optionally with properties.
  • createDomain / updateDomain / deleteDomain: create, update and delete objects from request data.
  • render: converts an object to a Map. DomainObjectRenderType has two values: ONLY_LABEL_FIELD (only the label field) and ALL_COLUMNS (all fields).
  • batchCreate: creates objects in batch and returns the list of created objects.

# BeanContainer

Gets beans from the platform and plugins.

public static <T> T getBean(Class<T> beanClass)
public static <T> Collection<T> getBeansOfType(Class<T> beanClass)
1
2
  • getBean: gets one bean by type.
  • getBeansOfType: gets all beans of a type.

BeanContainer is provided at runtime by the built-in platform plugin PlatformAdapter, so PlatformAdapter must remain in the plugin's dependsOnPlugins. See Plugin Development.

# AsyncHelper

Runs tasks asynchronously while keeping context such as the current tenant in the new thread.

public static Runnable scheduleAtFixRate(long period, Runnable runnable)
public static Runnable scheduleAtFixRate(long period, Runnable runnable, boolean newThread)
public static void task(Runnable runnable)
1
2
3
  • scheduleAtFixRate: runs the task repeatedly at a fixed interval (milliseconds). The return value is a Runnable; running it cancels the task. When newThread is true, a dedicated timer thread is used.
  • task: runs a task asynchronously once.

Both methods carry the current tenant and the plugin's class loader into the new thread, but not the currently logged-in user. The platform runtime has marked both methods @Deprecated (for task(Runnable), switch to task(String threadName, Runnable runnable) in the 1.0.0 series api library); avoid them in new code where possible.

Methods not available on the current platform

The following methods are declared in api 0.0.5, but the current platform runtime has no implementation for them. Calling them fails with NoSuchMethodError (Java or @CompileStatic code) or MissingMethodException (dynamic Groovy code):

  • task(boolean newThread, Runnable runnable)
  • task(ExecutorService executorService, Runnable runnable)
  • newForkJoinPool(int parallelism)

In addition, the platform runtime's task(Runnable) returns Thread, which does not match the void declared in 0.0.5. Calling it from dynamic Groovy code works; calling it from Java or @CompileStatic code fails with NoSuchMethodError.

# MessageSeverity

Enum representing the severity of a message.

Value Description
INFO General information
WARNING Warning
ERROR Error
IMPORTANT Important
INTERNAL Originally intended for internal system information exchange. In the current version the backend does not filter it, and the frontend pops up a notification as for general information (INFO)
ACTION_REQUIRED A message that requires user action. In the current version the frontend displays it the same way as IMPORTANT: a notification in the lower-right corner that does not close automatically and does not block the page

# MessageHelper

Currently unavailable

The pushNotification method declared in the api library (0.0.5 and the 1.0.0 series) does not exist in the current platform runtime, and calls to it fail. What the platform runtime actually provides is pushMessage, and the recipient field of its Notification is toUser (a user object), which does not match toUserName (a username) in api 0.0.5. Until the platform fixes this, do not call MessageHelper in plugins.

# Plugin Extension Interfaces

Besides the utility classes above, the tech.muyan.api package also contains interfaces for plugins to implement, for example:

  • DynamicDomainEntity: binds a POJO class in a plugin to a platform domain class.
  • MuyanPlatformComponent: a plugin component; the platform calls onLoad() / offLoad() when loading and unloading plugins.
  • websocket.MuyanWebSocketComponent: lets a plugin handle WebSocket messages.

For usage, see Plugin Development.

Last Updated: 9/24/2026, 2:27:35 PM