Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 141 additions & 9 deletions docs/api-spec/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,29 @@ var httpMethods = map[string]bool{

// Parameter describes a single OpenAPI operation parameter.
type Parameter struct {
Name string `yaml:"name"`
In string `yaml:"in"`
Required bool `yaml:"required"`
Description string `yaml:"description"`
Name string `yaml:"name" json:"name"`
In string `yaml:"in" json:"in"`
Required bool `yaml:"required" json:"required"`
Description string `yaml:"description" json:"description,omitempty"`
}

// Property describes a single top-level field of an operation's JSON request
// body. Type is either a JSON-schema primitive ("string", "boolean",
// "integer", "object", ...), "array<item-type>" for arrays, or the name of a
// referenced component schema (e.g. "BuildTarget") when the property itself
// is a $ref -- that nested schema's own fields are not flattened further.
type Property struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Description string `json:"description,omitempty"`
Default string `json:"default,omitempty"`
}

// RequestBody describes an operation's JSON request body payload.
type RequestBody struct {
Required bool `json:"required"`
Properties []Property `json:"properties,omitempty"`
}

// Operation describes a single OpenAPI path+method operation.
Expand All @@ -33,6 +52,9 @@ type Operation struct {
Tags []string
OperationId string
Parameters []Parameter
// RequestBody is nil for operations with no application/json request body
// (typically GET/DELETE).
RequestBody *RequestBody
}

// Metadata describes which spec bundle is embedded in this binary.
Expand All @@ -42,14 +64,42 @@ type Metadata struct {
}

type rawDoc struct {
Paths map[string]map[string]yaml.Node `yaml:"paths"`
Paths map[string]map[string]yaml.Node `yaml:"paths"`
Components struct {
Schemas map[string]rawSchema `yaml:"schemas"`
} `yaml:"components"`
}

type rawOperation struct {
Summary string `yaml:"summary"`
OperationId string `yaml:"operationId"`
Tags []string `yaml:"tags"`
Parameters []Parameter `yaml:"parameters"`
Summary string `yaml:"summary"`
OperationId string `yaml:"operationId"`
Tags []string `yaml:"tags"`
Parameters []Parameter `yaml:"parameters"`
RequestBody *rawRequestBody `yaml:"requestBody"`
}

type rawRequestBody struct {
Required bool `yaml:"required"`
Content map[string]rawMediaTypeItem `yaml:"content"`
}

type rawMediaTypeItem struct {
Schema rawSchema `yaml:"schema"`
}

// rawSchema is a deliberately narrow subset of OpenAPI's Schema Object: only
// what's needed to flatten a request body's top-level properties into
// Property. Nested object properties are identified by referenced schema name
// (see Property) rather than recursively flattened, to keep output compact
// and sidestep any $ref cycles in the full (non-stub) bundle.
type rawSchema struct {
Ref string `yaml:"$ref"`
Type string `yaml:"type"`
Description string `yaml:"description"`
Default any `yaml:"default"`
Required []string `yaml:"required"`
Properties map[string]rawSchema `yaml:"properties"`
Items *rawSchema `yaml:"items"`
}

var (
Expand Down Expand Up @@ -139,8 +189,90 @@ func parseFile(name string) ([]Operation, error) {
Tags: op.Tags,
OperationId: op.OperationId,
Parameters: op.Parameters,
RequestBody: buildRequestBody(op.RequestBody, doc.Components.Schemas),
})
}
}
return ops, nil
}

// buildRequestBody flattens a JSON request body's top-level schema properties
// into a RequestBody. Returns nil when there's no application/json content
// (the only content type used across today's rdme-admin reference bundle).
func buildRequestBody(raw *rawRequestBody, schemas map[string]rawSchema) *RequestBody {
if raw == nil {
return nil
}
media, ok := raw.Content["application/json"]
if !ok {
return nil
}

schema := media.Schema
if schema.Ref != "" {
if resolved, ok := schemas[schemaRefName(schema.Ref)]; ok {
schema = resolved
}
}

required := make(map[string]bool, len(schema.Required))
for _, name := range schema.Required {
required[name] = true
}

properties := make([]Property, 0, len(schema.Properties))
for name, prop := range schema.Properties {
properties = append(properties, Property{
Name: name,
Type: propertyType(prop),
Required: required[name],
Description: prop.Description,
Default: stringifyDefault(prop.Default),
})
}
sort.Slice(properties, func(i, j int) bool { return properties[i].Name < properties[j].Name })

return &RequestBody{Required: raw.Required, Properties: properties}
}

// schemaRefName extracts "Foo" from a local-document ref like
// "#/components/schemas/Foo".
func schemaRefName(ref string) string {
if idx := strings.LastIndex(ref, "/"); idx != -1 {
return ref[idx+1:]
}
return ref
}

// propertyType renders a rawSchema property as a compact type hint: a $ref
// becomes the referenced schema's name (not flattened further), an array
// becomes "array<item-type>", and anything else falls back to its declared
// type or "object" when untyped.
func propertyType(p rawSchema) string {
if p.Ref != "" {
return schemaRefName(p.Ref)
}
if p.Type == "array" {
itemType := "object"
if p.Items != nil {
switch {
case p.Items.Ref != "":
itemType = schemaRefName(p.Items.Ref)
case p.Items.Type != "":
itemType = p.Items.Type
}
}
return "array<" + itemType + ">"
}
if p.Type == "" {
return "object"
}
return p.Type
}

func stringifyDefault(v any) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
26 changes: 26 additions & 0 deletions docs/api-spec/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,37 @@ func TestOperations_Stub(t *testing.T) {
assert.Equal(t, []string{"Users"}, getUserList.Tags)
assert.Len(t, getUserList.Parameters, 10)

assert.Nil(t, getUserList.RequestBody, "a GET operation should have no request body")

createUser, ok := byOperationId["createUser"]
require.True(t, ok, "createUser should be present")
assert.Equal(t, "POST", createUser.Method)
assert.Equal(t, "/access/api/v2/users", createUser.Path)

require.NotNil(t, createUser.RequestBody, "createUser's requestBody ($ref: UserCreateRequest) should resolve")
assert.True(t, createUser.RequestBody.Required)
propsByName := make(map[string]Property, len(createUser.RequestBody.Properties))
for _, p := range createUser.RequestBody.Properties {
propsByName[p.Name] = p
}
username, ok := propsByName["username"]
require.True(t, ok, "username should be a flattened property of UserCreateRequest")
assert.Equal(t, "string", username.Type)
assert.True(t, username.Required, "username is in UserCreateRequest's required list")

password, ok := propsByName["password"]
require.True(t, ok)
assert.False(t, password.Required, "password is not in UserCreateRequest's required list")

admin, ok := propsByName["admin"]
require.True(t, ok)
assert.Equal(t, "boolean", admin.Type)
assert.Equal(t, "false", admin.Default)

groups, ok := propsByName["groups"]
require.True(t, ok)
assert.Equal(t, "array<string>", groups.Type)

deleteWorker, ok := byOperationId["deleteWorker"]
require.True(t, ok, "deleteWorker should be present")
assert.Equal(t, "DELETE", deleteWorker.Method)
Expand Down
116 changes: 116 additions & 0 deletions docs/api-spec/requestbody_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package apispec

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

// parseRawDoc is a test-only helper: unlike parseFile, it takes a literal YAML
// string instead of reading an embedded file, so these tests can exercise
// buildRequestBody against synthetic fixtures without needing new stub files.
func parseRawDoc(t *testing.T, doc string) rawDoc {
t.Helper()
var d rawDoc
require.NoError(t, yaml.Unmarshal([]byte(doc), &d))
return d
}

func TestBuildRequestBody_NilWhenNoRequestBody(t *testing.T) {
assert.Nil(t, buildRequestBody(nil, nil))
}

func TestBuildRequestBody_NilWhenNoJSONContent(t *testing.T) {
raw := &rawRequestBody{Required: true, Content: map[string]rawMediaTypeItem{
"multipart/form-data": {Schema: rawSchema{Type: "object"}},
}}
assert.Nil(t, buildRequestBody(raw, nil))
}

func TestBuildRequestBody_RefResolution(t *testing.T) {
d := parseRawDoc(t, `
components:
schemas:
Widget:
type: object
required: [name]
properties:
name:
type: string
description: Widget name
active:
type: boolean
default: true
`)
raw := &rawRequestBody{Required: true, Content: map[string]rawMediaTypeItem{
"application/json": {Schema: rawSchema{Ref: "#/components/schemas/Widget"}},
}}

rb := buildRequestBody(raw, d.Components.Schemas)
require.NotNil(t, rb)
assert.True(t, rb.Required)
require.Len(t, rb.Properties, 2)

byName := make(map[string]Property, len(rb.Properties))
for _, p := range rb.Properties {
byName[p.Name] = p
}
assert.True(t, byName["name"].Required)
assert.Equal(t, "string", byName["name"].Type)
assert.Equal(t, "Widget name", byName["name"].Description)
assert.False(t, byName["active"].Required)
assert.Equal(t, "true", byName["active"].Default)
}

func TestBuildRequestBody_InlineSchemaNoRef(t *testing.T) {
raw := &rawRequestBody{Required: false, Content: map[string]rawMediaTypeItem{
"application/json": {Schema: rawSchema{
Type: "object",
Required: []string{"password"},
Properties: map[string]rawSchema{
"password": {Type: "string"},
},
}},
}}

rb := buildRequestBody(raw, nil)
require.NotNil(t, rb)
assert.False(t, rb.Required)
require.Len(t, rb.Properties, 1)
assert.Equal(t, "password", rb.Properties[0].Name)
assert.True(t, rb.Properties[0].Required)
}

func TestPropertyType(t *testing.T) {
tests := []struct {
name string
in rawSchema
want string
}{
{"primitive string", rawSchema{Type: "string"}, "string"},
{"untyped falls back to object", rawSchema{}, "object"},
{"nested $ref is not flattened", rawSchema{Ref: "#/components/schemas/BuildTarget"}, "BuildTarget"},
{"array of primitives", rawSchema{Type: "array", Items: &rawSchema{Type: "string"}}, "array<string>"},
{"array of refs", rawSchema{Type: "array", Items: &rawSchema{Ref: "#/components/schemas/PatternFilter"}}, "array<PatternFilter>"},
{"array with no items info", rawSchema{Type: "array"}, "array<object>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, propertyType(tt.in))
})
}
}

func TestSchemaRefName(t *testing.T) {
assert.Equal(t, "Widget", schemaRefName("#/components/schemas/Widget"))
assert.Equal(t, "no-slash", schemaRefName("no-slash"))
}

func TestStringifyDefault(t *testing.T) {
assert.Equal(t, "", stringifyDefault(nil))
assert.Equal(t, "true", stringifyDefault(true))
assert.Equal(t, "0", stringifyDefault(0))
assert.Equal(t, "hello", stringifyDefault("hello"))
}
5 changes: 4 additions & 1 deletion docs/general/api/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ When to use:
- Scripting platform admin tasks where 'jf rt' or 'jf c' don't cover the operation.
- Debugging API responses with full visibility into status code and body.

Before guessing at a path: if you don't already know the exact endpoint/method, run 'jf api docs search <query>' first (e.g. 'jf api docs search user') — it's a local, offline lookup over this binary's embedded OpenAPI spec that returns ranked matches with a ready-to-run 'jf api' command for each.

Prerequisites:
- A configured server (jf c add or jf login) or explicit --url / --access-token / --server-id.
- The caller's identity must have the privileges the target endpoint requires.
Expand All @@ -85,6 +87,7 @@ Gotchas:
- -d/--data and --input are mutually exclusive.
- HTTP status goes to stderr (one line), body to stdout. Non-2xx exits with status 1 but still prints the body.
- Some APIs require trailing slashes or specific Accept headers; check the API reference before scripting.
- The bare, slash-less path 'docs' (e.g. 'jf api docs') routes to 'jf api docs search' instead of issuing an HTTP call. This does not affect the leading-slash form: 'jf api -X GET /docs' still reaches the platform normally, since no real JFrog REST path is bare '/docs'.

Related: jf c add, jf rt, jf c show`
Related: jf api docs search, jf c add, jf rt, jf c show`
}
13 changes: 13 additions & 0 deletions docs/general/apidocs/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package apidocs

var Usage = []string{"api docs search <query> [command options]"}

func GetDescription() string {
return "Discover JFrog Platform REST API operations. Run 'jf api docs search <query>' to find the right endpoint before using 'jf api <path>'."
}

func GetAIDescription() string {
return `Namespace for API-discovery subcommands. Run 'jf api docs search <query>' to look up a REST endpoint by keyword before guessing at 'jf api <path>'.

See 'jf api docs search --help' for the full set of options.`
}
Loading
Loading