diff --git a/.circleci/config.yml b/.circleci/config.yml index 453bda49a..edd87a19d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -253,7 +253,7 @@ jobs: - attach_workspace: at: . - run: | - TAG=$(./dist/circleci-cli_linux_amd64/circleci version | cut -d' ' -f 1) && export TAG + TAG=$(./dist/circleci-cli_linux_amd64_v1/circleci version | cut -d' ' -f 1) && export TAG sed -i -- "s/%CLI_VERSION_PLACEHOLDER%/$TAG/g" snap/snapcraft.yaml - run: snapcraft - run: diff --git a/api/api.go b/api/api.go index 0c51b486d..2dbaca0bd 100644 --- a/api/api.go +++ b/api/api.go @@ -512,133 +512,6 @@ func WhoamiQuery(cl *graphql.Client) (*WhoamiResponse, error) { return &response, nil } -// OrbQuery validated and processes an orb. -func OrbQuery(cl *graphql.Client, configPath string, ownerId string) (*ConfigResponse, error) { - var response OrbConfigResponse - - config, err := loadYaml(configPath) - if err != nil { - return nil, err - } - - request, err := makeOrbRequest(cl, config, ownerId) - if err != nil { - return nil, err - } - - err = cl.Run(request, &response) - if err != nil { - return nil, errors.Wrap(err, "Unable to validate config") - } - - if len(response.OrbConfig.ConfigResponse.Errors) > 0 { - return nil, response.OrbConfig.ConfigResponse.Errors - } - - return &response.OrbConfig.ConfigResponse, nil -} - -func makeOrbRequest(cl *graphql.Client, configContent string, ownerId string) (*graphql.Request, error) { - handlesOwner := orbQueryHandleOwnerId(cl) - - if handlesOwner { - query := ` - query ValidateOrb ($config: String!, $owner: UUID) { - orbConfig(orbYaml: $config, ownerId: $owner) { - valid, - errors { message }, - sourceYaml, - outputYaml - } - }` - - request := graphql.NewRequest(query) - request.Var("config", configContent) - - if ownerId != "" { - request.Var("owner", ownerId) - } - - request.SetToken(cl.Token) - return request, nil - } - - if ownerId != "" { - return nil, errors.Errorf("Your version of Server does not support validating orbs that refer to other private orbs. Please see the README for more information on server compatibility: https://github.com/CircleCI-Public/circleci-cli#server-compatibility") - } - query := ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { - valid, - errors { message }, - sourceYaml, - outputYaml - } - }` - - request := graphql.NewRequest(query) - request.Var("config", configContent) - - request.SetToken(cl.Token) - return request, nil -} - -type OrbIntrospectionResponse struct { - Schema struct { - Query struct { - Fields []struct { - Name string `json:"name"` - Args []struct { - Name string `json:"name"` - } `json:"args"` - } `json:"fields"` - } `json:"queryType"` - } `json:"__schema"` -} - -func orbQueryHandleOwnerId(cl *graphql.Client) bool { - query := ` -query ValidateOrb { - __schema { - queryType { - fields(includeDeprecated: true) { - name - args { - name - __typename - type { - name - } - } - } - } - } -}` - request := graphql.NewRequest(query) - response := OrbIntrospectionResponse{} - err := cl.Run(request, &response) - if err != nil { - return false - } - - request.SetToken(cl.Token) - - // Find the orbConfig query method, look at its arguments, if it has the "ownerId" argument, return true - for _, field := range response.Schema.Query.Fields { - if field.Name == "orbConfig" { - for _, arg := range field.Args { - if arg.Name == "ownerId" { - return true - } - } - } - } - - // else return false, ownerId is not supported - - return false -} - // OrbImportVersion publishes a new version of an orb using the provided source and id. func OrbImportVersion(cl *graphql.Client, orbSrc string, orbID string, orbVersion string) (*Orb, error) { var response OrbImportVersionResponse diff --git a/api/orb/client.go b/api/orb/client.go new file mode 100644 index 000000000..445479a08 --- /dev/null +++ b/api/orb/client.go @@ -0,0 +1,131 @@ +package orb + +import ( + "fmt" + "io" + "os" + + "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/api/graphql" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/pkg/errors" +) + +type clientVersion string + +// ConfigResponse is a structure that matches the result of the GQL +// query, so that we can use mapstructure to convert from +// nested maps to a strongly typed struct. +type QueryResponse struct { + OrbConfig struct { + api.ConfigResponse + } +} + +type Client interface { + OrbQuery(configPath string, ownerId string) (*api.ConfigResponse, error) +} + +func NewClient(config *settings.Config) (Client, error) { + gql := graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug) + + clientVersion, err := detectClientVersion(gql) + if err != nil { + return nil, err + } + + switch clientVersion { + case v1_string: + return &v1Client{gql}, nil + case v2_string: + return &v2Client{gql}, nil + default: + return nil, fmt.Errorf("Unable to recognise your server orb API") + } +} + +// detectClientVersion returns the highest available version of the orb API +// +// To do that it checks that whether the GraphQL query has the parameter "ownerId" or not. +// If it does not have the parameter, the function returns `v1_string` else it returns `v2_string` +func detectClientVersion(gql *graphql.Client) (clientVersion, error) { + handlesOwnerId, err := orbQueryHandleOwnerId(gql) + if err != nil { + return "", err + } + if !handlesOwnerId { + return v1_string, nil + } + return v2_string, nil +} + +type OrbIntrospectionResponse struct { + Schema struct { + Query struct { + Fields []struct { + Name string `json:"name"` + Args []struct { + Name string `json:"name"` + } `json:"args"` + } `json:"fields"` + } `json:"queryType"` + } `json:"__schema"` +} + +func orbQueryHandleOwnerId(gql *graphql.Client) (bool, error) { + query := `query IntrospectionQuery { + _schema { + queryType { + fields(includeDeprecated: true) { + name + args { + name + __typename + type { + name + } + } + } + } + } +}` + request := graphql.NewRequest(query) + response := OrbIntrospectionResponse{} + err := gql.Run(request, &response) + if err != nil { + return false, err + } + + request.SetToken(gql.Token) + + // Find the orbConfig query method, look at its arguments, if it has the "ownerId" argument, return true + for _, field := range response.Schema.Query.Fields { + if field.Name == "orbConfig" { + for _, arg := range field.Args { + if arg.Name == "ownerId" { + return true, nil + } + } + } + } + + // else return false, ownerId is not supported + + return false, nil +} + +func loadYaml(path string) (string, error) { + var err error + var config []byte + if path == "-" { + config, err = io.ReadAll(os.Stdin) + } else { + config, err = os.ReadFile(path) + } + + if err != nil { + return "", errors.Wrapf(err, "Could not load config file at %s", path) + } + + return string(config), nil +} diff --git a/api/orb/v1_client.go b/api/orb/v1_client.go new file mode 100644 index 000000000..3fee53040 --- /dev/null +++ b/api/orb/v1_client.go @@ -0,0 +1,53 @@ +package orb + +import ( + "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/api/graphql" + "github.com/pkg/errors" +) + +// This client makes request to servers that **DON'T** have the field `ownerId` in the GraphQL query method: `orbConfig` + +const v1_string clientVersion = "v1" + +type v1Client struct { + gql *graphql.Client +} + +func (client *v1Client) OrbQuery(configPath string, ownerId string) (*api.ConfigResponse, error) { + if ownerId != "" { + return nil, errors.New("Your version of Server does not support validating orbs that refer to other private orbs. Please see the README for more information on server compatibility: https://github.com/CircleCI-Public/circleci-cli#server-compatibility") + } + + var response QueryResponse + + configContent, err := loadYaml(configPath) + if err != nil { + return nil, err + } + + query := `query ValidateOrb ($config: String!) { + orbConfig(orbYaml: $config) { + valid, + errors { message }, + sourceYaml, + outputYaml + } +}` + + request := graphql.NewRequest(query) + request.Var("config", configContent) + + request.SetToken(client.gql.Token) + + err = client.gql.Run(request, &response) + if err != nil { + return nil, errors.Wrap(err, "Validating config") + } + + if len(response.OrbConfig.ConfigResponse.Errors) > 0 { + return nil, response.OrbConfig.ConfigResponse.Errors + } + + return &response.OrbConfig.ConfigResponse, nil +} diff --git a/api/orb/v2_client.go b/api/orb/v2_client.go new file mode 100644 index 000000000..a33496f93 --- /dev/null +++ b/api/orb/v2_client.go @@ -0,0 +1,52 @@ +package orb + +import ( + "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/api/graphql" + "github.com/pkg/errors" +) + +// This client makes request to servers that **DO** have the field `ownerId` in the GraphQL query method: `orbConfig` + +const v2_string clientVersion = "v2" + +type v2Client struct { + gql *graphql.Client +} + +func (client *v2Client) OrbQuery(configPath string, ownerId string) (*api.ConfigResponse, error) { + var response QueryResponse + + configContent, err := loadYaml(configPath) + if err != nil { + return nil, err + } + + query := `query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { + valid, + errors { message }, + sourceYaml, + outputYaml + } +}` + + request := graphql.NewRequest(query) + request.Var("config", configContent) + + if ownerId != "" { + request.Var("owner", ownerId) + } + request.SetToken(client.gql.Token) + + err = client.gql.Run(request, &response) + if err != nil { + return nil, errors.Wrap(err, "Validating config") + } + + if len(response.OrbConfig.ConfigResponse.Errors) > 0 { + return nil, response.OrbConfig.ConfigResponse.Errors + } + + return &response.OrbConfig.ConfigResponse, nil +} diff --git a/api/rest/client.go b/api/rest/client.go index 2d1628f41..1e8e9e6bb 100644 --- a/api/rest/client.go +++ b/api/rest/client.go @@ -84,7 +84,7 @@ func (c *Client) enrichRequestHeaders(req *http.Request, payload interface{}) { } } -func (c *Client) DoRequest(req *http.Request, resp interface{}) (statusCode int, err error) { +func (c *Client) DoRequest(req *http.Request, resp interface{}) (int, error) { httpResp, err := c.client.Do(req) if err != nil { fmt.Printf("failed to make http request: %s\n", err.Error()) @@ -96,9 +96,13 @@ func (c *Client) DoRequest(req *http.Request, resp interface{}) (statusCode int, httpError := struct { Message string `json:"message"` }{} - err = json.NewDecoder(httpResp.Body).Decode(&httpError) + body, err := io.ReadAll(httpResp.Body) if err != nil { - return httpResp.StatusCode, err + return 0, err + } + err = json.Unmarshal(body, &httpError) + if err != nil { + return httpResp.StatusCode, &HTTPError{Code: httpResp.StatusCode, Message: string(body)} } return httpResp.StatusCode, &HTTPError{Code: httpResp.StatusCode, Message: httpError.Message} } diff --git a/cmd/config.go b/cmd/config.go index 6c85de763..690fc78d1 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -50,7 +50,11 @@ func newConfigCommand(globalConfig *settings.Config) *cobra.Command { Aliases: []string{"check"}, Short: "Check that the config file is well formed.", RunE: func(cmd *cobra.Command, args []string) error { - compiler := config.New(globalConfig) + compiler, err := config.NewWithConfig(globalConfig) + if err != nil { + return err + } + orgID, _ := cmd.Flags().GetString("org-id") orgSlug, _ := cmd.Flags().GetString("org-slug") path := config.DefaultConfigPath @@ -86,7 +90,11 @@ func newConfigCommand(globalConfig *settings.Config) *cobra.Command { Use: "process ", Short: "Validate config and display expanded configuration.", RunE: func(cmd *cobra.Command, args []string) error { - compiler := config.New(globalConfig) + compiler, err := config.NewWithConfig(globalConfig) + if err != nil { + return err + } + pipelineParamsFilePath, _ := cmd.Flags().GetString("pipeline-parameters") orgID, _ := cmd.Flags().GetString("org-id") orgSlug, _ := cmd.Flags().GetString("org-slug") diff --git a/cmd/orb.go b/cmd/orb.go index a7037c7d8..d7a011eb3 100644 --- a/cmd/orb.go +++ b/cmd/orb.go @@ -20,6 +20,7 @@ import ( "github.com/CircleCI-Public/circleci-cli/api" "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" + "github.com/CircleCI-Public/circleci-cli/api/orb" "github.com/CircleCI-Public/circleci-cli/filetree" "github.com/CircleCI-Public/circleci-cli/process" "github.com/CircleCI-Public/circleci-cli/prompt" @@ -732,7 +733,11 @@ func validateOrb(opts orbOptions, org orbOrgOptions) error { return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) } - _, err = api.OrbQuery(opts.cl, opts.args[0], orgId) + client, err := orb.NewClient(opts.cfg) + if err != nil { + return errors.Wrap(err, "Getting orb client") + } + _, err = client.OrbQuery(opts.args[0], orgId) if err != nil { return err @@ -754,7 +759,11 @@ func processOrb(opts orbOptions, org orbOrgOptions) error { return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) } - response, err := api.OrbQuery(opts.cl, opts.args[0], orgId) + client, err := orb.NewClient(opts.cfg) + if err != nil { + return errors.Wrap(err, "Getting orb client") + } + response, err := client.OrbQuery(opts.args[0], orgId) if err != nil { return err diff --git a/cmd/orb_test.go b/cmd/orb_test.go index 7b4e917ba..66187398b 100644 --- a/cmd/orb_test.go +++ b/cmd/orb_test.go @@ -125,15 +125,14 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs Config string `json:"config"` } `json:"variables"` }{ - Query: ` - query ValidateOrb ($config: String!, $owner: UUID) { - orbConfig(orbYaml: $config, ownerId: $owner) { - valid, - errors { message }, - sourceYaml, - outputYaml - } - }`, + Query: `query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { + valid, + errors { message }, + sourceYaml, + outputYaml + } +}`, Variables: struct { Config string `json:"config"` }{ @@ -189,7 +188,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "query ValidateOrb ($config: String!, $owner: UUID) {\n\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\tvalid,\n\t\terrors { message },\n\t\tsourceYaml,\n\t\toutputYaml\n\t}\n}", "variables": { "config": "{}" } @@ -210,9 +209,12 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }) }) - Describe("with old server version", func() { + Describe("when using org-id parameter", func() { BeforeEach(func() { token = "testtoken" + }) + + It("should use the old GraphQL resolver when the parameter is not present on the server pointed by host", func() { command = exec.Command(pathCLI, "orb", "validate", "--skip-update-check", @@ -229,13 +231,106 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs panic(err) } }() + + By("setting up a mock server") + + mockOrbIntrospection(false, "", tempSettings) + + gqlResponse := `{ + "orbConfig": { + "sourceYaml": "{}", + "valid": true, + "errors": [] + } + }` + + response := struct { + Query string `json:"query"` + Variables struct { + Config string `json:"config"` + } `json:"variables"` + }{ + Query: `query ValidateOrb ($config: String!) { + orbConfig(orbYaml: $config) { + valid, + errors { message }, + sourceYaml, + outputYaml + } +}`, + Variables: struct { + Config string `json:"config"` + }{ + Config: "{}", + }, + } + expected, err := json.Marshal(response) + Expect(err).ShouldNot(HaveOccurred()) + + tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{ + Status: http.StatusOK, + Request: string(expected), + Response: gqlResponse}) + + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + + Expect(err).ShouldNot(HaveOccurred()) + Eventually(session.Out).Should(gbytes.Say("Orb input is valid.")) + Eventually(session).Should(gexec.Exit(0)) }) - It("should use the old GraphQL resolver", func() { + It("indicate a deprecation error when the parameter is not present on the server pointed by host", func() { + command = exec.Command(pathCLI, + "orb", "validate", + "--skip-update-check", + "--token", token, + "--host", tempSettings.TestServer.URL(), + "--org-id", "org-id", + "-", + ) + stdin, err := command.StdinPipe() + Expect(err).ToNot(HaveOccurred()) + go func() { + defer stdin.Close() + _, err := io.WriteString(stdin, "{}") + if err != nil { + panic(err) + } + }() + By("setting up a mock server") mockOrbIntrospection(false, "", tempSettings) + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + + Expect(err).ShouldNot(HaveOccurred()) + Eventually(session.Err).Should(gbytes.Say("Your version of Server does not support validating orbs that refer to other private orbs. Please see the README for more information on server compatibility: https://github.com/CircleCI-Public/circleci-cli#server-compatibility")) + Eventually(session).Should(gexec.Exit(-1)) + }) + + It("should work properly when the parameter is present", func() { + command = exec.Command(pathCLI, + "orb", "validate", + "--skip-update-check", + "--token", token, + "--host", tempSettings.TestServer.URL(), + "--org-id", "org-id", + "-", + ) + stdin, err := command.StdinPipe() + Expect(err).ToNot(HaveOccurred()) + go func() { + defer stdin.Close() + _, err := io.WriteString(stdin, "{}") + if err != nil { + panic(err) + } + }() + + By("setting up a mock server") + + mockOrbIntrospection(true, "", tempSettings) gqlResponse := `{ "orbConfig": { "sourceYaml": "{}", @@ -248,21 +343,23 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs Query string `json:"query"` Variables struct { Config string `json:"config"` + Owner string `json:"owner"` } `json:"variables"` }{ - Query: ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { - valid, - errors { message }, - sourceYaml, - outputYaml - } - }`, + Query: `query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { + valid, + errors { message }, + sourceYaml, + outputYaml + } +}`, Variables: struct { Config string `json:"config"` + Owner string `json:"owner"` }{ Config: "{}", + Owner: "org-id", }, } expected, err := json.Marshal(response) @@ -310,7 +407,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "query ValidateOrb ($config: String!, $owner: UUID) {\n\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\tvalid,\n\t\terrors { message },\n\t\tsourceYaml,\n\t\toutputYaml\n\t}\n}", "variables": { "config": "some orb" } @@ -347,7 +444,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "query ValidateOrb ($config: String!, $owner: UUID) {\n\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\tvalid,\n\t\terrors { message },\n\t\tsourceYaml,\n\t\toutputYaml\n\t}\n}", "variables": { "config": "some orb" } @@ -392,7 +489,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "query ValidateOrb ($config: String!, $owner: UUID) {\n\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\tvalid,\n\t\terrors { message },\n\t\tsourceYaml,\n\t\toutputYaml\n\t}\n}", "variables": { "config": "some orb" } @@ -429,7 +526,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "query ValidateOrb ($config: String!, $owner: UUID) {\n\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\tvalid,\n\t\terrors { message },\n\t\tsourceYaml,\n\t\toutputYaml\n\t}\n}", "variables": { "config": "some orb" } @@ -3615,22 +3712,21 @@ func mockOrbIntrospection(isValid bool, token string, tempSettings *clitest.Temp Expect(err).ToNot(HaveOccurred()) requestStruct := map[string]interface{}{ - "query": ` -query ValidateOrb { - __schema { - queryType { - fields(includeDeprecated: true) { - name - args { - name - __typename - type { - name - } - } - } - } - } + "query": `query IntrospectionQuery { + _schema { + queryType { + fields(includeDeprecated: true) { + name + args { + name + __typename + type { + name + } + } + } + } + } }`, "variables": map[string]interface{}{}, } diff --git a/cmd/policy/policy.go b/cmd/policy/policy.go index dfecd3371..6ccab181c 100644 --- a/cmd/policy/policy.go +++ b/cmd/policy/policy.go @@ -295,7 +295,10 @@ This group of commands allows the management of polices to be verified against b } if !noCompile && context == "config" { - compiler := config.New(globalConfig) + compiler, err := config.NewWithConfig(globalConfig) + if err != nil { + return err + } input, err = mergeCompiledConfig(compiler, config.ProcessConfigOpts{ ConfigPath: inputPath, OrgID: ownerID, @@ -376,7 +379,10 @@ This group of commands allows the management of polices to be verified against b } if !noCompile && context == "config" { - compiler := config.New(globalConfig) + compiler, err := config.NewWithConfig(globalConfig) + if err != nil { + return err + } input, err = mergeCompiledConfig(compiler, config.ProcessConfigOpts{ ConfigPath: inputPath, OrgID: ownerID, diff --git a/cmd/policy/policy_test.go b/cmd/policy/policy_test.go index 8fbe1a72c..227a6a66d 100644 --- a/cmd/policy/policy_test.go +++ b/cmd/policy/policy_test.go @@ -635,7 +635,10 @@ test: config CompilerServerHandler: func(w http.ResponseWriter, r *http.Request) { var req config.CompileConfigRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } // dummy compilation here (remove the _compiled_ key in compiled config, as compiled config can't have that at top-level key). var yamlResp map[string]any @@ -678,7 +681,10 @@ test: config CompilerServerHandler: func(w http.ResponseWriter, r *http.Request) { var req config.CompileConfigRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } response := config.ConfigResponse{Valid: true, SourceYaml: req.ConfigYaml, OutputYaml: req.ConfigYaml} @@ -837,7 +843,10 @@ test: config CompilerServerHandler: func(w http.ResponseWriter, r *http.Request) { var req config.CompileConfigRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } response := config.ConfigResponse{Valid: true, SourceYaml: req.ConfigYaml, OutputYaml: req.ConfigYaml} @@ -1082,7 +1091,10 @@ test: config CompilerServerHandler: func(w http.ResponseWriter, r *http.Request) { var req config.CompileConfigRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } response := config.ConfigResponse{Valid: true, SourceYaml: req.ConfigYaml, OutputYaml: req.ConfigYaml} diff --git a/config/api_client.go b/config/api_client.go new file mode 100644 index 000000000..22c791fcc --- /dev/null +++ b/config/api_client.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/CircleCI-Public/circleci-cli/api/graphql" + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +const compilePath = "compile-config-with-defaults" + +type apiClientVersion string + +type APIClient interface { + CompileConfig(configContent string, orgID string, params Parameters, values Values) (*ConfigResponse, error) +} + +func newAPIClient(config *settings.Config) (APIClient, error) { + hostValue := GetCompileHost(config.Host) + restClient := rest.NewFromConfig(hostValue, config) + + version, err := detectAPIClientVersion(restClient) + if err != nil { + return nil, err + } + + switch version { + case v1_string: + return &v1APIClient{graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug)}, nil + case v2_string: + return &v2APIClient{restClient}, nil + default: + return nil, fmt.Errorf("Unable to recognise your Server's config file API") + } +} + +// detectAPIClientVersion returns the highest available version of the config API. +// +// To do that it tries to request the `compilePath` API route. +// If the route returns a 404, this means the route does not exist on the requested host and the function returns +// `v1_string` indicating that the deprecated GraphQL endpoint should be used instead. +// Else if the route returns any other status, this means it is available for request and the function returns +// `v2_string` indicating that the route can be used +func detectAPIClientVersion(restClient *rest.Client) (apiClientVersion, error) { + req, err := restClient.NewRequest("POST", &url.URL{Path: compilePath}, nil) + if err != nil { + return "", err + } + + _, err = restClient.DoRequest(req, nil) + httpErr, ok := err.(*rest.HTTPError) + if !ok { + return "", err + } + if httpErr.Code == http.StatusNotFound { + return v1_string, nil + } + return v2_string, nil +} diff --git a/config/api_client_test.go b/config/api_client_test.go new file mode 100644 index 000000000..cc61be18d --- /dev/null +++ b/config/api_client_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "gotest.tools/v3/assert" +) + +func TestAPIClient(t *testing.T) { + t.Run("detectCompilerVersion", func(t *testing.T) { + t.Run("when the route returns a 404 tells that the version is v1", func(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + fmt.Fprintf(w, "Invalid input") + })) + url, err := url.Parse(svr.URL) + assert.NilError(t, err) + + restClient := rest.New(url, "token", http.DefaultClient) + version, err := detectAPIClientVersion(restClient) + assert.NilError(t, err) + assert.Equal(t, version, v1_string) + }) + + t.Run("on other cases return v2", func(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + fmt.Fprintf(w, "Invalid input") + })) + url, err := url.Parse(svr.URL) + assert.NilError(t, err) + + restClient := rest.New(url, "token", http.DefaultClient) + version, err := detectAPIClientVersion(restClient) + assert.NilError(t, err) + assert.Equal(t, version, v2_string) + }) + }) +} diff --git a/config/commands_test.go b/config/commands_test.go index dc6e7a16a..786eafa18 100644 --- a/config/commands_test.go +++ b/config/commands_test.go @@ -8,6 +8,8 @@ import ( "net/http/httptest" "testing" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" + "github.com/CircleCI-Public/circleci-cli/api/rest" "github.com/CircleCI-Public/circleci-cli/settings" "github.com/stretchr/testify/assert" ) @@ -18,7 +20,11 @@ func TestGetOrgID(t *testing.T) { fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) t.Run("returns the original org-id passed if it is set", func(t *testing.T) { expected := "1234" @@ -66,9 +72,13 @@ func TestValidateConfig(t *testing.T) { fmt.Fprintf(w, `{"valid":true,"source-yaml":"%s","output-yaml":"%s","errors":[]}`, testYaml, testYaml) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) - err := compiler.ValidateConfig(ValidateConfigOpts{ + err = compiler.ValidateConfig(ValidateConfigOpts{ ConfigPath: "testdata/config.yml", }) assert.NoError(t, err) @@ -87,9 +97,13 @@ func TestValidateConfig(t *testing.T) { fmt.Fprintf(w, `{"valid":true,"source-yaml":"%s","output-yaml":"%s","errors":[]}`, testYaml, testYaml) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) - err := compiler.ValidateConfig(ValidateConfigOpts{ + err = compiler.ValidateConfig(ValidateConfigOpts{ ConfigPath: "testdata/config.yml", OrgID: "1234", }) @@ -119,9 +133,13 @@ func TestValidateConfig(t *testing.T) { svr := httptest.NewServer(mux) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) - err := compiler.ValidateConfig(ValidateConfigOpts{ + err = compiler.ValidateConfig(ValidateConfigOpts{ ConfigPath: "testdata/config.yml", OrgSlug: "gh/test", }) @@ -151,9 +169,13 @@ func TestValidateConfig(t *testing.T) { svr := httptest.NewServer(mux) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) - err := compiler.ValidateConfig(ValidateConfigOpts{ + err = compiler.ValidateConfig(ValidateConfigOpts{ ConfigPath: "testdata/config.yml", OrgSlug: "gh/nonexistent", }) diff --git a/config/config.go b/config/config.go index ba55404de..9d52be9a1 100644 --- a/config/config.go +++ b/config/config.go @@ -3,12 +3,9 @@ package config import ( "fmt" "io" - "net/url" "os" "github.com/CircleCI-Public/circleci-cli/api/collaborators" - "github.com/CircleCI-Public/circleci-cli/api/graphql" - "github.com/CircleCI-Public/circleci-cli/api/rest" "github.com/CircleCI-Public/circleci-cli/settings" "github.com/pkg/errors" ) @@ -22,29 +19,28 @@ var ( ) type ConfigCompiler struct { - host string - compileRestClient *rest.Client - collaborators collaborators.CollaboratorsClient - - cfg *settings.Config - legacyGraphQLClient *graphql.Client + apiClient APIClient + collaborators collaborators.CollaboratorsClient } -func New(cfg *settings.Config) *ConfigCompiler { - hostValue := GetCompileHost(cfg.Host) +func NewWithConfig(cfg *settings.Config) (*ConfigCompiler, error) { + apiClient, err := newAPIClient(cfg) + if err != nil { + return nil, err + } collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) if err != nil { - panic(err) + return nil, err } + return New(apiClient, collaboratorsClient), nil +} +func New(apiClient APIClient, collaboratorsClient collaborators.CollaboratorsClient) *ConfigCompiler { configCompiler := &ConfigCompiler{ - host: hostValue, - compileRestClient: rest.NewFromConfig(hostValue, cfg), - collaborators: collaboratorsClient, - cfg: cfg, + apiClient: apiClient, + collaborators: collaboratorsClient, } - configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug) return configCompiler } @@ -94,49 +90,7 @@ func (c *ConfigCompiler) ConfigQuery( return nil, fmt.Errorf("failed to load yaml config from config path provider: %w", err) } - compileRequest := CompileConfigRequest{ - ConfigYaml: configString, - Options: Options{ - OwnerID: orgID, - PipelineValues: values, - PipelineParameters: params, - }, - } - - req, err := c.compileRestClient.NewRequest( - "POST", - &url.URL{ - Path: "compile-config-with-defaults", - }, - compileRequest, - ) - if err != nil { - return nil, fmt.Errorf("an error occurred creating the request: %w", err) - } - - configCompilationResp := &ConfigResponse{} - statusCode, originalErr := c.compileRestClient.DoRequest(req, configCompilationResp) - if statusCode == 404 { - fmt.Fprintf(os.Stderr, "You are using a old version of CircleCI Server, please consider updating\n") - legacyResponse, err := c.legacyConfigQueryByOrgID(configString, orgID, params, values, c.cfg) - if err != nil { - return nil, err - } - return legacyResponse, nil - } - if originalErr != nil { - return nil, fmt.Errorf("config compilation request returned an error: %w", originalErr) - } - - if statusCode != 200 { - return nil, errors.New("unable to validate or compile config") - } - - if len(configCompilationResp.Errors) > 0 { - return nil, fmt.Errorf("config compilation contains errors: %s", configCompilationResp.Errors) - } - - return configCompilationResp, nil + return c.apiClient.CompileConfig(configString, orgID, params, values) } func loadYaml(path string) (string, error) { diff --git a/config/config_test.go b/config/config_test.go index 84af6c2cc..21a2b30f9 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -8,37 +8,25 @@ import ( "net/http/httptest" "testing" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" + "github.com/CircleCI-Public/circleci-cli/api/rest" "github.com/CircleCI-Public/circleci-cli/settings" "github.com/stretchr/testify/assert" ) -func TestCompiler(t *testing.T) { - t.Run("test compiler setup", func(t *testing.T) { - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`) - })) - defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) - - t.Run("assert compiler has correct host", func(t *testing.T) { - assert.Equal(t, "http://"+compiler.compileRestClient.BaseURL.Host, svr.URL) - }) - - t.Run("assert compiler has default api host", func(t *testing.T) { - newCompiler := New(&settings.Config{Host: defaultHost, HTTPClient: http.DefaultClient}) - assert.Equal(t, "https://"+newCompiler.compileRestClient.BaseURL.Host, defaultAPIHost) - }) - - t.Run("tests that we correctly get the config api host when the host is not the default one", func(t *testing.T) { +func TestConfig(t *testing.T) { + t.Run("test getCompileHost", func(t *testing.T) { + t.Run("for Server instances", func(t *testing.T) { // if the host isn't equal to `https://circleci.com` then this is likely a server instance and // wont have the api.X.com subdomain so we should instead just respect the host for config commands host := GetCompileHost("test") assert.Equal(t, host, "test") + }) + t.Run("for CircleCI servers", func(t *testing.T) { // If the host passed in is the same as the defaultHost 'https://circleci.com' - then we know this is cloud // and as such should use the `api.circleci.com` subdomain - host = GetCompileHost("https://circleci.com") + host := GetCompileHost("https://circleci.com") assert.Equal(t, host, "https://api.circleci.com") }) }) @@ -50,7 +38,11 @@ func TestCompiler(t *testing.T) { fmt.Fprintf(w, `{"valid":true,"source-yaml":"source","output-yaml":"output","errors":[]}`) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) result, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) assert.NoError(t, err) @@ -65,9 +57,13 @@ func TestCompiler(t *testing.T) { fmt.Fprintf(w, `{"valid":true,"source-yaml":"source","output-yaml":"output","errors":[]}`) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) - _, err := compiler.ConfigQuery("testdata/nonexistent.yml", "1234", Parameters{}, Values{}) + _, err = compiler.ConfigQuery("testdata/nonexistent.yml", "1234", Parameters{}, Values{}) assert.Error(t, err) assert.Contains(t, err.Error(), "Could not load config file at testdata/nonexistent.yml") }) @@ -90,9 +86,13 @@ func TestCompiler(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) - _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) + _, err = compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) assert.Error(t, err) assert.Contains(t, err.Error(), "config compilation request returned an error") }) @@ -111,7 +111,11 @@ func TestCompiler(t *testing.T) { fmt.Fprintf(w, `{"valid":true,"source-yaml":"source","output-yaml":"output","errors":[]}`) })) defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) + cfg := &settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient} + apiClient := &v2APIClient{rest.NewFromConfig(cfg.Host, cfg)} + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) resp, err := compiler.ConfigQuery("testdata/test.yml", "1234", Parameters{}, Values{}) assert.NoError(t, err) diff --git a/config/legacy.go b/config/v1_api_client.go similarity index 88% rename from config/legacy.go rename to config/v1_api_client.go index 02581bb74..a65e9a134 100644 --- a/config/legacy.go +++ b/config/v1_api_client.go @@ -4,13 +4,17 @@ import ( "encoding/json" "fmt" "sort" - "strings" "github.com/CircleCI-Public/circleci-cli/api/graphql" - "github.com/CircleCI-Public/circleci-cli/settings" "github.com/pkg/errors" ) +const v1_string apiClientVersion = "v1" + +type v1APIClient struct { + gql *graphql.Client +} + // GQLErrorsCollection is a slice of errors returned by the GraphQL server. // Each error is made up of a GQLResponseError type. type GQLErrorsCollection []GQLResponseError @@ -24,13 +28,13 @@ type BuildConfigResponse struct { // Error turns a GQLErrorsCollection into an acceptable error string that can be printed to the user. func (errs GQLErrorsCollection) Error() string { - messages := []string{} + message := "config compilation contains errors:" for i := range errs { - messages = append(messages, errs[i].Message) + message += fmt.Sprintf("\n\t- %s", errs[i].Message) } - return strings.Join(messages, "\n") + return message } // LegacyConfigResponse is a structure that matches the result of the GQL @@ -54,6 +58,12 @@ type GQLResponseError struct { Type string } +// KeyVal is a data structure specifically for passing pipeline data to GraphQL which doesn't support free-form maps. +type KeyVal struct { + Key string `json:"key"` + Val interface{} `json:"val"` +} + // PrepareForGraphQL takes a golang homogenous map, and transforms it into a list of keyval pairs, since GraphQL does not support homogenous maps. func PrepareForGraphQL(kvMap Values) []KeyVal { // we need to create the slice of KeyVals in a deterministic order for testing purposes @@ -70,13 +80,7 @@ func PrepareForGraphQL(kvMap Values) []KeyVal { return kvs } -func (c *ConfigCompiler) legacyConfigQueryByOrgID( - configString string, - orgID string, - params Parameters, - values Values, - cfg *settings.Config, -) (*ConfigResponse, error) { +func (client *v1APIClient) CompileConfig(configContent string, orgID string, params Parameters, values Values) (*ConfigResponse, error) { var response BuildConfigResponse // GraphQL isn't forwards-compatible, so we are unusually selective here about // passing only non-empty fields on to the API, to minimize user impact if the @@ -101,8 +105,8 @@ func (c *ConfigCompiler) legacyConfigQueryByOrgID( ) request := graphql.NewRequest(query) - request.SetToken(cfg.Token) - request.Var("config", configString) + request.SetToken(client.gql.Token) + request.Var("config", configContent) if values != nil { request.Var("pipelineValues", PrepareForGraphQL(values)) @@ -119,7 +123,7 @@ func (c *ConfigCompiler) legacyConfigQueryByOrgID( request.Var("orgId", orgID) } - err := c.legacyGraphQLClient.Run(request, &response) + err := client.gql.Run(request, &response) if err != nil { return nil, errors.Wrap(err, "Unable to validate config") } @@ -133,9 +137,3 @@ func (c *ConfigCompiler) legacyConfigQueryByOrgID( OutputYaml: response.BuildConfig.LegacyConfigResponse.OutputYaml, }, nil } - -// KeyVal is a data structure specifically for passing pipeline data to GraphQL which doesn't support free-form maps. -type KeyVal struct { - Key string `json:"key"` - Val interface{} `json:"val"` -} diff --git a/config/legacy_test.go b/config/v1_api_client_test.go similarity index 74% rename from config/legacy_test.go rename to config/v1_api_client_test.go index 5175a136f..64a78c8fe 100644 --- a/config/legacy_test.go +++ b/config/v1_api_client_test.go @@ -2,15 +2,17 @@ package config import ( "fmt" + "io" "net/http" "net/http/httptest" "testing" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/settings" "github.com/stretchr/testify/assert" ) -func TestLegacyFlow(t *testing.T) { +func TestAPIV1Flow(t *testing.T) { t.Run("tests that the compiler defaults to the graphQL resolver should the original API request fail with 404", func(t *testing.T) { mux := http.NewServeMux() @@ -31,12 +33,17 @@ func TestLegacyFlow(t *testing.T) { svr := httptest.NewServer(mux) defer svr.Close() - compiler := New(&settings.Config{ + cfg := &settings.Config{ Host: svr.URL, Endpoint: "/graphql-unstable", HTTPClient: http.DefaultClient, Token: "", - }) + } + apiClient, err := newAPIClient(cfg) + assert.NoError(t, err) + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) resp, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) assert.Equal(t, true, resp.Valid) @@ -63,13 +70,18 @@ func TestLegacyFlow(t *testing.T) { svr := httptest.NewServer(mux) defer svr.Close() - compiler := New(&settings.Config{ + cfg := &settings.Config{ Host: svr.URL, Endpoint: "/graphql-unstable", HTTPClient: http.DefaultClient, Token: "", - }) - _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) + } + apiClient, err := newAPIClient(cfg) + assert.NoError(t, err) + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) + _, err = compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to validate") }) @@ -79,8 +91,14 @@ func TestLegacyFlow(t *testing.T) { gqlHitCounter := 0 mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + if len(body) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusInternalServerError) }) mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) { @@ -97,13 +115,18 @@ func TestLegacyFlow(t *testing.T) { svr := httptest.NewServer(mux) defer svr.Close() - compiler := New(&settings.Config{ + cfg := &settings.Config{ Host: svr.URL, Endpoint: "/graphql-unstable", HTTPClient: http.DefaultClient, Token: "", - }) - _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) + } + apiClient, err := newAPIClient(cfg) + assert.NoError(t, err) + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + assert.NoError(t, err) + compiler := New(apiClient, collaboratorsClient) + _, err = compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{}) assert.Error(t, err) assert.Contains(t, err.Error(), "config compilation request returned an error:") assert.Equal(t, 0, gqlHitCounter) diff --git a/config/v2_api_client.go b/config/v2_api_client.go new file mode 100644 index 000000000..2ae9a3152 --- /dev/null +++ b/config/v2_api_client.go @@ -0,0 +1,60 @@ +package config + +import ( + "fmt" + "net/url" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/pkg/errors" +) + +const v2_string apiClientVersion = "v2" + +type v2APIClient struct { + restClient *rest.Client +} + +func configErrorsAsError(configErrors []ConfigError) error { + message := "config compilation contains errors:" + for _, err := range configErrors { + message += fmt.Sprintf("\n\t- %s", err.Message) + } + return errors.New(message) +} + +func (client *v2APIClient) CompileConfig(configContent string, orgID string, params Parameters, values Values) (*ConfigResponse, error) { + compileRequest := CompileConfigRequest{ + ConfigYaml: configContent, + Options: Options{ + OwnerID: orgID, + PipelineValues: values, + PipelineParameters: params, + }, + } + + req, err := client.restClient.NewRequest( + "POST", + &url.URL{Path: compilePath}, + compileRequest, + ) + if err != nil { + return nil, fmt.Errorf("an error occurred creating the request: %w", err) + } + + configCompilationResp := &ConfigResponse{} + statusCode, originalErr := client.restClient.DoRequest(req, configCompilationResp) + + if originalErr != nil { + return nil, fmt.Errorf("config compilation request returned an error: %w", originalErr) + } + + if statusCode != 200 { + return nil, errors.New("unable to validate or compile config") + } + + if len(configCompilationResp.Errors) > 0 { + return nil, fmt.Errorf("config compilation contains errors: %s", configErrorsAsError(configCompilationResp.Errors)) + } + + return configCompilationResp, nil +} diff --git a/config/v2_api_client_test.go b/config/v2_api_client_test.go new file mode 100644 index 000000000..d5ebec6f2 --- /dev/null +++ b/config/v2_api_client_test.go @@ -0,0 +1,17 @@ +package config + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestConfigErrorsAsError(t *testing.T) { + err := configErrorsAsError([]ConfigError{ + {Message: "error on line 1"}, + {Message: "error on line 42"}, + }) + assert.Error(t, err, `config compilation contains errors: + - error on line 1 + - error on line 42`) +} diff --git a/local/local.go b/local/local.go index 39272c91a..4aa60ae72 100644 --- a/local/local.go +++ b/local/local.go @@ -26,7 +26,10 @@ func Execute(flags *pflag.FlagSet, cfg *settings.Config, args []string) error { var configResponse *config.ConfigResponse processedArgs, configPath := buildAgentArguments(flags) - compiler := config.New(cfg) + compiler, err := config.NewWithConfig(cfg) + if err != nil { + return err + } //if no orgId provided use org slug orgID, _ := flags.GetString("org-id")