mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
214 lines
6.4 KiB
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),
|
|
}
|
|
}
|