Files
larksuite-cli/internal/schema/assembler.go

214 lines
6.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"sort"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// Convert renders a meta.Field as a JSON-Schema Property. meta owns the value
// normalization (canonical type, literal coercion, enum ordering); this adds
// only the JSON-Schema-specific shape: the "file" binary format, numeric
// bounds, nested object/array properties, and the array-items fallback.
func Convert(f meta.Field) Property {
var p Property
p.Type = f.CanonicalType()
if f.Type == "file" {
p.Format = "binary"
}
p.Description = f.Description
p.Default = f.CoercedDefault()
p.Example = f.CoercedExample()
p.Minimum = f.MinBound()
p.Maximum = f.MaxBound()
p.Enum, p.EnumDescriptions = enumSchema(f.EnumOptions())
if children := f.Children(); len(children) > 0 {
props, required := propsOf(children), requiredOf(children)
if p.Type == "array" {
// meta_data quirk: array element schema is wrapped in "properties".
p.Items = &Property{Type: "object", Properties: props, Required: required}
} else {
if p.Type == "" {
p.Type = "object" // infer
}
p.Properties = props
p.Required = required
}
}
// Every array needs an items schema to be valid for consumers that require
// one, even when meta_data describes no element shape.
if p.Type == "array" && p.Items == nil {
p.Items = &Property{}
}
return p
}
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
// arrays for the envelope. enumDescriptions is nil unless at least one value
// carries a description (so the bare-enum form stays values-only), keeping the
// two arrays index-aligned for AI consumers.
func enumSchema(opts []meta.EnumOption) (values []interface{}, descriptions []string) {
if len(opts) == 0 {
return nil, nil
}
values = make([]interface{}, len(opts))
descs := make([]string, len(opts))
hasDesc := false
for i, o := range opts {
values[i] = o.Value
descs[i] = o.Description
if o.Description != "" {
hasDesc = true
}
}
if hasDesc {
descriptions = descs
}
return values, descriptions
}
// propsOf renders fields as an ordered JSON-Schema property map. meta's field
// accessors return fields sorted by name, so the property order is alphabetical.
func propsOf(fields []meta.Field) *OrderedProps {
op := &OrderedProps{}
for _, f := range fields {
op.Set(f.Name, Convert(f))
}
return op
}
// requiredOf returns the alphabetized names of the required fields.
func requiredOf(fields []meta.Field) []string {
var required []string
for _, f := range fields {
if f.Required {
required = append(required, f.Name)
}
}
sort.Strings(required)
return required
}
// buildInputSchema produces the inputSchema sections — params (path+query →
// --params), data (non-file body → --data), file (file body → --file) — plus a
// `yes` confirmation gate for high-risk-write methods.
func buildInputSchema(m meta.Method) *InputSchema {
is := &InputSchema{
Type: "object",
Required: []string{}, // never nil — stable envelope shape
Properties: &OrderedProps{},
}
addInputObject(is, "params", "", m.Params())
addInputObject(is, "data", "", m.Data())
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
if m.Risk == core.RiskHighRiskWrite {
falseVal := false
is.Properties.Set("yes", Property{
Type: "boolean",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
})
}
sort.Strings(is.Required)
return is
}
// addInputObject adds one named sub-object section (params/data/file) to the
// input schema when it has fields: its Properties come from the fields, its
// Required lists the mandatory keys, and the section itself is required at top
// level when any field is required. Empty sections are skipped.
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
if len(fields) == 0 {
return
}
req := requiredOf(fields)
is.Properties.Set(name, Property{
Type: "object",
Description: description,
Required: req,
Properties: propsOf(fields),
})
if len(req) > 0 {
is.Required = append(is.Required, name)
}
}
// buildOutputSchema produces the outputSchema from the response-body fields.
func buildOutputSchema(m meta.Method) *OutputSchema {
return &OutputSchema{Type: "object", Properties: propsOf(m.Response())}
}
// buildMeta produces the _meta extension namespace.
func buildMeta(m meta.Method) *Meta {
out := &Meta{
EnvelopeVersion: "1.0",
RequiredScopes: []string{}, // never nil for stable JSON
Scopes: m.Scopes,
AccessTokens: m.Identities(),
Danger: m.Danger,
}
if a, ok := m.ParsedAffordance(); ok {
out.Affordance = &a
}
if len(m.RequiredScopes) > 0 {
out.RequiredScopes = m.RequiredScopes
}
if m.Risk != "" {
out.Risk = m.Risk
} else {
out.Risk = core.RiskRead
}
if m.DocURL != "" {
out.DocURL = m.DocURL
}
return out
}
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
}
// Envelopes renders the given method refs into envelopes, sorted by name. The
// caller supplies the refs (from apicatalog navigation), so this package owns
// only rendering — never metadata source selection or traversal.
func Envelopes(refs []apicatalog.MethodRef) []Envelope {
var out []Envelope
for _, ref := range refs {
out = append(out, EnvelopeOf(ref))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// assemble builds the envelope from a method's navigation context. The method
// name comes from m.Name, injected by the typed accessors.
func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope {
name := serviceName
for _, r := range resourcePath {
name += " " + r
}
name += " " + m.Name
return Envelope{
Name: name,
Description: m.Description,
InputSchema: buildInputSchema(m),
OutputSchema: buildOutputSchema(m),
Meta: buildMeta(m),
}
}