From 4ba1ee8f06d9fbbfcc2586facd3e192c83d8bfbe Mon Sep 17 00:00:00 2001 From: Yusuke Tsutsumi Date: Sat, 9 Nov 2024 12:41:12 -0800 Subject: [PATCH] refactor: using aep-lib-go Common utilities have moved to aep-lib-go. --- cmd/aepcli/main.go | 12 +- go.mod | 1 + go.sum | 2 + internal/openapi/openapi.go | 197 ------------ internal/service/resource_definition.go | 46 +-- internal/service/resource_definition_test.go | 35 +-- internal/service/service.go | 22 +- internal/service/service_definition.go | 305 ------------------- internal/service/service_definition_test.go | 271 ---------------- internal/service/service_test.go | 6 +- internal/utils/cases.go | 14 - 11 files changed, 46 insertions(+), 865 deletions(-) delete mode 100644 internal/openapi/openapi.go delete mode 100644 internal/service/service_definition.go delete mode 100644 internal/service/service_definition_test.go delete mode 100644 internal/utils/cases.go diff --git a/cmd/aepcli/main.go b/cmd/aepcli/main.go index 4bbc69b..59315fb 100644 --- a/cmd/aepcli/main.go +++ b/cmd/aepcli/main.go @@ -7,8 +7,9 @@ import ( "path/filepath" "strings" + "github.com/aep-dev/aep-lib-go/pkg/api" + "github.com/aep-dev/aep-lib-go/pkg/openapi" "github.com/aep-dev/aepcli/internal/config" - "github.com/aep-dev/aepcli/internal/openapi" "github.com/aep-dev/aepcli/internal/service" "github.com/spf13/cobra" @@ -95,21 +96,20 @@ func aepcli(args []string) error { serverURL = api.ServerURL } - openapi, err := openapi.FetchOpenAPI(fileAliasOrCore) + oas, err := openapi.FetchOpenAPI(fileAliasOrCore) if err != nil { return fmt.Errorf("unable to fetch openapi: %w", err) } - serviceDefinition, err := service.GetServiceDefinition(openapi, serverURL, pathPrefix) + api, err := api.GetAPI(oas, serverURL, pathPrefix) if err != nil { - return fmt.Errorf("unable to get service definition: %w", err) + return fmt.Errorf("unable to get api: %w", err) } - headersMap, err := parseHeaders(headers) if err != nil { return fmt.Errorf("unable to parse headers: %w", err) } - s = service.NewService(*serviceDefinition, headersMap) + s = service.NewService(api, headersMap) result, err := s.ExecuteCommand(additionalArgs) if err != nil { diff --git a/go.mod b/go.mod index 0b9d6e6..f0cb214 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.3 require ( github.com/BurntSushi/toml v1.4.0 // indirect + github.com/aep-dev/aep-lib-go v0.0.0-20241109204312-789b93c37d17 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index e82277d..0301672 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/aep-dev/aep-lib-go v0.0.0-20241109204312-789b93c37d17 h1:I6JqfEQyyMZ7jMTFv2OrLBOzqCa8dFiu/FSsEx0Bty0= +github.com/aep-dev/aep-lib-go v0.0.0-20241109204312-789b93c37d17/go.mod h1:M+h1D6T2uIUPelmaEsJbjR6JhqKsTlPX3lxp25zQQsk= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go deleted file mode 100644 index c3a54c9..0000000 --- a/internal/openapi/openapi.go +++ /dev/null @@ -1,197 +0,0 @@ -package openapi - -import ( - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "os" - "strings" -) - -const ( - OAS2 = "2.0" - OAS3 = "3.0" - ContentType = "application/json" -) - -type OpenAPI struct { - // oas 2.0 has swagger in the root.k - Swagger string `json:"swagger,omitempty"` - Openapi string `json:"openapi,omitempty"` - Servers []Server `json:"servers,omitempty"` - Info Info `json:"info"` - Paths map[string]PathItem `json:"paths"` - Components Components `json:"components,omitempty"` - // oas 2.0 has definitions in the root. - Definitions map[string]Schema `json:"definitions,omitempty"` -} - -func (o *OpenAPI) OASVersion() string { - if o.Swagger == "2.0" { - return OAS2 - } - return OAS3 -} - -func (o *OpenAPI) DereferenceSchema(schema Schema) (*Schema, error) { - if schema.Ref != "" { - parts := strings.Split(schema.Ref, "/") - key := parts[len(parts)-1] - var childSchema Schema - var ok bool - switch o.OASVersion() { - case OAS2: - childSchema, ok = o.Definitions[key] - slog.Debug("oasv2.0", "key", key) - if !ok { - return nil, fmt.Errorf("schema %q not found", schema.Ref) - } - default: - childSchema, ok = o.Components.Schemas[key] - if !ok { - return nil, fmt.Errorf("schema %q not found", schema.Ref) - } - } - return o.DereferenceSchema(childSchema) - } - return &schema, nil -} - -func (o *OpenAPI) GetSchemaFromResponse(r Response) *Schema { - switch o.OASVersion() { - case OAS2: - return r.Schema - default: - ct := r.Content[ContentType] - return ct.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"` - Variables map[string]ServerVariable `json:"variables,omitempty"` -} - -type ServerVariable struct { - Enum []string `json:"enum,omitempty"` - Default string `json:"default"` - Description string `json:"description,omitempty"` -} - -type Info struct { - Title string `json:"title"` - Description string `json:"description"` - Version string `json:"version"` -} - -type PathItem struct { - Get *Operation `json:"get,omitempty"` - Patch *Operation `json:"patch,omitempty"` - Post *Operation `json:"post,omitempty"` - Put *Operation `json:"put,omitempty"` - Delete *Operation `json:"delete,omitempty"` -} - -type Operation struct { - Summary string `json:"summary"` - Description string `json:"description"` - OperationID string `json:"operationId"` - Parameters []Parameter `json:"parameters"` - Responses map[string]Response `json:"responses"` - RequestBody *RequestBody `json:"requestBody,omitempty"` -} - -type Parameter struct { - Name string `json:"name"` - In string `json:"in"` - Description string `json:"description"` - Required bool `json:"required"` - Schema Schema `json:"schema"` -} - -type Response struct { - Description string `json:"description"` - Content map[string]MediaType `json:"content"` - // oas 2.0 has the schema in the response. - Schema *Schema `json:"schema,omitempty"` -} - -type RequestBody struct { - Description string `json:"description"` - Content map[string]MediaType `json:"content"` - Required bool `json:"required"` - // oas 2.0 has the schema in the request body. - Schema *Schema `json:"schema,omitempty"` -} - -type MediaType struct { - Schema *Schema `json:"schema,omitempty"` -} - -type Schema struct { - Type string `json:"type"` - Format string `json:"format,omitempty"` - Items *Schema `json:"items,omitempty"` - Properties map[string]Schema `json:"properties,omitempty"` - Ref string `json:"$ref,omitempty"` - XAEPResource *XAEPResource `json:"x-aep-resource,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - Required []string `json:"required,omitempty"` -} - -type Components struct { - Schemas map[string]Schema `json:"schemas"` -} - -type XAEPResource struct { - Singular string `json:"singular,omitempty"` - Plural string `json:"plural,omitempty"` - Patterns []string `json:"patterns,omitempty"` - Parents []string `json:"parents,omitempty"` -} - -func FetchOpenAPI(pathOrURL string) (*OpenAPI, error) { - body, err := readFileOrURL(pathOrURL) - if err != nil { - return nil, fmt.Errorf("unable to read file or URL: %w", err) - } - - var api OpenAPI - if err := json.Unmarshal(body, &api); err != nil { - return nil, err - } - - return &api, nil -} - -func readFileOrURL(pathOrURL string) ([]byte, error) { - if isURL(pathOrURL) { - resp, err := http.Get(pathOrURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - return io.ReadAll(resp.Body) - } - - return os.ReadFile(pathOrURL) -} - -func isURL(str string) bool { - u, err := url.Parse(str) - return err == nil && (u.Scheme == "http" || u.Scheme == "https") -} diff --git a/internal/service/resource_definition.go b/internal/service/resource_definition.go index 5844b04..e15c857 100644 --- a/internal/service/resource_definition.go +++ b/internal/service/resource_definition.go @@ -7,48 +7,12 @@ import ( "net/http" "strings" - "github.com/aep-dev/aepcli/internal/openapi" + "github.com/aep-dev/aep-lib-go/pkg/api" + "github.com/aep-dev/aep-lib-go/pkg/openapi" "github.com/spf13/cobra" ) -type Resource struct { - 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 { - SupportsUserSettableCreate bool -} - -type GetMethod struct { -} - -type UpdateMethod struct { -} - -type ListMethod struct { -} - -type DeleteMethod struct { -} - -type CustomMethod struct { - Name string - Method string - Request *openapi.Schema - Response *openapi.Schema -} - -func (r *Resource) ExecuteCommand(args []string) (*http.Request, string, error) { +func ExecuteResourceCommand(r *api.Resource, args []string) (*http.Request, string, error) { c := cobra.Command{Use: r.Singular} var err error var req *http.Request @@ -205,10 +169,6 @@ func (r *Resource) ExecuteCommand(args []string) (*http.Request, string, error) 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 { for name, prop := range schema.Properties { if prop.ReadOnly { diff --git a/internal/service/resource_definition_test.go b/internal/service/resource_definition_test.go index a14d69c..504cea0 100644 --- a/internal/service/resource_definition_test.go +++ b/internal/service/resource_definition_test.go @@ -4,14 +4,15 @@ import ( "io" "testing" - "github.com/aep-dev/aepcli/internal/openapi" + "github.com/aep-dev/aep-lib-go/pkg/api" + "github.com/aep-dev/aep-lib-go/pkg/openapi" ) -var projectResource = Resource{ +var projectResource = api.Resource{ Singular: "project", Plural: "projects", PatternElems: []string{"projects", "{project}"}, - Parents: []*Resource{}, + Parents: []*api.Resource{}, Schema: &openapi.Schema{ Properties: map[string]openapi.Schema{ "name": { @@ -38,17 +39,17 @@ var projectResource = Resource{ }, Required: []string{"name"}, }, - GetMethod: &GetMethod{}, - ListMethod: &ListMethod{}, - CreateMethod: &CreateMethod{}, - UpdateMethod: &UpdateMethod{}, - DeleteMethod: &DeleteMethod{}, + GetMethod: &api.GetMethod{}, + ListMethod: &api.ListMethod{}, + CreateMethod: &api.CreateMethod{}, + UpdateMethod: &api.UpdateMethod{}, + DeleteMethod: &api.DeleteMethod{}, } func TestExecuteCommand(t *testing.T) { tests := []struct { name string - resource Resource + resource api.Resource args []string expectedQuery string expectedPath string @@ -87,17 +88,17 @@ func TestExecuteCommand(t *testing.T) { }, { name: "resource with parent", - resource: Resource{ + resource: api.Resource{ Singular: "dataset", Plural: "datasets", PatternElems: []string{"projects", "{project}", "datasets", "{dataset}"}, - Parents: []*Resource{&projectResource}, + Parents: []*api.Resource{&projectResource}, Schema: &openapi.Schema{}, - GetMethod: &GetMethod{}, - ListMethod: &ListMethod{}, - CreateMethod: &CreateMethod{}, - UpdateMethod: &UpdateMethod{}, - DeleteMethod: &DeleteMethod{}, + GetMethod: &api.GetMethod{}, + ListMethod: &api.ListMethod{}, + CreateMethod: &api.CreateMethod{}, + UpdateMethod: &api.UpdateMethod{}, + DeleteMethod: &api.DeleteMethod{}, }, args: []string{"--project=foo", "get", "abc"}, expectedPath: "projects/foo/datasets/abc", @@ -109,7 +110,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 := ExecuteResourceCommand(&tt.resource, 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 86927f5..6349105 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -10,19 +10,21 @@ import ( "net/url" "sort" "strings" + + "github.com/aep-dev/aep-lib-go/pkg/api" ) type Service struct { - ServiceDefinition + API api.API Headers map[string]string Client *http.Client } -func NewService(serviceDefinition ServiceDefinition, headers map[string]string) *Service { +func NewService(api *api.API, headers map[string]string) *Service { return &Service{ - ServiceDefinition: serviceDefinition, - Headers: headers, - Client: &http.Client{}, + API: *api, + Headers: headers, + Client: &http.Client{}, } } @@ -31,18 +33,18 @@ func (s *Service) ExecuteCommand(args []string) (string, error) { return s.PrintHelp(), nil } resource := args[0] - r, err := s.GetResource(resource) + r, err := s.API.GetResource(resource) if err != nil { return "", fmt.Errorf("%v\n%v", err, s.PrintHelp()) } - req, output, err := r.ExecuteCommand(args[1:]) + req, output, err := ExecuteResourceCommand(r, args[1:]) if err != nil { return "", fmt.Errorf("unable to execute command: %v", err) } if req == nil { return output, nil } - url, err := url.Parse(fmt.Sprintf("%s/%s", s.ServerURL, req.URL.String())) + url, err := url.Parse(fmt.Sprintf("%s/%s", s.API.ServerURL, req.URL.String())) if err != nil { return "", fmt.Errorf("unable to create url: %v", err) } @@ -88,14 +90,14 @@ func (s *Service) doRequest(r *http.Request) (string, error) { func (s *Service) PrintHelp() string { var resources []string - for singular := range s.Resources { + for singular := range s.API.Resources { resources = append(resources, singular) } sort.Strings(resources) var output strings.Builder output.WriteString("Usage: [resource] [method] [flags]\n\n") - output.WriteString("Command group for " + s.ServerURL + "\n\n") + output.WriteString("Command group for " + s.API.ServerURL + "\n\n") output.WriteString("Available resources:\n") for _, r := range resources { output.WriteString(fmt.Sprintf(" - %s\n", r)) diff --git a/internal/service/service_definition.go b/internal/service/service_definition.go deleted file mode 100644 index 68ab3a9..0000000 --- a/internal/service/service_definition.go +++ /dev/null @@ -1,305 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "strings" - - "github.com/aep-dev/aepcli/internal/openapi" - "github.com/aep-dev/aepcli/internal/utils" -) - -type ServiceDefinition struct { - ServerURL string - Resources map[string]*Resource -} - -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 { - path = path[len(pathPrefix):] - slog.Debug("path", "path", path) - var r Resource - var sRef *openapi.Schema - p := getPatternInfo(path) - if p == nil { // not a resource pattern - slog.Debug("path is not a resource", "path", path) - continue - } - slog.Debug("parsing path for resource", "path", path) - 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{} - } - if pathItem.Get != nil { - if resp, ok := pathItem.Get.Responses["200"]; ok { - sRef = api.GetSchemaFromResponse(resp) - r.GetMethod = &GetMethod{} - } - } - if pathItem.Patch != nil { - if resp, ok := pathItem.Patch.Responses["200"]; ok { - sRef = api.GetSchemaFromResponse(resp) - r.UpdateMethod = &UpdateMethod{} - } - } - } else { - // create method - if pathItem.Post != nil { - // check if there is a query parameter "id" - if resp, ok := pathItem.Post.Responses["200"]; ok { - sRef = api.GetSchemaFromResponse(resp) - supportsUserSettableCreate := false - for _, param := range pathItem.Post.Parameters { - if param.Name == "id" { - supportsUserSettableCreate = true - break - } - } - r.CreateMethod = &CreateMethod{SupportsUserSettableCreate: supportsUserSettableCreate} - } - } - // list method - if pathItem.Get != nil { - if resp, ok := pathItem.Get.Responses["200"]; ok { - respSchema := api.GetSchemaFromResponse(resp) - if respSchema == nil { - slog.Warn(fmt.Sprintf("resource %q has a LIST method with a response schema, but the response schema is nil.", path)) - } else { - resolvedSchema, err := api.DereferenceSchema(*respSchema) - if err != nil { - return nil, fmt.Errorf("error dereferencing schema %q: %v", respSchema.Ref, err) - } - found := false - for _, property := range resolvedSchema.Properties { - if property.Type == "array" { - sRef = property.Items - r.ListMethod = &ListMethod{} - found = true - break - } - } - if !found { - slog.Warn(fmt.Sprintf("resource %q has a LIST method with a response schema, but the items field is not present or is not an array.", path)) - } - } - } - } - } - if sRef != nil { - // s should always be a reference to a schema in the components section. - parts := strings.Split(sRef.Ref, "/") - key := parts[len(parts)-1] - dereferencedSchema, err := api.DereferenceSchema(*sRef) - if err != nil { - return nil, fmt.Errorf("error dereferencing schema %q: %v", sRef.Ref, err) - } - singular := utils.PascalCaseToKebabCase(key) - pattern := strings.Split(path, "/")[1:] - if !p.IsResourcePattern { - // 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 { - return nil, fmt.Errorf("error populating resource %q: %v", r.Singular, err) - } - foldResourceMethods(&r, r2) - } - } - // 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 - } - } - - if serverURL == "" { - return nil, fmt.Errorf("no server URL found in openapi, and none was provided") - } - - return &ServiceDefinition{ - ServerURL: serverURL, - Resources: resourceBySingular, - }, nil -} - -func (s *ServiceDefinition) GetResource(resource string) (*Resource, error) { - r, ok := (*s).Resources[resource] - if !ok { - return nil, fmt.Errorf("Resource %q not found", resource) - } - return r, nil -} - -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, ":") { - parts := strings.Split(path, ":") - path = parts[0] - customMethodName = parts[1] - } - // we ignore the first segment, which is empty. - pattern := strings.Split(path, "/")[1:] - for i, segment := range pattern { - // check if segment is wrapped in curly brackets - wrapped := strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") - wantWrapped := i%2 == 1 - if wrapped != wantWrapped { - return nil - } - } - return &PatternInfo{ - IsResourcePattern: len(pattern)%2 == 0, - CustomMethodName: customMethodName, - } -} - -// getOrPopulateResource populates the resource via a variety of means: -// - if the resource already exists in the map, it returns it -// - if the schema has the x-aep-resource annotation, it parses the resource -// - otherwise, it attempts to infer the resource from the schema and name. -func getOrPopulateResource(singular string, pattern []string, s *openapi.Schema, resourceBySingular map[string]*Resource, api *openapi.OpenAPI) (*Resource, error) { - if r, ok := resourceBySingular[singular]; ok { - return r, nil - } - var r *Resource - // use the X-AEP-Resource annotation to populate the resource, - // if it exists. - if s.XAEPResource != nil { - parents := []*Resource{} - for _, parentSingular := range s.XAEPResource.Parents { - parentSchema, ok := api.Components.Schemas[parentSingular] - if !ok { - return nil, fmt.Errorf("resource %q parent %q not found", singular, parentSingular) - } - parentResource, err := getOrPopulateResource(parentSingular, []string{}, &parentSchema, resourceBySingular, api) - if err != nil { - 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, - PatternElems: strings.Split(s.XAEPResource.Patterns[0], "/")[1:], - Schema: s, - } - } else { - // best effort otherwise - r = &Resource{ - Schema: s, - PatternElems: pattern, - Singular: singular, - } - } - resourceBySingular[singular] = r - return r, nil -} - -func foldResourceMethods(from, into *Resource) { - if from.GetMethod != nil { - into.GetMethod = from.GetMethod - } - if from.ListMethod != nil { - into.ListMethod = from.ListMethod - } - if from.CreateMethod != nil { - into.CreateMethod = from.CreateMethod - } - if from.UpdateMethod != nil { - into.UpdateMethod = from.UpdateMethod - } - if from.DeleteMethod != nil { - into.DeleteMethod = from.DeleteMethod - } -} diff --git a/internal/service/service_definition_test.go b/internal/service/service_definition_test.go deleted file mode 100644 index c6f8da1..0000000 --- a/internal/service/service_definition_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package service - -import ( - "testing" - - "github.com/aep-dev/aepcli/internal/openapi" - "github.com/stretchr/testify/assert" -) - -var basicOpenAPI = &openapi.OpenAPI{ - Servers: []openapi.Server{{URL: "https://api.example.com"}}, - Paths: map[string]openapi.PathItem{ - "/widgets": { - Get: &openapi.Operation{ - Responses: map[string]openapi.Response{ - "200": { - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.Schema{ - Properties: map[string]openapi.Schema{ - "results": { - Type: "array", - Items: &openapi.Schema{ - Ref: "#/components/schemas/Widget", - }, - }, - }, - }, - }, - }, - }, - }, - }, - Post: &openapi.Operation{ - Responses: map[string]openapi.Response{ - "200": { - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.Schema{ - Ref: "#/components/schemas/Widget", - }, - }, - }, - }, - }, - }, - }, - "/widgets/{widget}": { - Get: &openapi.Operation{ - Responses: map[string]openapi.Response{ - "200": { - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.Schema{ - Ref: "#/components/schemas/Widget", - }, - }, - }, - }, - }, - }, - Delete: &openapi.Operation{}, - Patch: &openapi.Operation{ - Responses: map[string]openapi.Response{ - "200": { - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.Schema{ - Ref: "#/components/schemas/Widget", - }, - }, - }, - }, - }, - }, - }, - }, - Components: openapi.Components{ - Schemas: map[string]openapi.Schema{ - "Widget": { - Type: "object", - Properties: map[string]openapi.Schema{ - "name": {Type: "string"}, - }, - }, - }, - }, -} - -func TestGetServiceDefinition(t *testing.T) { - tests := []struct { - name string - api *openapi.OpenAPI - serverURL string - expectedError string - validateResult func(*testing.T, *ServiceDefinition) - }{ - { - name: "basic resource with CRUD operations", - api: basicOpenAPI, - validateResult: func(t *testing.T, sd *ServiceDefinition) { - assert.Equal(t, "https://api.example.com", sd.ServerURL) - - widget, ok := sd.Resources["widget"] - assert.True(t, ok, "widget resource should exist") - 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") - assert.NotNil(t, widget.CreateMethod, "should have CREATE method") - if widget.CreateMethod != nil { - assert.False(t, widget.CreateMethod.SupportsUserSettableCreate, "should not support user-settable create") - } - assert.NotNil(t, widget.UpdateMethod, "should have UPDATE method") - assert.NotNil(t, widget.DeleteMethod, "should have DELETE method") - }, - }, - { - name: "empty openapi with server url override", - api: basicOpenAPI, - serverURL: "https://override.example.com", - validateResult: func(t *testing.T, sd *ServiceDefinition) { - assert.Equal(t, "https://override.example.com", sd.ServerURL) - }, - }, - { - name: "resource with x-aep-resource annotation", - api: &openapi.OpenAPI{ - Paths: map[string]openapi.PathItem{ - "/widgets/{widget}": { - Get: &openapi.Operation{ - Responses: map[string]openapi.Response{ - "200": { - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.Schema{ - Ref: "#/components/schemas/widget", - }, - }, - }, - }, - }, - }, - }, - }, - Servers: []openapi.Server{{URL: "https://api.example.com"}}, - Components: openapi.Components{ - Schemas: map[string]openapi.Schema{ - "widget": { - Type: "object", - Properties: map[string]openapi.Schema{ - "name": {Type: "string"}, - }, - XAEPResource: &openapi.XAEPResource{ - Singular: "widget", - Plural: "widgets", - Patterns: []string{"/widgets/{widget}"}, - }, - }, - }, - }, - }, - validateResult: func(t *testing.T, sd *ServiceDefinition) { - widget, ok := sd.Resources["widget"] - 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.PatternElems) - }, - }, - { - name: "missing server URL", - api: &openapi.OpenAPI{ - Servers: []openapi.Server{}, - }, - expectedError: "no server URL found in openapi, and none was provided", - }, - { - name: "resource with user-settable create ID", - api: &openapi.OpenAPI{ - Servers: []openapi.Server{{URL: "https://api.example.com"}}, - Paths: map[string]openapi.PathItem{ - "/widgets": { - Post: &openapi.Operation{ - Parameters: []openapi.Parameter{ - {Name: "id"}, - }, - Responses: map[string]openapi.Response{ - "200": { - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.Schema{ - Ref: "#/components/schemas/Widget", - }, - }, - }, - }, - }, - }, - }, - }, - Components: openapi.Components{ - Schemas: map[string]openapi.Schema{ - "Widget": { - Type: "object", - }, - }, - }, - }, - validateResult: func(t *testing.T, sd *ServiceDefinition) { - widget, ok := sd.Resources["widget"] - assert.True(t, ok, "widget resource should exist") - assert.True(t, widget.CreateMethod.SupportsUserSettableCreate, - "should support user-settable create") - }, - }, - { - name: "OAS 2.0 style schema in response", - api: &openapi.OpenAPI{ - Swagger: "2.0", - Servers: []openapi.Server{{URL: "https://api.example.com"}}, - Paths: map[string]openapi.PathItem{ - "/widgets/{widget}": { - Get: &openapi.Operation{ - Responses: map[string]openapi.Response{ - "200": { - Schema: &openapi.Schema{ - Ref: "#/definitions/Widget", - }, - }, - }, - }, - }, - }, - Definitions: map[string]openapi.Schema{ - "Widget": { - Type: "object", - Properties: map[string]openapi.Schema{ - "name": {Type: "string"}, - }, - }, - }, - }, - validateResult: func(t *testing.T, sd *ServiceDefinition) { - 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.PatternElems) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := GetServiceDefinition(tt.api, tt.serverURL, "") - - if tt.expectedError != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedError) - return - } - - assert.NoError(t, err) - assert.NotNil(t, result) - - if tt.validateResult != nil { - tt.validateResult(t, result) - } - }) - } -} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 6fb5d23..249972f 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -3,13 +3,15 @@ package service import ( "strings" "testing" + + "github.com/aep-dev/aep-lib-go/pkg/api" ) func TestService_ExecuteCommand_ListResources(t *testing.T) { // Test setup - svc := NewService(ServiceDefinition{ + svc := NewService(&api.API{ ServerURL: "http://test.com", - Resources: map[string]*Resource{ + Resources: map[string]*api.Resource{ "project": &projectResource, "user": {}, "post": {}, diff --git a/internal/utils/cases.go b/internal/utils/cases.go deleted file mode 100644 index b03600d..0000000 --- a/internal/utils/cases.go +++ /dev/null @@ -1,14 +0,0 @@ -package utils - -import "strings" - -func PascalCaseToKebabCase(s string) string { - var result []rune - for i, r := range s { - if i > 0 && 'A' <= r && r <= 'Z' { - result = append(result, '-') - } - result = append(result, r) - } - return strings.ToLower(string(result)) -}