Skip to content

Commit

Permalink
feat(custom): add support for custom methods
Browse files Browse the repository at this point in the history
add supported for custom methods, including being
able to infer it.
  • Loading branch information
toumorokoshi committed Nov 6, 2024
1 parent 35d6f9d commit f51f152
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 47 deletions.
13 changes: 12 additions & 1 deletion internal/openapi/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ func (o *OpenAPI) GetSchemaFromResponse(r Response) *Schema {
}
}

func (o *OpenAPI) GetSchemaFromRequestBody(r RequestBody) *Schema {
switch o.OASVersion() {
case OAS2:
return r.Schema
default:
ct := r.Content[ContentType]
return ct.Schema
}
}

type Server struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
Expand Down Expand Up @@ -124,7 +134,8 @@ type RequestBody struct {
Description string `json:"description"`
Content map[string]MediaType `json:"content"`
Required bool `json:"required"`
Schema *Schema `json:"schema,omitempty"`
// oas 2.0 has the schema in the request body.
Schema *Schema `json:"schema,omitempty"`
}

type MediaType struct {
Expand Down
83 changes: 62 additions & 21 deletions internal/service/resource_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ import (
)

type Resource struct {
Singular string
Plural string
Parents []*Resource
Pattern []string // TOO(yft): support multiple patterns
Schema *openapi.Schema
GetMethod *GetMethod
ListMethod *ListMethod
CreateMethod *CreateMethod
UpdateMethod *UpdateMethod
DeleteMethod *DeleteMethod
Singular string
Plural string
Parents []*Resource
PatternElems []string // TOO(yft): support multiple patterns
Schema *openapi.Schema
GetMethod *GetMethod
ListMethod *ListMethod
CreateMethod *CreateMethod
UpdateMethod *UpdateMethod
DeleteMethod *DeleteMethod
CustomMethods []*CustomMethod
}

type CreateMethod struct {
Expand All @@ -40,15 +41,22 @@ type ListMethod struct {
type DeleteMethod struct {
}

func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string) {
type CustomMethod struct {
Name string
Method string
Request *openapi.Schema
Response *openapi.Schema
}

func (r *Resource) ExecuteCommand(args []string) (*http.Request, string, error) {
c := cobra.Command{Use: r.Singular}
var err error
var req *http.Request
var parents []*string

i := 1
for i < len(r.Pattern)-1 {
p := r.Pattern[i]
for i < len(r.PatternElems)-1 {
p := r.PatternElems[i]
flagName := p[1 : len(p)-1]
var flagValue string
parents = append(parents, &flagValue)
Expand All @@ -58,9 +66,9 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string)

withPrefix := func(path string) string {
pElems := []string{}
for i, p := range r.Pattern {
for i, p := range r.PatternElems {
// last element, we assume this was handled by the caller.
if i == len(r.Pattern)-1 {
if i == len(r.PatternElems)-1 {
continue
}
if i%2 == 0 {
Expand All @@ -76,8 +84,9 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string)
if r.CreateMethod != nil {
createArgs := map[string]interface{}{}
createCmd := &cobra.Command{
Use: "create",
Use: "create [id]",
Short: fmt.Sprintf("Create a %v", strings.ToLower(r.Singular)),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
p := withPrefix(fmt.Sprintf("?id=%s", id))
Expand All @@ -97,8 +106,9 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string)

if r.GetMethod != nil {
getCmd := &cobra.Command{
Use: "get",
Use: "get [id]",
Short: fmt.Sprintf("Get a %v", strings.ToLower(r.Singular)),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
p := withPrefix(fmt.Sprintf("/%s", id))
Expand All @@ -112,8 +122,9 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string)

updateArgs := map[string]interface{}{}
updateCmd := &cobra.Command{
Use: "update",
Use: "update [id]",
Short: fmt.Sprintf("Update a %v", strings.ToLower(r.Singular)),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
p := withPrefix(fmt.Sprintf("/%s", id))
Expand All @@ -134,8 +145,9 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string)
if r.DeleteMethod != nil {

deleteCmd := &cobra.Command{
Use: "delete",
Use: "delete [id]",
Short: fmt.Sprintf("Delete a %v", strings.ToLower(r.Singular)),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
p := withPrefix(fmt.Sprintf("/%s", id))
Expand All @@ -157,15 +169,44 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, error, string)
}
c.AddCommand(listCmd)
}
for _, cm := range r.CustomMethods {
customArgs := map[string]interface{}{}
customCmd := &cobra.Command{
Use: fmt.Sprintf("%s [id]", cm.Name),
Short: fmt.Sprintf("%v a %v", cm.Method, strings.ToLower(r.Singular)),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
p := withPrefix(fmt.Sprintf("/%s:%s", id, cm.Name))
if cm.Method == "POST" {
jsonBody, inner_err := generateJsonPayload(cmd, customArgs)
if inner_err != nil {
slog.Error(fmt.Sprintf("unable to create json body for update: %v", inner_err))
}
req, err = http.NewRequest(cm.Method, p, strings.NewReader(string(jsonBody)))
} else {
req, err = http.NewRequest(cm.Method, p, nil)
}
},
}
if cm.Method == "POST" {
addSchemaFlags(customCmd, *cm.Request, customArgs)
}
c.AddCommand(customCmd)
}
var stderr strings.Builder
var stdout strings.Builder
c.SetOut(&stdout)
c.SetErr(&stderr)
c.SetArgs(args)
if err := c.Execute(); err != nil {
return nil, err, stdout.String() + stderr.String()
return nil, stdout.String() + stderr.String(), err
}
return req, err, stdout.String() + stderr.String()
return req, stdout.String() + stderr.String(), err
}

func (r *Resource) GetPattern() string {
return strings.Join(r.PatternElems, "/")
}

func addSchemaFlags(c *cobra.Command, schema openapi.Schema, args map[string]interface{}) error {
Expand Down
12 changes: 6 additions & 6 deletions internal/service/resource_definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import (
)

var projectResource = Resource{
Singular: "project",
Plural: "projects",
Pattern: []string{"projects", "{project}"},
Parents: []*Resource{},
Singular: "project",
Plural: "projects",
PatternElems: []string{"projects", "{project}"},
Parents: []*Resource{},
Schema: &openapi.Schema{
Properties: map[string]openapi.Schema{
"name": {
Expand Down Expand Up @@ -90,7 +90,7 @@ func TestExecuteCommand(t *testing.T) {
resource: Resource{
Singular: "dataset",
Plural: "datasets",
Pattern: []string{"projects", "{project}", "datasets", "{dataset}"},
PatternElems: []string{"projects", "{project}", "datasets", "{dataset}"},
Parents: []*Resource{&projectResource},
Schema: &openapi.Schema{},
GetMethod: &GetMethod{},
Expand All @@ -109,7 +109,7 @@ func TestExecuteCommand(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err, _ := tt.resource.ExecuteCommand(tt.args)
req, _, err := tt.resource.ExecuteCommand(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("ExecuteCommand() error = %v, wantErr %v", err, tt.wantErr)
return
Expand Down
2 changes: 1 addition & 1 deletion internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (s *Service) ExecuteCommand(args []string) (string, error) {
if err != nil {
return "", fmt.Errorf("%v\n%v", err, s.PrintHelp())
}
req, err, output := r.ExecuteCommand(args[1:])
req, output, err := r.ExecuteCommand(args[1:])
if err != nil {
return "", fmt.Errorf("unable to execute command: %v", err)
}
Expand Down
109 changes: 94 additions & 15 deletions internal/service/service_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type ServiceDefinition struct {
func GetServiceDefinition(api *openapi.OpenAPI, serverURL, pathPrefix string) (*ServiceDefinition, error) {
slog.Debug("parsing openapi", "pathPrefix", pathPrefix)
resourceBySingular := make(map[string]*Resource)
customMethodsByPattern := make(map[string][]*CustomMethod)
// we try to parse the paths to find possible resources, since
// they may not always be annotated as such.
for path, pathItem := range api.Paths {
Expand All @@ -30,7 +31,55 @@ func GetServiceDefinition(api *openapi.OpenAPI, serverURL, pathPrefix string) (*
continue
}
slog.Debug("parsing path for resource", "path", path)
if p.IsResourcePattern {
if p.CustomMethodName != "" && p.IsResourcePattern {
// strip the leading slash and the custom method suffix
pattern := strings.Split(path, ":")[0][1:]
if _, ok := customMethodsByPattern[pattern]; !ok {
customMethodsByPattern[pattern] = []*CustomMethod{}
}
if pathItem.Post != nil {
if resp, ok := pathItem.Post.Responses["200"]; ok {
schema := api.GetSchemaFromResponse(resp)
responseSchema := &openapi.Schema{}
if schema != nil {
var err error
responseSchema, err = api.DereferenceSchema(*schema)
if err != nil {
return nil, fmt.Errorf("error dereferencing schema %v: %v", schema, err)
}
}
schema = api.GetSchemaFromRequestBody(*pathItem.Post.RequestBody)
requestSchema, err := api.DereferenceSchema(*schema)
if err != nil {
return nil, fmt.Errorf("error dereferencing schema %q: %v", schema.Ref, err)
}
customMethodsByPattern[pattern] = append(customMethodsByPattern[pattern], &CustomMethod{
Name: p.CustomMethodName,
Method: "POST",
Request: requestSchema,
Response: responseSchema,
})
}
}
if pathItem.Get != nil {
if resp, ok := pathItem.Get.Responses["200"]; ok {
schema := api.GetSchemaFromResponse(resp)
responseSchema := &openapi.Schema{}
if schema != nil {
var err error
responseSchema, err = api.DereferenceSchema(*schema)
if err != nil {
return nil, fmt.Errorf("error dereferencing schema %v: %v", schema.Ref, err)
}
}
customMethodsByPattern[pattern] = append(r.CustomMethods, &CustomMethod{
Name: p.CustomMethodName,
Method: "GET",
Response: responseSchema,
})
}
}
} else if p.IsResourcePattern {
// treat it like a collection pattern (update, delete, get)
if pathItem.Delete != nil {
r.DeleteMethod = &DeleteMethod{}
Expand Down Expand Up @@ -100,9 +149,18 @@ func GetServiceDefinition(api *openapi.OpenAPI, serverURL, pathPrefix string) (*
}
singular := utils.PascalCaseToKebabCase(key)
pattern := strings.Split(path, "/")[1:]
// collection-level patterns don't include the singular, so we need to add it
if !p.IsResourcePattern {
pattern = append(pattern, fmt.Sprintf("{%s}", singular))
// deduplicate the singular, if applicable
finalSingular := singular
parent := ""
if len(pattern) >= 3 {
parent = pattern[len(pattern)-3]
parent = parent[0 : len(parent)-1] // strip curly surrounding
if strings.HasPrefix(singular, parent) {
finalSingular = strings.TrimPrefix(singular, parent+"-")
}
}
pattern = append(pattern, fmt.Sprintf("{%s}", finalSingular))
}
r2, err := getOrPopulateResource(singular, pattern, dereferencedSchema, resourceBySingular, api)
if err != nil {
Expand All @@ -111,7 +169,24 @@ func GetServiceDefinition(api *openapi.OpenAPI, serverURL, pathPrefix string) (*
foldResourceMethods(&r, r2)
}
}
// get the first serverURL url
// the custom methods are trickier - because they may not respond with the schema of the resource
// (which would allow us to map the resource via looking at it's reference), we instead will have to
// map it by the pattern.
// we also have to do this by longest pattern match - this helps account for situations where
// the custom method doesn't match the resource pattern exactly with things like deduping.
for pattern, customMethods := range customMethodsByPattern {
found := false
for _, r := range resourceBySingular {
if r.GetPattern() == pattern {
r.CustomMethods = customMethods
found = true
break
}
}
if !found {
slog.Debug(fmt.Sprintf("custom methods with pattern %q have no resource associated with it", pattern))
}
}
if serverURL == "" {
for _, s := range api.Servers {
serverURL = s.URL + pathPrefix
Expand Down Expand Up @@ -140,14 +215,17 @@ type PatternInfo struct {
// if true, the pattern represents an individual resource,
// otherwise it represents a path to a collection of resources
IsResourcePattern bool
CustomMethodName string
}

// getPatternInfo returns true if the path is an alternating pairing of collection and id,
// and returns the collection names if so.
func getPatternInfo(path string) *PatternInfo {
customMethodName := ""
if strings.Contains(path, ":") {
slog.Debug("path contains colon, custom methods are not currently supported.", "path", path)
return nil
parts := strings.Split(path, ":")
path = parts[0]
customMethodName = parts[1]
}
// we ignore the first segment, which is empty.
pattern := strings.Split(path, "/")[1:]
Expand All @@ -161,6 +239,7 @@ func getPatternInfo(path string) *PatternInfo {
}
return &PatternInfo{
IsResourcePattern: len(pattern)%2 == 0,
CustomMethodName: customMethodName,
}
}

Expand All @@ -184,23 +263,23 @@ func getOrPopulateResource(singular string, pattern []string, s *openapi.Schema,
}
parentResource, err := getOrPopulateResource(parentSingular, []string{}, &parentSchema, resourceBySingular, api)
if err != nil {
return nil, fmt.Errorf("error parsing resource %q parent %q: %v", r.Singular, parentSingular, err)
return nil, fmt.Errorf("error parsing resource %q parent %q: %v", singular, parentSingular, err)
}
parents = append(parents, parentResource)
}
r = &Resource{
Singular: s.XAEPResource.Singular,
Plural: s.XAEPResource.Plural,
Parents: parents,
Pattern: strings.Split(s.XAEPResource.Patterns[0], "/")[1:],
Schema: s,
Singular: s.XAEPResource.Singular,
Plural: s.XAEPResource.Plural,
Parents: parents,
PatternElems: strings.Split(s.XAEPResource.Patterns[0], "/")[1:],
Schema: s,
}
} else {
// best effort otherwise
r = &Resource{
Schema: s,
Pattern: pattern,
Singular: singular,
Schema: s,
PatternElems: pattern,
Singular: singular,
}
}
resourceBySingular[singular] = r
Expand Down
Loading

0 comments on commit f51f152

Please sign in to comment.