• Configure APIs and Response Fields

    In the new menu model, business code does not need to create apiBinding records by hand. Put api:* resources on a page load API or an action, and permission-core compiles the internal API contract when the menu config is saved. The same contract is then used by role-menu grants, Vext route guards, and response-field projection.

    This page answers three questions:

    1. Which APIs are called when a page opens.
    2. Which APIs are called by page buttons, and which buttons are frontend-only permissions.
    3. Which fields in an API response should be returned only after role authorization.

    If you only need to know “which method should I use?”, this table is enough:

    TaskManagement methodOne thing to remember
    Register the API called when a page opensmenus.loadApis.add()Write resource: 'api:GET:/api/orders'; do not write action.
    Register a button permissionmenus.actions.create()Backend buttons use api:*; frontend-only buttons use ui:button:*.
    Declare API response fieldsmenus.responses.set()Put fields under response.fields; these are response DTO fields, not database fields.
    Grant fields to a roleroles.menuPermissions.grant()Pick the fields this role can actually receive in responseFields.
    Filter a backend responsesubject.menus.filterResponse()Project the response before returning it; still prefer subject.assert() at the API entrypoint.

    Prerequisites

    Before configuring APIs and fields incrementally, create the menu config, menu, and view first:

    const scoped = pc.scope(
      { tenantId: 'acme', appId: 'admin' },
      { actorId: 'admin', requestId: 'req-api-bindings-save' },
    );
    
    // Assume these already exist:
    // configId: 'admin'
    // menuId: 'orders'
    // viewId: 'orders-list'

    orders-list is the view.id assigned when the view is created, not a name generated by loadApis.add(). It must be unique within the same menu config. See the menus.views.create() example in Manage Menus.

    A normal flow looks like this:

    Create menu config
    -> Create menu
    -> Create view orders-list
    -> Add page load API
    -> Add action API or UI permission
    -> Configure API response fields
    -> Grant views, APIs, actions, and fields to a role
    -> Protect APIs with assert/filterResponse or Vext

    To make those permissions effective for a user, bind the role with userRoles.assign() or userRoles.set() after role authorization.

    If the config, view, or action does not exist yet, menus.loadApis.add(), menus.actions.create(), and menus.responses.set() fail during preview or execution.

    Page load APIs

    A page load API is the backend API that must be called when a user opens a view.

    In the incremental management API, this is menus.loadApis.add() with input.resource. If you use the advanced config-as-code entrypoint, the same meaning is load.resource; this page focuses on the incremental management API.

    For example, if the orders list opens by calling:

    GET /api/orders

    register it as the load API of the orders-list view:

    const added = await scoped.menus.loadApis.add('admin', 'orders-list', {
      resource: 'api:GET:/api/orders',
    });

    This means:

    Add GET /api/orders as the default load API for the orders-list view in the admin menu config.

    Parameter notes:

    ParameterMeaning
    'admin'Menu config ID; the admin menu config to change.
    'orders-list'View ID from the created view.id; unique within the same menu config.
    resourceAPI resource in api:METHOD:/path form.

    The operator and request ID are already bound through pc.scope(scope, defaults). Pass per-call options only when you need to override those defaults.

    After saving, you do not need to care about the internal snapshot shape. Just remember this record lands on the load APIs of the orders-list view, equivalent to views[].load[].resource = 'api:GET:/api/orders' in the menu config.

    This load entry affects three places:

    PlaceEffect
    Menu configThe API is registered into the internal API contract.
    Role grantsWith include.loads: true, the role receives invoke + api:GET:/api/orders.
    User runtimegetViewState() can use the page load API when deciding whether the page is usable.

    You do not write action: 'invoke' for API resources here. loadApis.add() compiles api:GET:/api/orders into invoke + api:GET:/api/orders.

    For path parameters, use a route template:

    { resource: 'api:GET:/api/orders/:id' }

    Do not put a concrete business ID in the resource:

    // Not recommended
    { resource: 'api:GET:/api/orders/123' }

    Page buttons and actions

    An action is a button or operation inside a page, such as export, approve, delete, or open details.

    In the incremental management API, this is menus.actions.create() with input.resource. If you use the advanced config-as-code entrypoint, the same meaning is actions[].resource.

    If the button calls the backend, use api:*:

    await scoped.menus.actions.create('admin', 'orders-list', {
      id: 'export',
      title: '导出订单',
      resource: 'api:POST:/api/orders/export',
    });

    This means:

    Create an “Export orders” action under the orders-list page. A user with this action permission may see or click it; if it calls the backend, the backend must still check invoke + api:POST:/api/orders/export.

    If the button is frontend-only and calls no backend API, use ui:button:*:

    await scoped.menus.actions.create('admin', 'orders-list', {
      id: 'show-cost-column',
      title: '显示成本列',
      resource: 'ui:button:orders.show-cost-column',
    });

    The two resource types mean different things:

    resourceBest forBackend authorization needed?
    api:POST:/api/orders/exportA click that calls a backend endpointYes
    ui:button:orders.show-cost-columnA purely frontend display or interaction permissionNo

    If a button only opens a modal, and the modal later calls an API, model the modal as a dialog or drawer view and configure its own load API. That keeps the permission meaning clean:

    按钮权限:能不能打开弹窗
    弹窗页面权限:能不能进入弹窗视图
    弹窗 load API:能不能请求弹窗里的数据接口

    Response field configuration

    Response field configuration answers:

    Which fields in this API response DTO can be granted to roles?

    Keep these two operations separate:

    OperationPurpose
    menus.responses.set()Declare which response fields exist for this API.
    roles.menuPermissions.grant({ responseFields })Assign which declared fields a role can actually receive.

    Declaring response fields does not mean users can already see them. Without role field grants, those fields are still removed.

    Where response fields attach

    The owner in menus.responses.set() only tells permission-core: “these fields belong to this API response.” New users should usually choose load or action by page source, instead of starting with api.

    ownerTypeUse forExample
    loadFields of a page load APIGET /api/orders on the orders list page
    actionFields of an action APIPOST /api/orders/export on the export action
    apiFind sources by API resourceAdvanced usage, usually better for config-as-code or migration tools

    Response fields for a page load API:

    const responseInput = {
      owner: {
        ownerType: 'load',
        viewId: 'orders-list',
        resource: 'api:GET:/api/orders',
      },
      response: {
        target: 'items',
        preserve: ['total'],
        fields: [
          { field: 'orderNo', title: '订单号' },
          { field: 'status', title: '状态' },
          { field: 'amount', title: '金额' },
        ],
      },
    } as const;
    
    await scoped.menus.responses.set('admin', responseInput);

    This means:

    api:GET:/api/orders returns paginated data; the projected rows live under items; total is a structural pagination field that should be preserved without field authorization; orderNo/status/amount are grantable response fields.

    These ordinary add/set operations automatically run an internal preview and commit when safe. If the management UI wants to display the impact first, call previewAdd(), previewCreate(), or previewSet(), then execute with expected/previewToken.

    Parameter notes:

    ParameterMeaning
    owner.ownerType: 'load'The fields belong to a page load API.
    owner.viewIdView ID.
    owner.resourceThe page load API resource.
    response.targetObject or array path to project, commonly items or data.items for paginated APIs.
    response.preserveOuter fields to keep without field authorization, such as total or cursor.
    response.fields[].fieldResponse DTO field path, not a database field path.
    response.fields[].titleDisplay title in the admin UI.

    After saving, these fields attach to the items response target of api:GET:/api/orders. Later role grants pick from the declared orderNo/status/amount fields.

    Array form and object form

    Inside MenuConfigInput.load[].response or actions[].response, a direct array is allowed:

    response: [
      { field: 'orderNo', title: '订单号' },
      { field: 'buyer.name', title: '买家姓名' },
    ]

    But in menus.responses.set(), response uses object form. Even without target, write:

    response: {
      fields: [
        { field: 'orderNo', title: '订单号' },
        { field: 'buyer.name', title: '买家姓名' },
      ],
    }

    For a paginated response:

    {
      "items": [{ "orderNo": "O-1001", "status": "paid", "amount": 88 }],
      "total": 1
    }

    prefer:

    response: {
      target: 'items',
      preserve: ['total'],
      fields: [
        { field: 'orderNo', title: '订单号' },
        { field: 'status', title: '状态' },
        { field: 'amount', title: '金额' },
      ],
    }

    Do not put sensitive business fields in preserve; preserve bypasses field authorization.

    Authorizing response fields

    After declaring fields, grant selected fields to roles. This selection means:

    order-operator can enter the orders list page, call the page load API and action APIs, but the orders list API returns only orderNo and status.

    const selection = {
      configId: 'admin',
      views: ['orders-list'],
      responseFields: [{
        apiResource: 'api:GET:/api/orders',
        target: 'items',
        fields: ['orderNo', 'status'],
      }],
      include: {
        loads: true,
        actions: true,
        responseFields: 'none',
      },
    };

    fields must be declared earlier. For paginated or nested responses, write target, such as items or data.items; if one API has multiple response targets and target is omitted, preview rejects the ambiguous selection.

    By default, response fields are not automatically all selected. If a role should receive every field, say it explicitly:

    include: { responseFields: 'all' }

    Filtering responses on the backend

    Response field projection must happen before returning data from the backend. It should not be only a frontend hide/show rule.

    In a hand-written framework integration, keep API entry authorization and response projection separate:

    const subject = pc.forSubject({ userId: 'u-menu', scope });
    
    await subject.assert('invoke', 'api:GET:/api/orders');
    
    const payload = {
      items: [
        { orderNo: 'O-1001', status: 'paid', amount: 88, internalCost: 51 },
      ],
      total: 1,
      debug: true,
    };
    
    const projected = await subject.menus.filterResponse(
      'api:GET:/api/orders',
      payload,
    );
    
    return projected.data;

    If the current user only has orderNo and status, projected.data is close to:

    {
      "items": [
        { "orderNo": "O-1001", "status": "paid" }
      ],
      "total": 1
    }

    Responsibility boundary:

    MethodResponsibility
    subject.assert('invoke', apiResource)Protect the API entrypoint.
    subject.menus.filterResponse(apiResource, payload)Project the response according to the current user’s field grants.

    filterResponse() also checks whether the current user can invoke the API. Still, business routes should usually call subject.assert() or a framework guard first, because that gives clearer failure points.

    With the Vext plugin, routes protected by permission: true can automatically perform API authorization and response-field projection. Hand-written route code can also call req.auth.permission.filterResponse() directly. See Vext Plugin.

    What if no response fields are configured?

    • If an API has no response configured, filterResponse() returns the original payload after API permission passes.
    • If an API has response configured but the current user has no field grants, only fields listed in preserve remain.
    • If an API contains sensitive fields, configure response and grant fields explicitly through roles.

    Reusing the same API across pages

    The same apiResource can be reused by multiple pages or actions, but the response structure must be compatible.

    For example, these usually merge cleanly:

    orders-list    -> api:GET:/api/orders -> target: items
    sales-orders   -> api:GET:/api/orders -> target: items

    If different pages declare incompatible structures for the same API, such as one using target: 'items' and another using target: 'data.rows', preview may reject the config. Prefer splitting the API or making the response shape consistent.

    Advanced: config-as-code and batch imports

    This page focuses on the incremental management API. If a plugin, CI/CD job, or config file imports the full menu in one pass, see Menu Config as Code and Batch Imports.

    There is only one equivalence to remember: MenuConfigInput.load[].resource maps to menus.loadApis.add(), MenuConfigInput.actions[].resource maps to menus.actions.create(), and MenuConfigInput.load[].response or MenuConfigInput.actions[].response maps to menus.responses.set().

    Common misunderstandings

    MisunderstandingCorrect model
    You must manually create API bindings firstNo. Declaring APIs through loadApis/actions/responses or MenuConfigInput creates the internal binding.
    load needs action: 'invoke'No. api:* resources are automatically treated as invoke.
    menus.responses.set() can receive an array directly in responseNo. The incremental API uses object form: response: { fields: [...] }. Array form is mainly for MenuConfigInput.
    Declaring response fields lets users see themNo. Fields must also be granted through role-menu authorization.
    Selecting a view automatically returns every fieldNo. Response fields are not granted by default; choose fields explicitly or set include.responseFields: 'all'.
    filterResponse() directly returns the business objectNo. The projected business payload is in projected.data.
    Response fields only affect frontend displayNo. Projection should happen on the backend before response return.
    preserve can hold any fieldNot recommended. preserve is for structural outer fields such as totals and cursors, not business-field authorization.

    For exact field constraints, see Configure APIs and Response Fields API. For the full flow, see Manage Menus and Authorize Role Menus.