diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go index a10528c..c3a54c9 100644 --- a/internal/openapi/openapi.go +++ b/internal/openapi/openapi.go @@ -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"` @@ -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 { diff --git a/internal/service/resource_definition.go b/internal/service/resource_definition.go index 3c9019c..5844b04 100644 --- a/internal/service/resource_definition.go +++ b/internal/service/resource_definition.go @@ -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 { @@ -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) @@ -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 { @@ -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)) @@ -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)) @@ -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)) @@ -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)) @@ -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 { diff --git a/internal/service/resource_definition_test.go b/internal/service/resource_definition_test.go index 6398a0b..a14d69c 100644 --- a/internal/service/resource_definition_test.go +++ b/internal/service/resource_definition_test.go @@ -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": { @@ -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{}, @@ -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 diff --git a/internal/service/service.go b/internal/service/service.go index 742dcb3..86927f5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) } diff --git a/internal/service/service_definition.go b/internal/service/service_definition.go index 9b2966b..68ab3a9 100644 --- a/internal/service/service_definition.go +++ b/internal/service/service_definition.go @@ -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 { @@ -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{} @@ -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 { @@ -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 @@ -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:] @@ -161,6 +239,7 @@ func getPatternInfo(path string) *PatternInfo { } return &PatternInfo{ IsResourcePattern: len(pattern)%2 == 0, + CustomMethodName: customMethodName, } } @@ -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 diff --git a/internal/service/service_definition_test.go b/internal/service/service_definition_test.go index 64dcb3e..c6f8da1 100644 --- a/internal/service/service_definition_test.go +++ b/internal/service/service_definition_test.go @@ -103,7 +103,7 @@ func TestGetServiceDefinition(t *testing.T) { widget, ok := sd.Resources["widget"] assert.True(t, ok, "widget resource should exist") - assert.Equal(t, widget.Pattern, []string{"widgets", "{widget}"}) + assert.Equal(t, widget.PatternElems, []string{"widgets", "{widget}"}) assert.Equal(t, sd.ServerURL, "https://api.example.com") assert.NotNil(t, widget.GetMethod, "should have GET method") assert.NotNil(t, widget.ListMethod, "should have LIST method") @@ -165,7 +165,7 @@ func TestGetServiceDefinition(t *testing.T) { assert.True(t, ok, "widget resource should exist") assert.Equal(t, "widget", widget.Singular) assert.Equal(t, "widgets", widget.Plural) - assert.Equal(t, []string{"widgets", "{widget}"}, widget.Pattern) + assert.Equal(t, []string{"widgets", "{widget}"}, widget.PatternElems) }, }, { @@ -245,7 +245,7 @@ func TestGetServiceDefinition(t *testing.T) { widget, ok := sd.Resources["widget"] assert.True(t, ok, "widget resource should exist") assert.NotNil(t, widget.GetMethod, "should have GET method") - assert.Equal(t, []string{"widgets", "{widget}"}, widget.Pattern) + assert.Equal(t, []string{"widgets", "{widget}"}, widget.PatternElems) }, }, }