├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── go.yml │ └── codeql-analysis.yml ├── go.mod ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── go.sum ├── examples ├── local │ └── main.go ├── basic │ └── main.go ├── cloudfoundry │ └── main.go └── oauth │ └── main.go ├── CODE_OF_CONDUCT.md ├── resource.go ├── http.go ├── client.go ├── resource_test.go ├── configuration.go ├── client_test.go ├── README.md ├── http_test.go └── configuration_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Piszmog 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Issue Description 2 | 3 | 4 | ### Steps to Reproduce 5 | 6 | 7 | ### Expected Behavior 8 | 9 | 10 | ### Acceptance Criteria 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Changes Description 2 | 3 | 4 | ### Associated Issues 5 | 6 | 7 | ### Affected Files 8 | 9 | 10 | ### Unit Tests Changed or Added 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Piszmog/cloudconfigclient/v2 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Piszmog/cfservices v1.5.0 9 | github.com/stretchr/testify v1.11.1 10 | golang.org/x/oauth2 v0.33.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | /vendor 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: gomod 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The following are the guidelines for contributing to the project. 4 | 5 | ## Formatting 6 | 7 | * Tabs over spaces. 8 | * Use `go fmt` to format the code 9 | 10 | ### Imports 11 | 12 | * GoLand defaults are used. 13 | * `goimport` is used for package sorting. 14 | * No package grouping is used. 15 | 16 | ## Documentation 17 | 18 | * All exported functions and types are documented appropriately. 19 | 20 | ## Tests 21 | 22 | * Every exported function needs to have a Unit Test 23 | * All possible error cases are tested -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Piszmog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Piszmog/cfservices v1.5.0 h1:5R4PjvjBfe+tA/YiX/lcjawHVsZe9JlFg4IgvOF1iE0= 2 | github.com/Piszmog/cfservices v1.5.0/go.mod h1:z1XBAlX6a+ce3Yg5QhJvdSbKgeyBjzNvq1pTkRRXXLc= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 8 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 9 | golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= 10 | golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v6 14 | with: 15 | go-version-file: 'go.mod' 16 | - run: go mod download 17 | - name: Lint 18 | uses: golangci/golangci-lint-action@v8 19 | with: 20 | version: v2.1 21 | args: --tests=false 22 | test: 23 | name: Test 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-go@v6 28 | with: 29 | go-version-file: 'go.mod' 30 | - run: go mod download 31 | - name: Test and Calculate Coverage 32 | run: go test -v -covermode=count -coverprofile=coverage.out 33 | - name: Convert coverage to lcov 34 | uses: jandelgado/gcov2lcov-action@v1.1.1 35 | with: 36 | infile: coverage.out 37 | outfile: coverage.lcov 38 | - name: Coveralls 39 | uses: coverallsapp/github-action@v2.3.6 40 | with: 41 | github-token: ${{ secrets.github_token }} 42 | path-to-lcov: coverage.lcov 43 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '0 0 1 * *' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v4 36 | 37 | # Initializes the CodeQL tools for scanning. 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@v3 40 | with: 41 | languages: ${{ matrix.language }} 42 | 43 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 44 | # If this step fails, then you should remove it and run the build manually (see below) 45 | - name: Autobuild 46 | uses: github/codeql-action/autobuild@v3 47 | 48 | # ℹ️ Command-line programs to run using the OS shell. 49 | # 📚 https://git.io/JvXDl 50 | 51 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 52 | # and modify them (or add more) to build your code if your project 53 | # uses a compiled language 54 | 55 | #- run: | 56 | # make bootstrap 57 | # make release 58 | 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v3 61 | -------------------------------------------------------------------------------- /examples/local/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/Piszmog/cloudconfigclient/v2" 9 | ) 10 | 11 | func main() { 12 | // ensure you have the Config Server running locally... 13 | client, err := cloudconfigclient.New(cloudconfigclient.Local(&http.Client{}, "http://localhost:8888")) 14 | if err != nil { 15 | log.Fatalln(err) 16 | } 17 | 18 | // load a config file 19 | configuration, err := client.GetConfiguration("test-app", "local") 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | // we can either unmarshal into a struct 24 | var configData configStruct 25 | if err = configuration.Unmarshal(&configData); err != nil { 26 | log.Fatalln("failed to find cloud property file") 27 | } 28 | 29 | // or manually access the values 30 | localProp, err := configuration.GetPropertySource("application-local.yml") 31 | if err != nil { 32 | log.Fatalln("failed to find local property file") 33 | } 34 | fmt.Printf("local property file: %+v\n", localProp) 35 | // handle all config properties 36 | configuration.HandlePropertySources(func(propertySource cloudconfigclient.PropertySource) { 37 | //if strings.HasSuffix(propertySource.Name, "test-app.properties") { 38 | // TODO save off values 39 | //} 40 | }) 41 | 42 | // load a specific file (e.g. json/txt) 43 | var f map[string]string 44 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 45 | if err = client.GetFile("fooDir", "bar.json", &f); err != nil { 46 | log.Fatalln(err) 47 | } 48 | fmt.Printf("file from default branch: %+v\n", f) 49 | 50 | // load a specific file (e.g. json/txt) 51 | var b map[string]string 52 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 53 | if err = client.GetFileFromBranch("develop", "fooDir", "bar.json", &b); err != nil { 54 | log.Fatalln(err) 55 | } 56 | fmt.Printf("file from specific branch: %+v\n", b) 57 | } 58 | 59 | type configStruct struct { 60 | Field1 string `json:"field1"` 61 | } 62 | -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/Piszmog/cloudconfigclient/v2" 9 | ) 10 | 11 | func main() { 12 | // ensure you have the Config Server running locally... 13 | client, err := cloudconfigclient.New(cloudconfigclient.Basic(&http.Client{}, "username", "password", "http://localhost:8888")) 14 | if err != nil { 15 | log.Fatalln(err) 16 | } 17 | 18 | // load a config file 19 | configuration, err := client.GetConfiguration("test-app", "local") 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | // we can either unmarshal into a struct 24 | var configData configStruct 25 | if err = configuration.Unmarshal(&configData); err != nil { 26 | log.Fatalln("failed to find cloud property file") 27 | } 28 | 29 | // or manually access the values 30 | localProp, err := configuration.GetPropertySource("application-local.yml") 31 | if err != nil { 32 | log.Fatalln("failed to find local property file") 33 | } 34 | fmt.Printf("local property file: %+v\n", localProp) 35 | // handle all config properties 36 | configuration.HandlePropertySources(func(propertySource cloudconfigclient.PropertySource) { 37 | //if strings.HasSuffix(propertySource.Name, "test-app.properties") { 38 | // TODO save off values 39 | //} 40 | }) 41 | 42 | // load a specific file (e.g. json/txt) 43 | var f map[string]string 44 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 45 | if err = client.GetFile("fooDir", "bar.json", &f); err != nil { 46 | log.Fatalln(err) 47 | } 48 | fmt.Printf("file from default branch: %+v\n", f) 49 | 50 | // load a specific file (e.g. json/txt) 51 | var b map[string]string 52 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 53 | if err = client.GetFileFromBranch("develop", "fooDir", "bar.json", &b); err != nil { 54 | log.Fatalln(err) 55 | } 56 | fmt.Printf("file from specific branch: %+v\n", b) 57 | } 58 | 59 | type configStruct struct { 60 | Field1 string `json:"field1"` 61 | } 62 | -------------------------------------------------------------------------------- /examples/cloudfoundry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/Piszmog/cloudconfigclient/v2" 8 | ) 9 | 10 | func main() { 11 | // ensure you have set the environment variable 'VCAP_SERVICES' that contains the information to connect to the 12 | // Config Server 13 | client, err := cloudconfigclient.New(cloudconfigclient.DefaultCFService()) 14 | if err != nil { 15 | log.Fatalln(err) 16 | } 17 | 18 | // load a config file 19 | configuration, err := client.GetConfiguration("test-app", "cloud") 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | // we can either unmarshal into a struct 24 | var configData configStruct 25 | if err = configuration.Unmarshal(&configData); err != nil { 26 | log.Fatalln("failed to find cloud property file") 27 | } 28 | 29 | // or manually access the values 30 | localProp, err := configuration.GetPropertySource("application-cloud.yml") 31 | if err != nil { 32 | log.Fatalln("failed to find cloud property file") 33 | } 34 | fmt.Printf("cloud property file: %+v\n", localProp) 35 | // handle all config properties 36 | configuration.HandlePropertySources(func(propertySource cloudconfigclient.PropertySource) { 37 | //if strings.HasSuffix(propertySource.Name, "test-app.properties") { 38 | // TODO save off values 39 | //} 40 | }) 41 | 42 | // load a specific file (e.g. json/txt) 43 | var f map[string]string 44 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 45 | if err = client.GetFile("fooDir", "bar.json", &f); err != nil { 46 | log.Fatalln(err) 47 | } 48 | fmt.Printf("file from default branch: %+v\n", f) 49 | 50 | // load a specific file (e.g. json/txt) 51 | var b map[string]string 52 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 53 | if err = client.GetFileFromBranch("develop", "fooDir", "bar.json", &b); err != nil { 54 | log.Fatalln(err) 55 | } 56 | fmt.Printf("file from specific branch: %+v\n", b) 57 | } 58 | 59 | type configStruct struct { 60 | Field1 string `json:"field1"` 61 | } 62 | -------------------------------------------------------------------------------- /examples/oauth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/Piszmog/cloudconfigclient/v2" 8 | ) 9 | 10 | func main() { 11 | // ensure you have the Config Server running locally (or in the cloud) and configured for OAuth2 12 | client, err := cloudconfigclient.New(cloudconfigclient.OAuth2("config server uri", "client id", 13 | "client secret", "access token uri")) 14 | if err != nil { 15 | log.Fatalln(err) 16 | } 17 | 18 | // load a config file 19 | configuration, err := client.GetConfiguration("test-app", "oauth") 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | // we can either unmarshal into a struct 24 | var configData configStruct 25 | if err = configuration.Unmarshal(&configData); err != nil { 26 | log.Fatalln("failed to find cloud property file") 27 | } 28 | 29 | // or manually access the values 30 | localProp, err := configuration.GetPropertySource("application-oauth.yml") 31 | if err != nil { 32 | log.Fatalln("failed to find oauth property file") 33 | } 34 | fmt.Printf("oauth property file: %+v\n", localProp) 35 | // handle all config properties 36 | configuration.HandlePropertySources(func(propertySource cloudconfigclient.PropertySource) { 37 | //if strings.HasSuffix(propertySource.Name, "test-app.properties") { 38 | // TODO save off values 39 | //} 40 | }) 41 | 42 | // load a specific file (e.g. json/txt) 43 | var f map[string]string 44 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 45 | if err = client.GetFile("fooDir", "bar.json", &f); err != nil { 46 | log.Fatalln(err) 47 | } 48 | fmt.Printf("file from default branch: %+v\n", f) 49 | 50 | // load a specific file (e.g. json/txt) 51 | var b map[string]string 52 | // if 'fooDir' has been added to 'searchPaths' in SCS v3.x, then pass "" (blank) for directory 53 | if err = client.GetFileFromBranch("develop", "fooDir", "bar.json", &b); err != nil { 54 | log.Fatalln(err) 55 | } 56 | fmt.Printf("file from specific branch: %+v\n", b) 57 | } 58 | 59 | type configStruct struct { 60 | Field1 string `json:"field1"` 61 | } 62 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at piszmogcode@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /resource.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | const ( 8 | defaultApplicationName = "default" 9 | defaultApplicationProfile = "default" 10 | ) 11 | 12 | var useDefaultLabel = map[string]string{"useDefaultLabel": "true"} 13 | 14 | // Resource interface describes how to retrieve files from the Config Server. 15 | type Resource interface { 16 | // GetFile retrieves the specified file from the provided directory from the Config Server's default branch. 17 | // 18 | // The file will be deserialized into the specified interface type. 19 | GetFile(directory string, file string, interfaceType any) error 20 | // GetFileFromBranch retrieves the specified file from the provided branch in the provided directory. 21 | // 22 | // The file will be deserialized into the specified interface type. 23 | GetFileFromBranch(branch string, directory string, file string, interfaceType any) error 24 | // GetFileRaw retrieves the file from the default branch as a byte slice. 25 | GetFileRaw(directory string, file string) ([]byte, error) 26 | // GetFileFromBranchRaw retrieves the file from the specified branch as a byte slice. 27 | GetFileFromBranchRaw(branch string, directory string, file string) ([]byte, error) 28 | } 29 | 30 | // GetFile retrieves the specified file from the provided directory from the Config Server's default branch. 31 | // 32 | // The file will be deserialized into the specified interface type. 33 | func (c *Client) GetFile(directory string, file string, interfaceType any) error { 34 | return c.getFile([]string{defaultApplicationName, defaultApplicationProfile, directory, file}, useDefaultLabel, interfaceType) 35 | } 36 | 37 | // GetFileFromBranch retrieves the specified file from the provided branch in the provided directory. 38 | // 39 | // The file will be deserialized into the specified interface type. 40 | func (c *Client) GetFileFromBranch(branch string, directory string, file string, interfaceType any) error { 41 | return c.getFile([]string{defaultApplicationName, defaultApplicationProfile, branch, directory, file}, nil, interfaceType) 42 | } 43 | 44 | func (c *Client) getFile(paths []string, params map[string]string, interfaceType any) error { 45 | fileFound := false 46 | for _, client := range c.clients { 47 | if err := client.GetResource(paths, params, interfaceType); err != nil { 48 | if errors.Is(err, ErrResourceNotFound) { 49 | continue 50 | } 51 | return err 52 | } 53 | fileFound = true 54 | } 55 | if !fileFound { 56 | return errors.New("failed to find file in the Config Server") 57 | } 58 | return nil 59 | } 60 | 61 | // GetFileRaw retrieves the file from the default branch as a byte slice. 62 | func (c *Client) GetFileRaw(directory string, file string) ([]byte, error) { 63 | return c.getFileRaw([]string{defaultApplicationName, defaultApplicationProfile, directory, file}, useDefaultLabel) 64 | } 65 | 66 | // GetFileFromBranchRaw retrieves the file from the specified branch as a byte slice. 67 | func (c *Client) GetFileFromBranchRaw(branch string, directory string, file string) ([]byte, error) { 68 | return c.getFileRaw([]string{defaultApplicationName, defaultApplicationProfile, branch, directory, file}, nil) 69 | } 70 | 71 | func (c *Client) getFileRaw(paths []string, params map[string]string) (b []byte, err error) { 72 | fileFound := false 73 | for _, client := range c.clients { 74 | b, err = client.GetResourceRaw(paths, params) 75 | if err != nil { 76 | if errors.Is(err, ErrResourceNotFound) { 77 | continue 78 | } 79 | return 80 | } 81 | fileFound = true 82 | } 83 | if !fileFound { 84 | err = errors.New("failed to find file in the Config Server") 85 | } 86 | return 87 | } 88 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "path" 12 | "strings" 13 | 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | // HTTPClient is a wrapper for http.Client. 18 | type HTTPClient struct { 19 | *http.Client 20 | // BaseURL is the base URL for the Config Server. 21 | BaseURL string 22 | // Authorization is the authorization header value for the Config Server. If not provided, no authorization header is not explicitly set. 23 | // If the client is using OAuth2, the authorization header is set automatically. 24 | Authorization string 25 | } 26 | 27 | // ErrResourceNotFound is a special error that is used to propagate 404s. 28 | var ErrResourceNotFound = errors.New("failed to find resource") 29 | 30 | const ( 31 | failedToDecodeMessage = "failed to decode response from url: %w" 32 | ) 33 | 34 | // GetResource performs a http.MethodGet operation. Builds the URL based on the provided paths and params. Deserializes 35 | // the response to the specified destination. 36 | // 37 | // Capable of unmarshalling YAML, JSON, and XML. If file type is of another type, use GetResourceRaw instead. 38 | func (h *HTTPClient) GetResource(paths []string, params map[string]string, dest any) (err error) { 39 | if len(paths) == 0 { 40 | return errors.New("no resource specified to be retrieved") 41 | } 42 | resp, err := h.Get(paths, params) 43 | if err != nil { 44 | return err 45 | } 46 | defer func() { 47 | if cerr := resp.Body.Close(); cerr != nil { 48 | err = errors.Join(err, fmt.Errorf("failed to close body: %w", cerr)) 49 | } 50 | }() 51 | if resp.StatusCode == http.StatusNotFound { 52 | return ErrResourceNotFound 53 | } 54 | if resp.StatusCode != http.StatusOK { 55 | var b []byte 56 | b, err = io.ReadAll(resp.Body) 57 | if err != nil { 58 | return fmt.Errorf("failed to read body with status code '%d': %w", resp.StatusCode, err) 59 | } 60 | return fmt.Errorf("server responded with status code '%d' and body '%s'", resp.StatusCode, b) 61 | } 62 | if err = decodeResponseBody(paths[len(paths)-1], resp, dest); err != nil { 63 | return err 64 | } 65 | return nil 66 | } 67 | 68 | func decodeResponseBody(file string, resp *http.Response, dest any) error { 69 | if strings.Contains(file, ".yml") || strings.Contains(file, ".yaml") { 70 | if err := yaml.NewDecoder(resp.Body).Decode(dest); err != nil { 71 | return fmt.Errorf(failedToDecodeMessage, err) 72 | } 73 | } else if strings.Contains(file, ".xml") { 74 | if err := xml.NewDecoder(resp.Body).Decode(dest); err != nil { 75 | return fmt.Errorf(failedToDecodeMessage, err) 76 | } 77 | } else { 78 | if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { 79 | return fmt.Errorf(failedToDecodeMessage, err) 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | // GetResourceRaw performs a http.MethodGet operation. Builds the URL based on the provided paths and params. Returns 86 | // the byte slice response. 87 | func (h *HTTPClient) GetResourceRaw(paths []string, params map[string]string) (b []byte, err error) { 88 | if len(paths) == 0 { 89 | return nil, errors.New("no resource specified to be retrieved") 90 | } 91 | resp, err := h.Get(paths, params) 92 | if err != nil { 93 | return nil, err 94 | } 95 | defer func() { 96 | if cerr := resp.Body.Close(); cerr != nil { 97 | err = errors.Join(err, fmt.Errorf("failed to close body: %w", cerr)) 98 | } 99 | }() 100 | if resp.StatusCode == http.StatusNotFound { 101 | return nil, ErrResourceNotFound 102 | } 103 | b, err = io.ReadAll(resp.Body) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to read body with status code '%d': %w", resp.StatusCode, err) 106 | } 107 | if resp.StatusCode != http.StatusOK { 108 | return nil, fmt.Errorf("server responded with status code '%d' and body '%s'", resp.StatusCode, b) 109 | } 110 | return b, nil 111 | } 112 | 113 | // Get performs a http.MethodGet operation. Builds the URL based on the provided paths and params. 114 | func (h *HTTPClient) Get(paths []string, params map[string]string) (*http.Response, error) { 115 | fullURL, err := newURL(h.BaseURL, paths, params) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to create url: %w", err) 118 | } 119 | req, err := http.NewRequest(http.MethodGet, fullURL, nil) 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to create request for %s: %w", fullURL, err) 122 | } 123 | if h.Authorization != "" { 124 | req.Header.Set("Authorization", h.Authorization) 125 | } 126 | response, err := h.Do(req) 127 | if err != nil { 128 | return nil, fmt.Errorf("failed to retrieve from %s: %w", fullURL, err) 129 | } 130 | return response, nil 131 | } 132 | 133 | func newURL(baseURL string, paths []string, params map[string]string) (string, error) { 134 | parseURL, err := url.Parse(baseURL) 135 | if err != nil { 136 | return "", fmt.Errorf("failed to parse url %s: %w", baseURL, err) 137 | } 138 | if len(paths) > 0 { 139 | for _, p := range paths { 140 | parseURL.Path = path.Join(parseURL.Path, p) 141 | } 142 | } 143 | if params != nil { 144 | query := parseURL.Query() 145 | for key, value := range params { 146 | query.Set(key, value) 147 | } 148 | parseURL.RawQuery = query.Encode() 149 | } 150 | return parseURL.String(), nil 151 | } 152 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "strings" 11 | 12 | "github.com/Piszmog/cfservices" 13 | "golang.org/x/oauth2/clientcredentials" 14 | ) 15 | 16 | const ( 17 | // ConfigServerName the service name of the Config Server in PCF. 18 | ConfigServerName = "p-config-server" 19 | // EnvironmentLocalConfigServerUrls is an environment variable for setting base URLs for local Config Servers. 20 | EnvironmentLocalConfigServerUrls = "CONFIG_SERVER_URLS" 21 | // SpringCloudConfigServerName the service name of the Spring Cloud Config Server in PCF. 22 | SpringCloudConfigServerName = "p.config-server" 23 | ) 24 | 25 | // Client contains the clients of the Config Servers. 26 | type Client struct { 27 | clients []*HTTPClient 28 | } 29 | 30 | // New creates a new Client based on the provided options. A Client can be configured to communicate with 31 | // a local Config Server, an OAuth2 Server, and Config Servers in Cloud Foundry. 32 | // 33 | // At least one option must be provided. 34 | func New(options ...Option) (*Client, error) { 35 | var clients []*HTTPClient 36 | if len(options) == 0 { 37 | return nil, errors.New("at least one option must be provided") 38 | } 39 | for _, option := range options { 40 | if err := option(&clients); err != nil { 41 | return nil, err 42 | } 43 | } 44 | return &Client{clients: clients}, nil 45 | } 46 | 47 | // Option creates a slice of httpClients per Config Server instance. 48 | type Option func(*[]*HTTPClient) error 49 | 50 | // LocalEnv creates a clients for a locally running Config Servers. The URLs to the Config Servers are acquired from the 51 | // environment variable 'CONFIG_SERVER_URLS'. 52 | func LocalEnv(client *http.Client) Option { 53 | return func(clients *[]*HTTPClient) error { 54 | httpClients, err := newLocalClientFromEnv(client) 55 | if err != nil { 56 | return err 57 | } 58 | *clients = append(*clients, httpClients...) 59 | return nil 60 | } 61 | } 62 | 63 | func newLocalClientFromEnv(client *http.Client) ([]*HTTPClient, error) { 64 | localUrls := os.Getenv(EnvironmentLocalConfigServerUrls) 65 | if len(localUrls) == 0 { 66 | return nil, fmt.Errorf("no local Config Server URLs provided in environment variable %s", EnvironmentLocalConfigServerUrls) 67 | } 68 | return newSimpleClient(client, "", strings.Split(localUrls, ",")), nil 69 | } 70 | 71 | // Local creates a clients for a locally running Config Servers. 72 | func Local(client *http.Client, urls ...string) Option { 73 | return func(clients *[]*HTTPClient) error { 74 | *clients = append(*clients, newSimpleClient(client, "", urls)...) 75 | return nil 76 | } 77 | } 78 | 79 | // Basic creates a clients for a Config Server based on the provided basic authentication information. 80 | func Basic(client *http.Client, username, password string, urls ...string) Option { 81 | return func(clients *[]*HTTPClient) error { 82 | auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) 83 | *clients = append(*clients, newSimpleClient(client, auth, urls)...) 84 | return nil 85 | } 86 | } 87 | 88 | func newSimpleClient(client *http.Client, auth string, urls []string) []*HTTPClient { 89 | clients := make([]*HTTPClient, len(urls)) 90 | for index, baseURL := range urls { 91 | clients[index] = &HTTPClient{BaseURL: baseURL, Client: client, Authorization: auth} 92 | } 93 | return clients 94 | } 95 | 96 | // DefaultCFService creates a clients for each Config Servers the application is bounded to in Cloud Foundry. The 97 | // environment variable 'VCAP_SERVICES' provides a JSON that contains an entry with the key 'p-config-server' (v2.x) 98 | // or 'p.config-server' (v3.x). 99 | // 100 | // The service 'p.config-server' is search for first. If not found, 'p-config-server' is searched for. 101 | func DefaultCFService() Option { 102 | return func(clients *[]*HTTPClient) error { 103 | services, err := cfservices.GetServices() 104 | if err != nil { 105 | return fmt.Errorf("failed to parse 'VCAP_SERVICES': %w", err) 106 | } 107 | httpClients, err := newCloudClientForService(SpringCloudConfigServerName, services) 108 | if err != nil { 109 | if errors.Is(err, cfservices.MissingServiceError) { 110 | httpClients, err = newCloudClientForService(ConfigServerName, services) 111 | if err != nil { 112 | if errors.Is(err, cfservices.MissingServiceError) { 113 | return fmt.Errorf("neither %s or %s exist in environment variable 'VCAP_SERVICES'", 114 | ConfigServerName, SpringCloudConfigServerName) 115 | } 116 | return err 117 | } 118 | } else { 119 | return err 120 | } 121 | } 122 | *clients = append(*clients, httpClients...) 123 | return nil 124 | } 125 | } 126 | 127 | // CFService creates a clients for each Config Servers the application is bounded to in Cloud Foundry. The environment 128 | // variable 'VCAP_SERVICES' provides a JSON. The JSON should contain the entry matching the specified name. This 129 | // entry and used to build an OAuth Client. 130 | func CFService(service string) Option { 131 | return func(clients *[]*HTTPClient) error { 132 | services, err := cfservices.GetServices() 133 | if err != nil { 134 | return fmt.Errorf("failed to parse 'VCAP_SERVICES': %w", err) 135 | } 136 | httpClients, err := newCloudClientForService(service, services) 137 | if err != nil { 138 | return err 139 | } 140 | *clients = append(*clients, httpClients...) 141 | return nil 142 | } 143 | } 144 | 145 | func newCloudClientForService(name string, services map[string][]cfservices.Service) ([]*HTTPClient, error) { 146 | creds, err := cfservices.GetServiceCredentials(services, name) 147 | if err != nil { 148 | return nil, fmt.Errorf("failed to create cloud Client: %w", err) 149 | } 150 | clients := make([]*HTTPClient, len(creds.Credentials)) 151 | for i, cred := range creds.Credentials { 152 | clients[i] = &HTTPClient{BaseURL: cred.Uri, Client: newOAuth2Client(cred.ClientId, cred.ClientSecret, cred.AccessTokenUri)} 153 | } 154 | return clients, nil 155 | } 156 | 157 | // OAuth2 creates a Client for a Config Server based on the provided OAuth2.0 information. 158 | func OAuth2(baseURL string, clientID string, secret string, tokenURI string) Option { 159 | return func(clients *[]*HTTPClient) error { 160 | *clients = append(*clients, &HTTPClient{BaseURL: baseURL, Client: newOAuth2Client(clientID, secret, tokenURI)}) 161 | return nil 162 | } 163 | } 164 | 165 | func newOAuth2Client(clientID string, secret string, tokenURI string) *http.Client { 166 | config := newOAuth2Config(clientID, secret, tokenURI) 167 | return config.Client(context.Background()) 168 | } 169 | 170 | func newOAuth2Config(clientID string, secret string, tokenURI string) *clientcredentials.Config { 171 | return &clientcredentials.Config{ClientID: clientID, ClientSecret: secret, TokenURL: tokenURI} 172 | } 173 | -------------------------------------------------------------------------------- /resource_test.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient_test 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/Piszmog/cloudconfigclient/v2" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | const ( 13 | testJSONFile = `{ 14 | "example":{ 15 | "field":"value" 16 | } 17 | }` 18 | ) 19 | 20 | type file struct { 21 | Example example `json:"example"` 22 | } 23 | 24 | type example struct { 25 | Field string `json:"field"` 26 | } 27 | 28 | func TestClient_GetFile(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | checker func(*testing.T, *http.Request) 32 | response *http.Response 33 | expected file 34 | err error 35 | }{ 36 | { 37 | name: "JSON File", 38 | checker: func(t *testing.T, request *http.Request) { 39 | require.Equal(t, "http://localhost:8888/default/default/directory/file.json?useDefaultLabel=true", request.URL.String()) 40 | }, 41 | response: NewMockHttpResponse(http.StatusOK, testJSONFile), 42 | expected: file{Example: example{Field: "value"}}, 43 | }, 44 | { 45 | name: "Not Found", 46 | response: NewMockHttpResponse(http.StatusNotFound, ""), 47 | err: errors.New("failed to find file in the Config Server"), 48 | }, 49 | { 50 | name: "Server Error", 51 | response: NewMockHttpResponse(http.StatusInternalServerError, ""), 52 | err: errors.New("server responded with status code '500' and body ''"), 53 | }, 54 | } 55 | for _, test := range tests { 56 | t.Run(test.name, func(t *testing.T) { 57 | httpClient := NewMockHttpClient(func(req *http.Request) *http.Response { 58 | if test.checker != nil { 59 | test.checker(t, req) 60 | } 61 | return test.response 62 | }) 63 | client, err := cloudconfigclient.New(cloudconfigclient.Local(httpClient, "http://localhost:8888")) 64 | require.NoError(t, err) 65 | 66 | var actual file 67 | err = client.GetFile("directory", "file.json", &actual) 68 | if err != nil { 69 | require.Error(t, err) 70 | require.Equal(t, test.err.Error(), err.Error()) 71 | } else { 72 | require.NoError(t, err) 73 | require.Equal(t, test.expected, actual) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestClient_GetFileFromBranch(t *testing.T) { 80 | tests := []struct { 81 | name string 82 | checker func(*testing.T, *http.Request) 83 | response *http.Response 84 | expected file 85 | err error 86 | }{ 87 | { 88 | name: "JSON File", 89 | checker: func(t *testing.T, request *http.Request) { 90 | require.Equal(t, "http://localhost:8888/default/default/branch/directory/file.json", request.URL.String()) 91 | }, 92 | response: NewMockHttpResponse(http.StatusOK, testJSONFile), 93 | expected: file{Example: example{Field: "value"}}, 94 | }, 95 | { 96 | name: "Not Found", 97 | response: NewMockHttpResponse(http.StatusNotFound, ""), 98 | err: errors.New("failed to find file in the Config Server"), 99 | }, 100 | { 101 | name: "Server Error", 102 | response: NewMockHttpResponse(http.StatusInternalServerError, ""), 103 | err: errors.New("server responded with status code '500' and body ''"), 104 | }, 105 | } 106 | for _, test := range tests { 107 | t.Run(test.name, func(t *testing.T) { 108 | httpClient := NewMockHttpClient(func(req *http.Request) *http.Response { 109 | if test.checker != nil { 110 | test.checker(t, req) 111 | } 112 | return test.response 113 | }) 114 | client, err := cloudconfigclient.New(cloudconfigclient.Local(httpClient, "http://localhost:8888")) 115 | require.NoError(t, err) 116 | 117 | var actual file 118 | err = client.GetFileFromBranch("branch", "directory", "file.json", &actual) 119 | if err != nil { 120 | require.Error(t, err) 121 | require.Equal(t, test.err.Error(), err.Error()) 122 | } else { 123 | require.NoError(t, err) 124 | require.Equal(t, test.expected, actual) 125 | } 126 | }) 127 | } 128 | } 129 | 130 | func TestClient_GetFileRaw(t *testing.T) { 131 | tests := []struct { 132 | name string 133 | checker func(*testing.T, *http.Request) 134 | response *http.Response 135 | expected []byte 136 | err error 137 | }{ 138 | { 139 | name: "Text File", 140 | checker: func(t *testing.T, request *http.Request) { 141 | require.Equal(t, "http://localhost:8888/default/default/directory/file.txt?useDefaultLabel=true", request.URL.String()) 142 | }, 143 | response: NewMockHttpResponse(http.StatusOK, "hello world"), 144 | expected: []byte("hello world"), 145 | }, 146 | { 147 | name: "Not Found", 148 | response: NewMockHttpResponse(http.StatusNotFound, ""), 149 | err: errors.New("failed to find file in the Config Server"), 150 | }, 151 | { 152 | name: "Server Error", 153 | response: NewMockHttpResponse(http.StatusInternalServerError, ""), 154 | err: errors.New("server responded with status code '500' and body ''"), 155 | }, 156 | } 157 | for _, test := range tests { 158 | t.Run(test.name, func(t *testing.T) { 159 | httpClient := NewMockHttpClient(func(req *http.Request) *http.Response { 160 | if test.checker != nil { 161 | test.checker(t, req) 162 | } 163 | return test.response 164 | }) 165 | client, err := cloudconfigclient.New(cloudconfigclient.Local(httpClient, "http://localhost:8888")) 166 | require.NoError(t, err) 167 | 168 | actual, err := client.GetFileRaw("directory", "file.txt") 169 | if err != nil { 170 | require.Error(t, err) 171 | require.Equal(t, test.err.Error(), err.Error()) 172 | } else { 173 | require.NoError(t, err) 174 | require.Equal(t, test.expected, actual) 175 | } 176 | }) 177 | } 178 | } 179 | 180 | func TestClient_GetFileFromBranchRaw(t *testing.T) { 181 | tests := []struct { 182 | name string 183 | checker func(*testing.T, *http.Request) 184 | response *http.Response 185 | expected []byte 186 | err error 187 | }{ 188 | { 189 | name: "Text File", 190 | checker: func(t *testing.T, request *http.Request) { 191 | require.Equal(t, "http://localhost:8888/default/default/branch/directory/file.txt", request.URL.String()) 192 | }, 193 | response: NewMockHttpResponse(http.StatusOK, "hello world"), 194 | expected: []byte("hello world"), 195 | }, 196 | { 197 | name: "Not Found", 198 | response: NewMockHttpResponse(http.StatusNotFound, ""), 199 | err: errors.New("failed to find file in the Config Server"), 200 | }, 201 | { 202 | name: "Server Error", 203 | response: NewMockHttpResponse(http.StatusInternalServerError, ""), 204 | err: errors.New("server responded with status code '500' and body ''"), 205 | }, 206 | } 207 | for _, test := range tests { 208 | t.Run(test.name, func(t *testing.T) { 209 | httpClient := NewMockHttpClient(func(req *http.Request) *http.Response { 210 | if test.checker != nil { 211 | test.checker(t, req) 212 | } 213 | return test.response 214 | }) 215 | client, err := cloudconfigclient.New(cloudconfigclient.Local(httpClient, "http://localhost:8888")) 216 | require.NoError(t, err) 217 | 218 | actual, err := client.GetFileFromBranchRaw("branch", "directory", "file.txt") 219 | if err != nil { 220 | require.Error(t, err) 221 | require.Equal(t, test.err.Error(), err.Error()) 222 | } else { 223 | require.NoError(t, err) 224 | require.Equal(t, test.expected, actual) 225 | } 226 | }) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /configuration.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "path/filepath" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // Source is the application's source configurations. It con contain zero to n number of property sources. 14 | type Source struct { 15 | Name string `json:"name"` 16 | Profiles []string `json:"profiles"` 17 | Label string `json:"label"` 18 | Version string `json:"version"` 19 | State string `json:"state"` 20 | PropertySources []PropertySource `json:"propertySources"` 21 | } 22 | 23 | // GetPropertySource retrieves the PropertySource that has the specifies fileName. The fileName is the name of the file 24 | // with extension - e.g. application-foo.yml. 25 | // 26 | // Usually the Config Server will return the PropertySource.Name as an URL of sorts 27 | // (e.g. ssh://base-url.com/path/to/repository/path/to/file.[yml/properties]). So in order to find the specific file 28 | // with the desired configurations, the ending of the name needs to be matched against. 29 | func (s *Source) GetPropertySource(fileName string) (PropertySource, error) { 30 | for _, propertySource := range s.PropertySources { 31 | if strings.HasSuffix(propertySource.Name, fileName) { 32 | return propertySource, nil 33 | } 34 | } 35 | return PropertySource{}, ErrPropertySourceDoesNotExist 36 | } 37 | 38 | // ErrPropertySourceDoesNotExist is the error that is returned when there are no PropertySource that match the specified 39 | // file name. 40 | var ErrPropertySourceDoesNotExist = errors.New("property source does not exist") 41 | 42 | // PropertySourceHandler handles the specific PropertySource. 43 | type PropertySourceHandler func(propertySource PropertySource) 44 | 45 | // HandlePropertySources handles all PropertySource configurations that are files. This is a convenience method to 46 | // handle boilerplate for-loop code and filtering of non-configuration files. 47 | // 48 | // Config Server may return other configurations (e.g. credhub property sources) that contain no configurations 49 | // (PropertySource.Source is empty). 50 | func (s *Source) HandlePropertySources(handler PropertySourceHandler) { 51 | for _, propertySource := range s.PropertySources { 52 | if len(filepath.Ext(propertySource.Name)) > 0 { 53 | handler(propertySource) 54 | } 55 | } 56 | } 57 | 58 | // Unmarshal converts the Source.PropertySources to the specified type. The type must be a pointer to a struct. 59 | // 60 | // The provided pointer struct must use JSON tags to map to the PropertySource.Source. 61 | // 62 | // This function is not optimized (ugly) and is intended to only be used at startup. 63 | func (s *Source) Unmarshal(v any) error { 64 | // covert to a map[string]any so we can convert to the target type 65 | obj, err := toJSON(s.PropertySources) 66 | if err != nil { 67 | return err 68 | } 69 | // convert to bytes 70 | b, err := json.Marshal(obj) 71 | if err != nil { 72 | return err 73 | } 74 | // now we can get to our target type 75 | return json.Unmarshal(b, v) 76 | } 77 | 78 | var sliceRegex = regexp.MustCompile(`(.*)\[(\d+)]`) 79 | 80 | func toJSON(propertySources []PropertySource) (map[string]any, error) { 81 | // get ready for a wild ride... 82 | output := map[string]any{} 83 | // save the root, so we can get back there when we walk the tree 84 | root := output 85 | _ = root 86 | for _, propertySource := range propertySources { 87 | for k, v := range propertySource.Source { 88 | keys := strings.Split(k, ".") 89 | for i, key := range keys { 90 | // determine if we are detailing with a slice - e.g. foo[0] or bar[0] 91 | matches := sliceRegex.FindStringSubmatch(key) 92 | if matches != nil { 93 | actualKey := matches[1] 94 | if _, ok := output[actualKey]; !ok { 95 | output[actualKey] = []any{} 96 | } 97 | if len(keys)-1 == i { 98 | // the value go straight into the slice, we don't have any slice of objects 99 | output[actualKey] = append(output[actualKey].([]any), v) 100 | output = root 101 | } else { 102 | // ugh... we have a slice of objects 103 | // convert the index of the path, the index now matters 104 | index, err := strconv.Atoi(matches[2]) 105 | if err != nil { 106 | return nil, err 107 | } 108 | var obj map[string]any 109 | slice := output[actualKey].([]any) 110 | // determine if the index we are walking exists yet in the slice we have built up 111 | if len(slice) > index { 112 | obj = slice[index].(map[string]any) 113 | if obj == nil { 114 | obj = map[string]any{} 115 | } 116 | } else { 117 | // the index does not exist, so we need to create it 118 | for j := len(slice); j <= index; j++ { 119 | output[actualKey] = append(output[actualKey].([]any), map[string]any{}) 120 | } 121 | obj = output[actualKey].([]any)[index].(map[string]any) 122 | } 123 | output = obj 124 | } 125 | } else if len(keys)-1 == i { 126 | // the value go straight into the key 127 | output[key] = v 128 | output = root 129 | } else { 130 | // need to create a nested object 131 | if _, ok := output[key]; !ok { 132 | output[key] = map[string]any{} 133 | } 134 | output = output[key].(map[string]any) 135 | } 136 | } 137 | } 138 | } 139 | return output, nil 140 | } 141 | 142 | // PropertySource is the property source for the application. 143 | // 144 | // A property source is either a YAML or a PROPERTIES file located in the repository that a Config Server is pointed at. 145 | type PropertySource struct { 146 | Source map[string]any `json:"source"` 147 | Name string `json:"name"` 148 | } 149 | 150 | // Configuration interface for retrieving an application's configuration files from the Config Server. 151 | type Configuration interface { 152 | // GetConfiguration retrieves the configurations/property sources of an application based on the name of the application 153 | // and the profiles of the application. 154 | GetConfiguration(applicationName string, profiles ...string) (Source, error) 155 | // GetConfigurationWithLabel retrieves the configurations/property sources of an application based on the name of the application 156 | // and the profiles of the application and the label. 157 | GetConfigurationWithLabel(label string, applicationName string, profiles ...string) (Source, error) 158 | } 159 | 160 | // GetConfiguration retrieves the configurations/property sources of an application based on the name of the application 161 | // and the profiles of the application. 162 | func (c *Client) GetConfiguration(applicationName string, profiles ...string) (Source, error) { 163 | var source Source 164 | paths := []string{applicationName, joinProfiles(profiles)} 165 | for _, client := range c.clients { 166 | if err := client.GetResource(paths, nil, &source); err != nil { 167 | if errors.Is(err, ErrResourceNotFound) { 168 | continue 169 | } 170 | return Source{}, err 171 | } 172 | return source, nil 173 | } 174 | return Source{}, fmt.Errorf("failed to find configuration for application %s with profiles %s", applicationName, profiles) 175 | } 176 | 177 | // GetConfigurationWithLabel retrieves the configurations/property sources of an application based on the name of the application 178 | // and the profiles of the application and the label. 179 | func (c *Client) GetConfigurationWithLabel(label string, applicationName string, profiles ...string) (Source, error) { 180 | var source Source 181 | paths := []string{applicationName, joinProfiles(profiles), label} 182 | for _, client := range c.clients { 183 | if err := client.GetResource(paths, nil, &source); err != nil { 184 | if errors.Is(err, ErrResourceNotFound) { 185 | continue 186 | } 187 | return Source{}, err 188 | } 189 | return source, nil 190 | } 191 | return Source{}, fmt.Errorf("failed to find configuration for application %s with profiles %s and label %s", applicationName, profiles, label) 192 | } 193 | 194 | func joinProfiles(profiles []string) string { 195 | return strings.Join(profiles, ",") 196 | } 197 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "testing" 9 | 10 | "github.com/Piszmog/cloudconfigclient/v2" 11 | "github.com/stretchr/testify/require" 12 | "golang.org/x/oauth2/clientcredentials" 13 | ) 14 | 15 | func TestNew(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | options []cloudconfigclient.Option 19 | err error 20 | }{ 21 | { 22 | name: "No Options", 23 | options: nil, 24 | err: errors.New("at least one option must be provided"), 25 | }, 26 | { 27 | name: "Option Error", 28 | options: []cloudconfigclient.Option{cloudconfigclient.LocalEnv(&http.Client{})}, 29 | err: errors.New("no local Config Server URLs provided in environment variable CONFIG_SERVER_URLS"), 30 | }, 31 | { 32 | name: "Created", 33 | options: []cloudconfigclient.Option{cloudconfigclient.Local(&http.Client{}, "http:localhost:8888")}, 34 | err: nil, 35 | }, 36 | } 37 | for _, test := range tests { 38 | t.Run(test.name, func(t *testing.T) { 39 | client, err := cloudconfigclient.New(test.options...) 40 | if err != nil { 41 | require.Equal(t, test.err, err) 42 | require.Nil(t, client) 43 | } else { 44 | require.NoError(t, err) 45 | require.NotNil(t, client) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestOption(t *testing.T) { 52 | oauthConfig := clientcredentials.Config{ClientID: "clientId", ClientSecret: "secret", TokenURL: "http://token"} 53 | oauthClient := oauthConfig.Client(context.Background()) 54 | tests := []struct { 55 | name string 56 | setup func() 57 | cleanup func() 58 | option cloudconfigclient.Option 59 | expected []*cloudconfigclient.HTTPClient 60 | err error 61 | }{ 62 | { 63 | name: "LocalEnv", 64 | setup: func() { 65 | os.Setenv(cloudconfigclient.EnvironmentLocalConfigServerUrls, "http://localhost:8880") 66 | }, 67 | cleanup: func() { 68 | os.Unsetenv(cloudconfigclient.EnvironmentLocalConfigServerUrls) 69 | }, 70 | option: cloudconfigclient.LocalEnv(&http.Client{}), 71 | expected: []*cloudconfigclient.HTTPClient{{BaseURL: "http://localhost:8880", Client: &http.Client{}}}, 72 | }, 73 | { 74 | name: "LocalEnv Multiple", 75 | setup: func() { 76 | os.Setenv(cloudconfigclient.EnvironmentLocalConfigServerUrls, "http://localhost:8880,http://localhost:8888") 77 | }, 78 | cleanup: func() { 79 | os.Unsetenv(cloudconfigclient.EnvironmentLocalConfigServerUrls) 80 | }, 81 | option: cloudconfigclient.LocalEnv(&http.Client{}), 82 | expected: []*cloudconfigclient.HTTPClient{ 83 | {BaseURL: "http://localhost:8880", Client: &http.Client{}}, 84 | {BaseURL: "http://localhost:8888", Client: &http.Client{}}, 85 | }, 86 | }, 87 | { 88 | name: "LocalEnv Error", 89 | option: cloudconfigclient.LocalEnv(&http.Client{}), 90 | err: errors.New("no local Config Server URLs provided in environment variable CONFIG_SERVER_URLS"), 91 | }, 92 | { 93 | name: "Local", 94 | option: cloudconfigclient.Local(&http.Client{}, "http://localhost:8880"), 95 | expected: []*cloudconfigclient.HTTPClient{{BaseURL: "http://localhost:8880", Client: &http.Client{}}}, 96 | }, 97 | { 98 | name: "Local Multiple", 99 | option: cloudconfigclient.Local(&http.Client{}, "http://localhost:8880", "http://localhost:8888"), 100 | expected: []*cloudconfigclient.HTTPClient{ 101 | {BaseURL: "http://localhost:8880", Client: &http.Client{}}, 102 | {BaseURL: "http://localhost:8888", Client: &http.Client{}}, 103 | }, 104 | }, 105 | { 106 | 107 | name: "Basic", 108 | option: cloudconfigclient.Basic(&http.Client{}, "username", "password", "http://localhost:8880"), 109 | expected: []*cloudconfigclient.HTTPClient{{BaseURL: "http://localhost:8880", Client: &http.Client{}, Authorization: "Basic dXNlcm5hbWU6cGFzc3dvcmQ="}}, 110 | }, 111 | { 112 | name: "DefaultCFService", 113 | setup: func() { 114 | os.Setenv("VCAP_SERVICES", `{ 115 | "p.config-server": [ 116 | { 117 | "credentials": { 118 | "uri": "http://config", 119 | "client_secret": "secret", 120 | "client_id": "clientId", 121 | "access_token_uri": "http://token" 122 | } 123 | } 124 | ] 125 | }`) 126 | }, 127 | cleanup: func() { 128 | os.Unsetenv("VCAP_SERVICES") 129 | }, 130 | option: cloudconfigclient.DefaultCFService(), 131 | expected: []*cloudconfigclient.HTTPClient{{ 132 | BaseURL: "http://config", 133 | Client: oauthClient, 134 | }}, 135 | }, 136 | { 137 | name: "DefaultCFService Missing Data", 138 | setup: func() { 139 | os.Setenv("VCAP_SERVICES", `{"p.config-server": []}`) 140 | }, 141 | cleanup: func() { 142 | os.Unsetenv("VCAP_SERVICES") 143 | }, 144 | option: cloudconfigclient.DefaultCFService(), 145 | err: errors.New("failed to create cloud Client: p.config-server has no data"), 146 | }, 147 | { 148 | name: "DefaultCFService Old Service", 149 | setup: func() { 150 | os.Setenv("VCAP_SERVICES", `{ 151 | "p-config-server": [ 152 | { 153 | "credentials": { 154 | "uri": "http://config", 155 | "client_secret": "secret", 156 | "client_id": "clientId", 157 | "access_token_uri": "http://token" 158 | } 159 | } 160 | ] 161 | }`) 162 | }, 163 | cleanup: func() { 164 | os.Unsetenv("VCAP_SERVICES") 165 | }, 166 | option: cloudconfigclient.DefaultCFService(), 167 | expected: []*cloudconfigclient.HTTPClient{{ 168 | BaseURL: "http://config", 169 | Client: oauthClient, 170 | }}, 171 | }, 172 | { 173 | name: "DefaultCFService Old Service Missing Data", 174 | setup: func() { 175 | os.Setenv("VCAP_SERVICES", `{"p-config-server": []}`) 176 | }, 177 | cleanup: func() { 178 | os.Unsetenv("VCAP_SERVICES") 179 | }, 180 | option: cloudconfigclient.DefaultCFService(), 181 | err: errors.New("failed to create cloud Client: p-config-server has no data"), 182 | }, 183 | { 184 | name: "DefaultCFService Not Found", 185 | setup: func() { 186 | os.Setenv("VCAP_SERVICES", `{}`) 187 | }, 188 | cleanup: func() { 189 | os.Unsetenv("VCAP_SERVICES") 190 | }, 191 | option: cloudconfigclient.DefaultCFService(), 192 | err: errors.New("neither p-config-server or p.config-server exist in environment variable 'VCAP_SERVICES'"), 193 | }, 194 | { 195 | name: "DefaultCFService Error", 196 | option: cloudconfigclient.DefaultCFService(), 197 | err: errors.New("failed to parse 'VCAP_SERVICES': failed to unmarshal JSON: unexpected end of JSON input"), 198 | }, 199 | { 200 | name: "CFService", 201 | setup: func() { 202 | os.Setenv("VCAP_SERVICES", `{ 203 | "config-server": [ 204 | { 205 | "credentials": { 206 | "uri": "http://config", 207 | "client_secret": "secret", 208 | "client_id": "clientId", 209 | "access_token_uri": "http://token" 210 | } 211 | } 212 | ] 213 | }`) 214 | }, 215 | cleanup: func() { 216 | os.Unsetenv("VCAP_SERVICES") 217 | }, 218 | option: cloudconfigclient.CFService("config-server"), 219 | expected: []*cloudconfigclient.HTTPClient{{ 220 | BaseURL: "http://config", 221 | Client: oauthClient, 222 | }}, 223 | }, 224 | { 225 | name: "CFService Error", 226 | option: cloudconfigclient.CFService("config-server"), 227 | err: errors.New("failed to parse 'VCAP_SERVICES': failed to unmarshal JSON: unexpected end of JSON input"), 228 | }, 229 | { 230 | name: "CFService Not Found", 231 | setup: func() { 232 | os.Setenv("VCAP_SERVICES", `{ 233 | "something-else": [ 234 | { 235 | "credentials": { 236 | "uri": "http://config", 237 | "client_secret": "secret", 238 | "client_id": "clientId", 239 | "access_token_uri": "http://token" 240 | } 241 | } 242 | ] 243 | }`) 244 | }, 245 | cleanup: func() { 246 | os.Unsetenv("VCAP_SERVICES") 247 | }, 248 | option: cloudconfigclient.CFService("config-server"), 249 | err: errors.New("failed to create cloud Client: service does not exist"), 250 | }, 251 | { 252 | name: "OAuth2", 253 | option: cloudconfigclient.OAuth2("http://config", "clientId", "secret", "http://token"), 254 | expected: []*cloudconfigclient.HTTPClient{{ 255 | BaseURL: "http://config", 256 | Client: oauthClient, 257 | }}, 258 | }, 259 | } 260 | for _, test := range tests { 261 | t.Run(test.name, func(t *testing.T) { 262 | if test.setup != nil { 263 | test.setup() 264 | } 265 | if test.cleanup != nil { 266 | defer test.cleanup() 267 | } 268 | var clients []*cloudconfigclient.HTTPClient 269 | err := test.option(&clients) 270 | if err != nil { 271 | require.Error(t, err) 272 | require.Equal(t, test.err.Error(), err.Error()) 273 | } else { 274 | require.NoError(t, err) 275 | require.Equal(t, test.expected, clients) 276 | } 277 | }) 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Config Server Client 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/Piszmog/cloudconfigclient.svg)](https://pkg.go.dev/github.com/Piszmog/cloudconfigclient/v2) 4 | [![Build Status](https://github.com/Piszmog/cloudconfigclient/workflows/Go/badge.svg)](https://github.com/Piszmog/cloudconfigclient/workflows/Go/badge.svg) 5 | [![Coverage Status](https://coveralls.io/repos/github/Piszmog/cloudconfigclient/badge.svg?branch=main)](https://coveralls.io/github/Piszmog/cloudconfigclient?branch=main) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/Piszmog/cloudconfigclient)](https://goreportcard.com/report/github.com/Piszmog/cloudconfigclient/v2) 7 | [![GitHub release](https://img.shields.io/github/release/Piszmog/cloudconfigclient.svg)](https://github.com/Piszmog/cloudconfigclient/releases/latest) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | 10 | Go library for Spring Config Server. Inspired by the Java 11 | library [Cloud Config Client](https://github.com/Piszmog/cloud-config-client). 12 | 13 | `go get github.com/Piszmog/cloudconfigclient/v2` 14 | 15 | #### V2 Migration 16 | 17 | See [V2 Migration](https://github.com/Piszmog/cloudconfigclient/wiki/V2-Migration) for details on how to migrate from V1 18 | to V2 19 | 20 | ## Description 21 | 22 | Spring's Config Server provides way to externalize configurations of applications. Spring's 23 | [Spring Cloud Config Client](https://github.com/spring-cloud/spring-cloud-config/tree/master/spring-cloud-config-client) 24 | can be used to load the base configurations that an application requires to properly function. 25 | 26 | This library provides clients the ability to load Configurations and Files from the Config Server. 27 | 28 | ### Compatibility 29 | 30 | This library is compatible with versions of Spring Config Server greater than or equal to `1.4.x.RELEASE`. Prior 31 | versions of the Config Server do not provide the endpoint necessary to retrieve files for the Config Server's default 32 | branch. 33 | 34 | #### Spring Cloud Config Server v3.x 35 | 36 | Since Spring Cloud Services v3.0, the service name in `VCAP_SERVICES` has changed from `p-config-server` to 37 | be `p.config-server`. 38 | 39 | To help mitigate migration difficulties, `cloudconfigclient.New(cloudconfigclient.DefaultCFService())` will first search 40 | for the service `p.config-server` (v3.x). If the v3.x service could not be found, 41 | `p-config-server` (v2.x) will be search for. 42 | 43 | See [Spring Cloud Services Differences](https://docs.pivotal.io/spring-cloud-services/3-1/common/config-server/managing-service-instances.html#differences-between-3-0-and-earlier) 44 | for more details. 45 | 46 | ## Example Usage 47 | 48 | Below is an example usage of the library to retrieve a file from the Config Server and to retrieve the application's 49 | configurations 50 | 51 | * For local config client, there are two options (`Option`) the create a client 52 | 1. Call `LocalEnv()`. Set the environment variable `CONFIG_SERVER_URLS`. It is a comma separated list of all the 53 | base URLs 54 | 2. Call `Local(baseUrls ...string)`. Provide the array of base URLs of Config Servers. 55 | * If the config server is protected with basic auth, call `Basic` with the username and password. 56 | * For running in Cloud Foundry, ensure a Config Server is bounded to the application. `VCAP_SERVICES` will be provided 57 | as an environment variables with the credentials to access the Config Server 58 | * For connecting to a Config Server via OAuth2 and not deployed to Cloud Foundry, an OAuth2 Client can be created 59 | with `OAuth2(baseURL string, clientId string, secret string, tokenURI string)` 60 | 61 | ```go 62 | package main 63 | 64 | import ( 65 | "fmt" 66 | "github.com/Piszmog/cloudconfigclient/v2" 67 | "net/http" 68 | ) 69 | 70 | type File struct { 71 | Example Example `json:"example"` 72 | } 73 | 74 | type Example struct { 75 | Field string `json:"field"` 76 | } 77 | 78 | func main() { 79 | // To create a Client for a locally running Spring Config Server 80 | configClient, err := cloudconfigclient.New(cloudconfigclient.LocalEnv(&http.Client{})) 81 | // Or 82 | configClient, err = cloudconfigclient.New(cloudconfigclient.Local(&http.Client{}, "http://localhost:8888")) 83 | // or to create a Client for a Spring Config Server using Basic Authentication 84 | configClient, err = cloudconfigclient.New(cloudconfigclient.Basic(&http.Client{}, "username", "password" "http://localhost:8888")) 85 | // or to create a Client for a Spring Config Server in Cloud Foundry 86 | configClient, err = cloudconfigclient.New(cloudconfigclient.DefaultCFService()) 87 | // or to create a Client for a Spring Config Server with OAuth2 88 | configClient, err = cloudconfigclient.New(cloudconfigclient.OAuth2("config server uri", "client id", "client secret", 89 | "access token uri")) 90 | // or a combination of local, Cloud Foundry, and OAuth2 91 | configClient, err = cloudconfigclient.New( 92 | cloudconfigclient.Local(&http.Client{}, "http://localhost:8888"), 93 | cloudconfigclient.DefaultCFService(), 94 | cloudconfigclient.OAuth2("config server uri", "client id", "client secret", "access token uri"), 95 | ) 96 | 97 | if err != nil { 98 | fmt.Println(err) 99 | return 100 | } 101 | var file File 102 | // Retrieves a 'temp1.json' from the Config Server's default branch in directory 'temp' and deserialize to File 103 | err = configClient.GetFile("temp", "temp1.json", &file) 104 | if err != nil { 105 | fmt.Println(err) 106 | return 107 | } 108 | fmt.Printf("%+v\n", file) 109 | // Retrieves a 'temp2.txt' from the Config Server's default branch in directory 'temp' as a byte slice ([]byte) 110 | b, err := configClient.GetFileRaw("temp", "temp2.txt") 111 | if err != nil { 112 | fmt.Println(err) 113 | return 114 | } 115 | fmt.Println(string(b)) 116 | 117 | // Retrieves the configurations from the Config Server based on the application name and active profiles 118 | config, err := configClient.GetConfiguration("testApp", "dev") 119 | if err != nil { 120 | fmt.Println(err) 121 | return 122 | } 123 | fmt.Printf("%+v", config) 124 | // if we want, we can convert the config to a struct 125 | var configStruct Config 126 | err = config.Unmarshal(&configStruct) 127 | if err != nil { 128 | fmt.Println(err) 129 | } 130 | } 131 | 132 | type Config struct { 133 | Field1 string `json:"example"` 134 | Field2 int `json:"field2"` 135 | } 136 | ``` 137 | 138 | #### VCAP_SERVICES 139 | 140 | When an application is deployed to Cloud Foundry, services can be bounded to the application. When a service is bounded 141 | to an application, the application will have the necessary connection information provided in the environment 142 | variable `VCAP_SERVICES`. 143 | 144 | Structure of the `VCAP_SERVICES` value 145 | 146 | ```json 147 | { 148 | "": [ 149 | { 150 | "name": "", 151 | "instance_name": "", 152 | "binding_name": "", 153 | "credentials": { 154 | "uri": "", 155 | "client_secret": "", 156 | "client_id": "", 157 | "access_token_uri": "" 158 | }, 159 | ... 160 | } 161 | ] 162 | } 163 | ``` 164 | 165 | ##### CredHub Reference 166 | 167 | Newer versions of PCF (>=2.6) may have services that use a CredHub Reference to store credential information. 168 | 169 | When viewing the Environment Variables of an application via the UI, the credentials may appear as the following 170 | 171 | ```json 172 | { 173 | "credentials": { 174 | "credhub-ref": "/c/example-service-broker/example-service/faa677f5-25cd-4f1e-8921-14a9d5ab48b8/credentials" 175 | } 176 | } 177 | ``` 178 | 179 | When the application starts up, the `credhub-ref` is replaced with the actual credential values that application will 180 | need to connect to the service. 181 | 182 | ## Configurations 183 | 184 | The Config Server allows the ability to retrieve configurations for an application. Only files that follow a strict 185 | naming convention will be loaded, 186 | 187 | | File Name | 188 | | :---: | 189 | |`application.{yml/properties}`| 190 | |`application-{profile}.{yml/properties}`| 191 | |`{application name}.{yml/properties}`| 192 | |`{application name}-{profile}.{yml/properties}`| 193 | 194 | The loaded configurations are in the following JSON format, 195 | 196 | ```json 197 | { 198 | "name": "", 199 | "profiles": "", 200 | "label": "", 201 | "version": "", 202 | "state": "", 203 | "propertySources": [ 204 | { 205 | "": { 206 | "name": "", 207 | "source": { 208 | "": "" 209 | } 210 | } 211 | } 212 | ] 213 | } 214 | ``` 215 | 216 | To use the library to retrieve configurations, create a `Client` and invoke the 217 | method `GetConfiguration(applicationName string, profiles ...string)`. The return will be the struct representation of 218 | the configuration JSON - `client.Configuration`. 219 | 220 | ## Resources 221 | 222 | Spring's Config Server allows two ways to retrieve files from a backing repository. 223 | 224 | | URL Path | 225 | | :---: | 226 | |`////?useDefaultLabel=true`| 227 | |`/////`| 228 | 229 | * When retrieving a file from the Config Server's default branch, the file must not exist at the root of the repository. 230 | * If the `directory` is in the `searchPath`, it does not have to be specified (depending on SCCS version) 231 | 232 | The functions available to retrieve resource files 233 | are `GetFile(directory string, file string, interfaceType interface{})` and 234 | `GetFileFromBranch(branch string, directory string, file string, interfaceType interface{})`. To retrieve the data from 235 | the files, the functions available are `GetFileRaw(directory string, file string)` and 236 | `GetFileFromBranchRaw(branch string, directory string, file string)` 237 | 238 | * The `interfaceType` is the object to deserialize the file to 239 | 240 | ### Spring Cloud Config Server v3.x Changes 241 | 242 | The following is only for certain versions of SCCS v3.x. If a file is not being found by the client, the following may 243 | be true. 244 | 245 | SCCS v3.x slightly changed how files are retrieved. If the Config Server specified a directory in the `searchPaths`, the 246 | path should be excluded from the `GetFile(..)` invocation. 247 | 248 | For example if `common` has been specified in the `searchPaths` and the file `common/foo.txt` needs to be retrieved, 249 | then the `directory` to provide to `GetFile(..)` 250 | should be `""` (blank). 251 | 252 | This differs with SCS v2.x where the directory in `searchPaths` did not impact the `directory` provided 253 | to `GetFile(..)` (e.g. to retrieve file `common/foo.txt`, 254 | `directory` would be `"common"`). 255 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/Piszmog/cloudconfigclient/v2" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | type RoundTripFunc func(req *http.Request) *http.Response 15 | 16 | func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { 17 | return f(req), nil 18 | } 19 | 20 | // NewMockHttpClient creates a mocked HTTP Client 21 | func NewMockHttpClient(fn RoundTripFunc) *http.Client { 22 | return &http.Client{ 23 | Transport: fn, 24 | } 25 | } 26 | 27 | // NewMockHttpResponse creates a mocked HTTP response 28 | func NewMockHttpResponse(code int, body string) *http.Response { 29 | return &http.Response{ 30 | StatusCode: code, 31 | // Send response to be tested 32 | Body: io.NopCloser(bytes.NewBufferString(body)), 33 | // Must be set to non-nil value or it panics 34 | Header: make(http.Header), 35 | } 36 | } 37 | 38 | func TestHTTPClient_Get(t *testing.T) { 39 | tests := []struct { 40 | name string 41 | baseURL string 42 | auth string 43 | paths []string 44 | params map[string]string 45 | response *http.Response 46 | checker func(*testing.T, *http.Request) 47 | err error 48 | }{ 49 | { 50 | name: "Invalid URL", 51 | baseURL: "\n", 52 | err: errors.New("failed to create url: failed to parse url \n: parse \"\\n\": net/url: invalid control character in URL"), 53 | }, 54 | { 55 | name: "HTTP Error", 56 | baseURL: "http://foobar", 57 | err: errors.New("failed to retrieve from http://foobar: Get \"http://foobar\": http: RoundTripper implementation (cloudconfigclient_test.RoundTripFunc) returned a nil *Response with a nil error"), 58 | }, 59 | { 60 | name: "Correct Request URI", 61 | baseURL: "http://something", 62 | response: NewMockHttpResponse(200, ""), 63 | checker: func(t *testing.T, request *http.Request) { 64 | require.Equal(t, "/", request.URL.RequestURI()) 65 | require.Empty(t, request.Header.Get("Authorization")) 66 | }, 67 | }, 68 | { 69 | name: "Correct Request URI With Path", 70 | baseURL: "http://something", 71 | paths: []string{"/foo", "/bar"}, 72 | response: NewMockHttpResponse(200, ""), 73 | checker: func(t *testing.T, request *http.Request) { 74 | require.Equal(t, "/foo/bar", request.URL.RequestURI()) 75 | }, 76 | }, 77 | { 78 | name: "Correct Request URI With Params", 79 | baseURL: "http://something", 80 | params: map[string]string{"field": "value"}, 81 | response: NewMockHttpResponse(200, ""), 82 | checker: func(t *testing.T, request *http.Request) { 83 | require.Equal(t, "/?field=value", request.URL.RequestURI()) 84 | }, 85 | }, 86 | { 87 | name: "Correct Request URI With Path and Params", 88 | baseURL: "http://something", 89 | paths: []string{"/foo", "/bar"}, 90 | params: map[string]string{"field": "value"}, 91 | response: NewMockHttpResponse(200, ""), 92 | checker: func(t *testing.T, request *http.Request) { 93 | require.Equal(t, "/foo/bar?field=value", request.URL.RequestURI()) 94 | }, 95 | }, 96 | { 97 | name: "Correct Request URI With Auth", 98 | baseURL: "http://something", 99 | auth: "Basic dXNlcjpwYXNz", 100 | response: NewMockHttpResponse(200, ""), 101 | checker: func(t *testing.T, request *http.Request) { 102 | require.Equal(t, "Basic dXNlcjpwYXNz", request.Header.Get("Authorization")) 103 | }, 104 | }, 105 | } 106 | for _, test := range tests { 107 | t.Run(test.name, func(t *testing.T) { 108 | client := NewMockHttpClient(func(req *http.Request) *http.Response { 109 | if test.checker != nil { 110 | test.checker(t, req) 111 | } 112 | return test.response 113 | }) 114 | httpClient := cloudconfigclient.HTTPClient{BaseURL: test.baseURL, Client: client, Authorization: test.auth} 115 | _, err := httpClient.Get(test.paths, test.params) 116 | if err != nil { 117 | require.Error(t, err) 118 | require.Equal(t, test.err.Error(), err.Error()) 119 | } else { 120 | require.NoError(t, err) 121 | } 122 | }) 123 | } 124 | } 125 | 126 | func TestHTTPClient_GetResource(t *testing.T) { 127 | tests := []struct { 128 | name string 129 | paths []string 130 | params map[string]string 131 | destination interface{} 132 | response *http.Response 133 | expected interface{} 134 | err error 135 | }{ 136 | { 137 | name: "HTTP Error", 138 | paths: []string{"file.yaml"}, 139 | err: errors.New("failed to retrieve from http://something/file.yaml: Get \"http://something/file.yaml\": http: RoundTripper implementation (cloudconfigclient_test.RoundTripFunc) returned a nil *Response with a nil error"), 140 | }, 141 | { 142 | name: "Internal Server Error", 143 | paths: []string{"file.yaml"}, 144 | response: NewMockHttpResponse(http.StatusInternalServerError, "Invalid HTTP Call"), 145 | err: errors.New("server responded with status code '500' and body 'Invalid HTTP Call'"), 146 | }, 147 | { 148 | name: "YAML Response", 149 | paths: []string{"file.yaml"}, 150 | params: map[string]string{"useDefault": "true"}, 151 | destination: make(map[string]interface{}), 152 | response: NewMockHttpResponse(http.StatusOK, `foo: bar`), 153 | expected: map[string]interface{}{"foo": "bar"}, 154 | }, 155 | { 156 | name: "YAML Response Malformed", 157 | paths: []string{"file.yaml"}, 158 | params: map[string]string{"useDefault": "true"}, 159 | destination: make(map[string]interface{}), 160 | response: NewMockHttpResponse(http.StatusOK, ""), 161 | err: errors.New("failed to decode response from url: EOF"), 162 | }, 163 | { 164 | name: "YML Response", 165 | paths: []string{"file.yml"}, 166 | params: map[string]string{"useDefault": "true"}, 167 | destination: make(map[string]interface{}), 168 | response: NewMockHttpResponse(http.StatusOK, `foo: bar`), 169 | expected: map[string]interface{}{"foo": "bar"}, 170 | }, 171 | { 172 | name: "JSON Response", 173 | paths: []string{"file.json"}, 174 | params: map[string]string{"useDefault": "true"}, 175 | destination: make(map[string]interface{}), 176 | response: NewMockHttpResponse(http.StatusOK, `{"foo":"bar"}`), 177 | expected: map[string]interface{}{"foo": "bar"}, 178 | }, 179 | { 180 | name: "JSON Response Malformed", 181 | paths: []string{"file.json"}, 182 | params: map[string]string{"useDefault": "true"}, 183 | destination: make(map[string]interface{}), 184 | response: NewMockHttpResponse(http.StatusOK, `{"foo":"bar"`), 185 | err: errors.New("failed to decode response from url: unexpected EOF"), 186 | }, 187 | { 188 | name: "XML Response", 189 | paths: []string{"file.xml"}, 190 | params: map[string]string{"useDefault": "true"}, 191 | destination: new(xmlResp), 192 | response: NewMockHttpResponse(http.StatusOK, `"bar"`), 193 | expected: &xmlResp{Foo: "bar"}, 194 | }, 195 | { 196 | name: "XML Response Malformed", 197 | paths: []string{"file.xml"}, 198 | params: map[string]string{"useDefault": "true"}, 199 | destination: new(xmlResp), 200 | response: NewMockHttpResponse(http.StatusOK, `"bar"), 202 | }, 203 | { 204 | name: "Read Error", 205 | paths: []string{"file.yml"}, 206 | params: map[string]string{"useDefault": "true"}, 207 | destination: new(xmlResp), 208 | response: &http.Response{ 209 | StatusCode: http.StatusOK, 210 | // Send response to be tested 211 | Body: errorReader{}, 212 | // Must be set to non-nil value or it panics 213 | Header: make(http.Header), 214 | }, 215 | err: errors.New("failed to decode response from url: yaml: input error: failed"), 216 | }, 217 | { 218 | name: "Internal Error Read Error", 219 | paths: []string{"file.yml"}, 220 | params: map[string]string{"useDefault": "true"}, 221 | destination: new(xmlResp), 222 | response: &http.Response{ 223 | StatusCode: http.StatusInternalServerError, 224 | // Send response to be tested 225 | Body: errorReader{}, 226 | // Must be set to non-nil value or it panics 227 | Header: make(http.Header), 228 | }, 229 | err: errors.New("failed to read body with status code '500': failed"), 230 | }, 231 | { 232 | name: "No Resource Specified", 233 | destination: new(xmlResp), 234 | err: errors.New("no resource specified to be retrieved"), 235 | }, 236 | } 237 | for _, test := range tests { 238 | t.Run(test.name, func(t *testing.T) { 239 | client := NewMockHttpClient(func(req *http.Request) *http.Response { 240 | return test.response 241 | }) 242 | httpClient := cloudconfigclient.HTTPClient{BaseURL: "http://something", Client: client} 243 | err := httpClient.GetResource(test.paths, test.params, &test.destination) 244 | if err != nil { 245 | require.Error(t, err) 246 | require.Equal(t, test.err.Error(), err.Error()) 247 | } else { 248 | require.NoError(t, err) 249 | require.Equal(t, test.expected, test.destination) 250 | } 251 | }) 252 | } 253 | } 254 | 255 | func TestHTTPClient_GetResourceRaw(t *testing.T) { 256 | tests := []struct { 257 | name string 258 | paths []string 259 | params map[string]string 260 | response *http.Response 261 | expected []byte 262 | err error 263 | }{ 264 | { 265 | name: "HTTP Error", 266 | paths: []string{"file.text"}, 267 | err: errors.New("failed to retrieve from http://something/file.text: Get \"http://something/file.text\": http: RoundTripper implementation (cloudconfigclient_test.RoundTripFunc) returned a nil *Response with a nil error"), 268 | }, 269 | { 270 | name: "Internal Server Error", 271 | paths: []string{"file.text"}, 272 | response: NewMockHttpResponse(http.StatusInternalServerError, "Invalid HTTP Call"), 273 | err: errors.New("server responded with status code '500' and body 'Invalid HTTP Call'"), 274 | }, 275 | { 276 | name: "Text Response", 277 | paths: []string{"file.text"}, 278 | params: map[string]string{"useDefault": "true"}, 279 | response: NewMockHttpResponse(http.StatusOK, `foo-bar`), 280 | expected: []byte("foo-bar"), 281 | }, 282 | { 283 | name: "Read Error", 284 | paths: []string{"file.text"}, 285 | params: map[string]string{"useDefault": "true"}, 286 | response: &http.Response{ 287 | StatusCode: http.StatusOK, 288 | // Send response to be tested 289 | Body: errorReader{}, 290 | // Must be set to non-nil value or it panics 291 | Header: make(http.Header), 292 | }, 293 | err: errors.New("failed to read body with status code '200': failed"), 294 | }, 295 | { 296 | name: "No Resource Specified", 297 | err: errors.New("no resource specified to be retrieved"), 298 | }, 299 | } 300 | for _, test := range tests { 301 | t.Run(test.name, func(t *testing.T) { 302 | client := NewMockHttpClient(func(req *http.Request) *http.Response { 303 | return test.response 304 | }) 305 | httpClient := cloudconfigclient.HTTPClient{BaseURL: "http://something", Client: client} 306 | resp, err := httpClient.GetResourceRaw(test.paths, test.params) 307 | if err != nil { 308 | require.Error(t, err) 309 | require.Equal(t, test.err.Error(), err.Error()) 310 | } else { 311 | require.NoError(t, err) 312 | require.Equal(t, test.expected, resp) 313 | } 314 | }) 315 | } 316 | } 317 | 318 | type errorReader struct { 319 | } 320 | 321 | func (e errorReader) Read(p []byte) (n int, err error) { 322 | return 0, errors.New("failed") 323 | } 324 | 325 | func (e errorReader) Close() error { 326 | return nil 327 | } 328 | 329 | type xmlResp struct { 330 | Foo string `xml:"foo"` 331 | } 332 | -------------------------------------------------------------------------------- /configuration_test.go: -------------------------------------------------------------------------------- 1 | package cloudconfigclient_test 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/Piszmog/cloudconfigclient/v2" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | const ( 14 | configurationSource = `{ 15 | "name": "testConfig", 16 | "profiles": [ 17 | "profile" 18 | ], 19 | "propertySources": [ 20 | { 21 | "name": "test", 22 | "source": { 23 | "field1": "value1", 24 | "field2": 1 25 | } 26 | } 27 | ] 28 | }` 29 | ) 30 | 31 | func TestClient_GetConfiguration(t *testing.T) { 32 | tests := []struct { 33 | name string 34 | application string 35 | profiles []string 36 | checker func(*testing.T, *http.Request) 37 | response *http.Response 38 | expected cloudconfigclient.Source 39 | err error 40 | }{ 41 | { 42 | name: "Get Config", 43 | application: "appName", 44 | profiles: []string{"profile"}, 45 | checker: func(t *testing.T, request *http.Request) { 46 | require.Equal(t, "http://localhost:8888/appName/profile", request.URL.String()) 47 | }, 48 | response: NewMockHttpResponse(http.StatusOK, configurationSource), 49 | expected: cloudconfigclient.Source{ 50 | Name: "testConfig", 51 | Profiles: []string{"profile"}, 52 | PropertySources: []cloudconfigclient.PropertySource{{Name: "test", Source: map[string]interface{}{"field1": "value1", "field2": float64(1)}}}, 53 | }, 54 | }, 55 | { 56 | name: "Multiple Profiles", 57 | application: "appName", 58 | profiles: []string{"profile1", "profile2", "profile3"}, 59 | checker: func(t *testing.T, request *http.Request) { 60 | require.Equal(t, "http://localhost:8888/appName/profile1,profile2,profile3", request.URL.String()) 61 | }, 62 | response: NewMockHttpResponse(http.StatusOK, configurationSource), 63 | expected: cloudconfigclient.Source{ 64 | Name: "testConfig", 65 | Profiles: []string{"profile"}, 66 | PropertySources: []cloudconfigclient.PropertySource{{Name: "test", Source: map[string]interface{}{"field1": "value1", "field2": float64(1)}}}, 67 | }, 68 | }, 69 | { 70 | name: "Not Found", 71 | application: "appName", 72 | profiles: []string{"profile"}, 73 | response: NewMockHttpResponse(http.StatusNotFound, ""), 74 | err: errors.New("failed to find configuration for application appName with profiles [profile]"), 75 | }, 76 | { 77 | name: "Server Error", 78 | application: "appName", 79 | profiles: []string{"profile"}, 80 | response: NewMockHttpResponse(http.StatusInternalServerError, ""), 81 | err: errors.New("server responded with status code '500' and body ''"), 82 | }, 83 | { 84 | name: "No Response Body", 85 | application: "appName", 86 | profiles: []string{"profile"}, 87 | response: NewMockHttpResponse(http.StatusOK, ""), 88 | err: errors.New("failed to decode response from url: EOF"), 89 | }, 90 | { 91 | name: "HTTP Error", 92 | application: "appName", 93 | profiles: []string{"profile"}, 94 | err: errors.New("failed to retrieve from http://localhost:8888/appName/profile: Get \"http://localhost:8888/appName/profile\": http: RoundTripper implementation (cloudconfigclient_test.RoundTripFunc) returned a nil *Response with a nil error"), 95 | }, 96 | } 97 | for _, test := range tests { 98 | t.Run(test.name, func(t *testing.T) { 99 | httpClient := NewMockHttpClient(func(req *http.Request) *http.Response { 100 | if test.checker != nil { 101 | test.checker(t, req) 102 | } 103 | return test.response 104 | }) 105 | client, err := cloudconfigclient.New(cloudconfigclient.Local(httpClient, "http://localhost:8888")) 106 | require.NoError(t, err) 107 | configuration, err := client.GetConfiguration(test.application, test.profiles...) 108 | if err != nil { 109 | require.Error(t, err) 110 | require.Equal(t, test.err.Error(), err.Error()) 111 | } else { 112 | require.NoError(t, err) 113 | require.Equal(t, test.expected, configuration) 114 | } 115 | }) 116 | } 117 | } 118 | func TestClient_GetConfigurationWithLabel(t *testing.T) { 119 | tests := []struct { 120 | name string 121 | label string 122 | application string 123 | profiles []string 124 | checker func(*testing.T, *http.Request) 125 | response *http.Response 126 | expected cloudconfigclient.Source 127 | err error 128 | }{ 129 | { 130 | name: "Get Config", 131 | label: "master", 132 | application: "appName", 133 | profiles: []string{"profile"}, 134 | checker: func(t *testing.T, request *http.Request) { 135 | require.Equal(t, "http://localhost:8888/appName/profile/master", request.URL.String()) 136 | }, 137 | response: NewMockHttpResponse(http.StatusOK, configurationSource), 138 | expected: cloudconfigclient.Source{ 139 | Name: "testConfig", 140 | Profiles: []string{"profile"}, 141 | PropertySources: []cloudconfigclient.PropertySource{{Name: "test", Source: map[string]interface{}{"field1": "value1", "field2": float64(1)}}}, 142 | }, 143 | }, 144 | { 145 | name: "Multiple Profiles", 146 | label: "master", 147 | application: "appName", 148 | profiles: []string{"profile1", "profile2", "profile3"}, 149 | checker: func(t *testing.T, request *http.Request) { 150 | require.Equal(t, "http://localhost:8888/appName/profile1,profile2,profile3/master", request.URL.String()) 151 | }, 152 | response: NewMockHttpResponse(http.StatusOK, configurationSource), 153 | expected: cloudconfigclient.Source{ 154 | Name: "testConfig", 155 | Profiles: []string{"profile"}, 156 | PropertySources: []cloudconfigclient.PropertySource{{Name: "test", Source: map[string]interface{}{"field1": "value1", "field2": float64(1)}}}, 157 | }, 158 | }, 159 | { 160 | name: "Not Found", 161 | label: "master", 162 | application: "appName", 163 | profiles: []string{"profile"}, 164 | response: NewMockHttpResponse(http.StatusNotFound, ""), 165 | err: errors.New("failed to find configuration for application appName with profiles [profile] and label master"), 166 | }, 167 | { 168 | name: "Server Error", 169 | label: "master", 170 | application: "appName", 171 | profiles: []string{"profile"}, 172 | response: NewMockHttpResponse(http.StatusInternalServerError, ""), 173 | err: errors.New("server responded with status code '500' and body ''"), 174 | }, 175 | { 176 | name: "No Response Body", 177 | label: "master", 178 | application: "appName", 179 | profiles: []string{"profile"}, 180 | response: NewMockHttpResponse(http.StatusOK, ""), 181 | err: errors.New("failed to decode response from url: EOF"), 182 | }, 183 | { 184 | name: "HTTP Error", 185 | label: "master", 186 | application: "appName", 187 | profiles: []string{"profile"}, 188 | err: errors.New("failed to retrieve from http://localhost:8888/appName/profile/master: Get \"http://localhost:8888/appName/profile/master\": http: RoundTripper implementation (cloudconfigclient_test.RoundTripFunc) returned a nil *Response with a nil error"), 189 | }, 190 | } 191 | for _, test := range tests { 192 | t.Run(test.name, func(t *testing.T) { 193 | httpClient := NewMockHttpClient(func(req *http.Request) *http.Response { 194 | if test.checker != nil { 195 | test.checker(t, req) 196 | } 197 | return test.response 198 | }) 199 | client, err := cloudconfigclient.New(cloudconfigclient.Local(httpClient, "http://localhost:8888")) 200 | require.NoError(t, err) 201 | configuration, err := client.GetConfigurationWithLabel(test.label, test.application, test.profiles...) 202 | if err != nil { 203 | require.Error(t, err) 204 | require.Equal(t, test.err.Error(), err.Error()) 205 | } else { 206 | require.NoError(t, err) 207 | require.Equal(t, test.expected, configuration) 208 | } 209 | }) 210 | } 211 | } 212 | 213 | func TestSource_GetPropertySource(t *testing.T) { 214 | source := cloudconfigclient.Source{ 215 | PropertySources: []cloudconfigclient.PropertySource{ 216 | {Name: "application-foo.yml"}, 217 | {Name: "application-foo.properties"}, 218 | {Name: "test-app-foo.yml"}, 219 | }, 220 | } 221 | 222 | tests := []struct { 223 | name string 224 | fileName string 225 | found bool 226 | }{ 227 | { 228 | name: "Property Source Found", 229 | fileName: "application-foo.yml", 230 | found: true, 231 | }, 232 | { 233 | name: "Property Source Not Found - Wrong Extension", 234 | fileName: "application-foo.json", 235 | found: false, 236 | }, 237 | { 238 | name: "Property Source Not Found - Invalid Name", 239 | fileName: "test.yml", 240 | found: false, 241 | }, 242 | } 243 | for _, test := range tests { 244 | t.Run(test.name, func(t *testing.T) { 245 | propertySource, err := source.GetPropertySource(test.fileName) 246 | if test.found { 247 | assert.NoError(t, err) 248 | assert.Equal(t, test.fileName, propertySource.Name) 249 | } else { 250 | assert.ErrorIs(t, err, cloudconfigclient.ErrPropertySourceDoesNotExist) 251 | } 252 | }) 253 | } 254 | } 255 | 256 | func TestSource_HandlePropertySources_NonFileExcluded(t *testing.T) { 257 | source := cloudconfigclient.Source{ 258 | PropertySources: []cloudconfigclient.PropertySource{ 259 | {Name: "application-foo.yml"}, 260 | {Name: "ssh://foo.bar.com/path/to/repo/path/to/file/application-foo.properties"}, 261 | {Name: "ssh://foo.bar.com/path/to/repo/path/to/file/application-foo.yaml"}, 262 | {Name: "test-app-foo"}, 263 | }, 264 | } 265 | count := 0 266 | source.HandlePropertySources(func(propertySource cloudconfigclient.PropertySource) { 267 | count++ 268 | }) 269 | assert.Equal(t, 3, count) 270 | } 271 | 272 | func TestSource_Unmarshal(t *testing.T) { 273 | tests := []struct { 274 | name string 275 | source cloudconfigclient.Source 276 | expected testStruct 277 | hasError bool 278 | }{ 279 | { 280 | name: "Full Struct", 281 | source: cloudconfigclient.Source{ 282 | PropertySources: []cloudconfigclient.PropertySource{ 283 | { 284 | Name: "application-foo.yml", 285 | Source: map[string]interface{}{ 286 | "stringVal": "value1", 287 | "intVal": 1, 288 | "sliceString[0]": "value2", 289 | "sliceInt[0]": 2, 290 | "sliceStruct[0].stringVal": "value5", 291 | "sliceStruct[0].intVal": 4, 292 | "sliceStruct[1].intVal": 5, 293 | "sliceStruct[1].stringVal": "value6", 294 | }, 295 | }, 296 | }, 297 | }, 298 | expected: testStruct{ 299 | StringVal: "value1", 300 | IntVal: 1, 301 | SliceString: []string{ 302 | "value2", 303 | }, 304 | SliceInt: []int{ 305 | 2, 306 | }, 307 | SliceStruct: []nestedStruct{ 308 | { 309 | StringVal: "value5", 310 | IntVal: 4, 311 | }, 312 | { 313 | StringVal: "value6", 314 | IntVal: 5, 315 | }, 316 | }, 317 | }, 318 | }, 319 | { 320 | name: "Split Across Files", 321 | source: cloudconfigclient.Source{ 322 | PropertySources: []cloudconfigclient.PropertySource{ 323 | { 324 | Name: "application-foo.yml", 325 | Source: map[string]interface{}{ 326 | "stringVal": "value1", 327 | "intVal": 1, 328 | "sliceString[0]": "value2", 329 | "sliceInt[0]": 2, 330 | }, 331 | }, 332 | { 333 | Name: "application-foo-dev.yml", 334 | Source: map[string]interface{}{ 335 | "sliceStruct[0].stringVal": "value5", 336 | "sliceStruct[0].intVal": 4, 337 | "sliceStruct[1].intVal": 5, 338 | "sliceStruct[1].stringVal": "value6", 339 | }, 340 | }, 341 | }, 342 | }, 343 | expected: testStruct{ 344 | StringVal: "value1", 345 | IntVal: 1, 346 | SliceString: []string{ 347 | "value2", 348 | }, 349 | SliceInt: []int{ 350 | 2, 351 | }, 352 | SliceStruct: []nestedStruct{ 353 | { 354 | StringVal: "value5", 355 | IntVal: 4, 356 | }, 357 | { 358 | StringVal: "value6", 359 | IntVal: 5, 360 | }, 361 | }, 362 | }, 363 | }, 364 | } 365 | for _, test := range tests { 366 | t.Run(test.name, func(t *testing.T) { 367 | var actual testStruct 368 | err := test.source.Unmarshal(&actual) 369 | if test.hasError { 370 | assert.Error(t, err) 371 | } else { 372 | assert.NoError(t, err) 373 | assert.Equal(t, test.expected, actual) 374 | } 375 | }) 376 | } 377 | } 378 | 379 | type testStruct struct { 380 | StringVal string `json:"stringVal"` 381 | IntVal int `json:"intVal"` 382 | SliceString []string `json:"sliceString"` 383 | SliceInt []int `json:"sliceInt"` 384 | SliceStruct []nestedStruct `json:"sliceStruct"` 385 | } 386 | 387 | type nestedStruct struct { 388 | StringVal string `json:"stringVal"` 389 | IntVal int `json:"intVal"` 390 | } 391 | --------------------------------------------------------------------------------