diff --git a/.github/workflows/go-list.yaml b/.github/workflows/go-list.yaml new file mode 100644 index 0000000..e16f32c --- /dev/null +++ b/.github/workflows/go-list.yaml @@ -0,0 +1,20 @@ +name: Go + +on: + push: + tags: + - '*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: go list + run: GOPROXY=proxy.golang.org go list -m github.com/loveholidays/go-config-loader@v${{ github.ref_name }} \ No newline at end of file diff --git a/.github/workflows/go-test.yaml b/.github/workflows/go-test.yaml new file mode 100644 index 0000000..d8f4502 --- /dev/null +++ b/.github/workflows/go-test.yaml @@ -0,0 +1,20 @@ +name: Go + +on: + push: + branches: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: Test + run: make test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7800b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +target/ +classes/ +.DS_Store + +.mvn/timing.properties + +*.class +.idea/ +.env +/venv +/.venv + +*.iml + +/**/bin + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ + +# Go workspace file +go.work +go.work.sum +.tool-versions diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..38b84ca --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,450 @@ +# See: https://olegk.dev/go-linters-configuration-the-right-version + +run: + # Depends on your hardware, my laptop can survive 8 threads. + concurrency: 8 + + # I really care about the result, so I'm fine to wait for it. + timeout: 30m + + # Fail if the error was met. + issues-exit-code: 1 + + # This is very important, bugs in tests are not acceptable either. + tests: true + + # In most cases this can be empty but there is a popular pattern + # to keep integration tests under this tag. Such tests often require + # additional setups like Postgres, Redis etc and are run separately. + # (to be honest I don't find this useful but I have such tags) + build-tags: + - integration + + # Up to you, good for a big enough repo with no-Go code. + skip-dirs: + # - src/external_libs + + # When enabled linter will skip directories: vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ + # Skipping `examples` sounds scary to me but skipping `testdata` sounds ok. + skip-dirs-use-default: false + + # Autogenerated files can be skipped (I'm looking at you gRPC). + # AFAIK autogen files are skipped but skipping the whole directory should be somewhat faster. + #skip-files: + # - "protobuf/.*.go" + + # With the read-only mode linter will fail if go.mod file is outdated. + modules-download-mode: readonly + + # Till today I didn't know this param exists, never ran 2 golangci-lint at once. + allow-parallel-runners: false + + # Keep this empty to use the Go version from the go.mod file. + go: "" + +linters: + # Set to true runs only fast linters. + # Good option for 'lint on save', pre-commit hook or CI. + fast: false + + enable: + # Check for pass []any as any in variadic func(...any). + # Rare case but saved me from debugging a few times. + - asasalint + + # I prefer plane ASCII identifiers. + # Symbol `∆` instead of `delta` looks cool but no thanks. + - asciicheck + + # Checks for dangerous unicode character sequences. + # Super rare but why not to be a bit paranoid? + - bidichk + + # Checks whether HTTP response body is closed successfully. + - bodyclose + + # Check whether the function uses a non-inherited context. + - contextcheck + + # Check for two durations multiplied together. + - durationcheck + + # Forces to not skip error check. + - errcheck + + # Checks `Err-` prefix for var and `-Error` suffix for error type. + - errname + + # Suggests to use `%w` for error-wrapping. + - errorlint + + # Checks for pointers to enclosing loop variables. + - exportloopref + + # As you already know I'm a co-author. It would be strange to not use + # one of my warmly loved projects. + - gocritic + + # Forces to put `.` at the end of the comment. Code is poetry. + - godot + + # Might not be that important but I prefer to keep all of them. + # `gofumpt` is amazing, kudos to Daniel Marti https://github.com/mvdan/gofumpt + - gofmt +# - gofumpt TODO do we want to enforce this? Then we should add to intellij style settings +# - goimports TODO do we want to enforce this? Then we should add to intellij style settings + + # Allow or ban replace directives in go.mod + # or force explanation for retract directives. + - gomoddirectives + + # Powerful security-oriented linter. But requires some time to + # configure it properly, see https://github.com/securego/gosec#available-rules + - gosec + + # Linter that specializes in simplifying code. + - gosimple + + # Official Go tool. Must have. + - govet + + # Detects when assignments to existing variables are not used + # Last week I caught a bug with it. + - ineffassign + + # Even with deprecation notice I find it useful. + # There are situations when instead of io.ReaderCloser + # I can use io.Reader. A small but good improvement. +# - interfacer + + # Fix all the misspells, amazing thing. + - misspell + + # Finds naked/bare returns and requires change them. + - nakedret + + # Both require a bit more explicit returns. + - nilerr +# - nilnil + + # Finds sending HTTP request without context.Context. + - noctx + + # Forces comment why another check is disabled. + # Better not to have //nolint: at all ;) + - nolintlint + + # Finds slices that could potentially be pre-allocated. + # Small performance win + cleaner code. + - prealloc + + # Finds shadowing of Go's predeclared identifiers. + # I hear a lot of complaints from junior developers. + # But after some time they find it very useful. + - predeclared + + # Lint your Prometheus metrics name. + - promlinter + + # Checks that package variables are not reassigned. + # Super rare case but can catch bad things (like `io.EOF = nil`) + - reassign + + # Drop-in replacement of `golint`. + - revive + + # Somewhat similar to `bodyclose` but for `database/sql` package. + - rowserrcheck + - sqlclosecheck + + # I have found that it's not the same as staticcheck binary :\ + - staticcheck + + # Is a replacement for `golint`, similar to `revive`. + - stylecheck + + # Check struct tags. + - tagliatelle + + # Test-related checks. All of them are good. + - tenv + - testableexamples + - thelper + - tparallel + + # Remove unnecessary type conversions, make code cleaner + - unconvert + + # Might be noisy but better to know what is unused + - unparam + + # Must have. Finds unused declarations. + - unused + + # Detect the possibility to use variables/constants from stdlib. + - usestdlibvars + + # Finds wasted assignment statements. + - wastedassign + + disable: + # Detects struct contained context.Context field. Not a problem. + - containedctx + + # Checks function and package cyclomatic complexity. + # I can have a long but trivial switch-case. + # + # Cyclomatic complexity is a measurement, not a goal. + # (c) Bryan C. Mills / https://github.com/bcmills + - cyclop + + # Abandoned, replaced by `unused`. + - deadcode + + # Check declaration order of types, consts, vars and funcs. + # I like it but I don't use it. + - decorder + + # Checks if package imports are in a list of acceptable packages. + # I'm very picky about what I import, so no automation. + - depguard + + # Checks assignments with too many blank identifiers. Very rare. + - dogsled + + # Tool for code clone detection. + - dupl + + # Find duplicate words, rare. + - dupword + + # I'm fine to check the error from json.Marshal ¯\_(ツ)_/¯ + - errchkjson + + # All SQL queries MUST BE covered with tests. + - execinquery + + # Forces to handle more cases. Cool but noisy. + - exhaustive + - exhaustivestruct # Deprecated, replaced by check below. + - exhaustruct + + # Forbids some identifiers. I don't have a case for it. + - forbidigo + + # Finds forced type assertions, very good for juniors. + - forcetypeassert + + # I might have long but a simple function. + - funlen + + # Imports order. I do this manually ¯\_(ツ)_/¯ + - gci + + # I'm not a fan of ginkgo and gomega packages. + - ginkgolinter + + # Checks that compiler directive comments (//go:) are valid. Rare. + - gocheckcompilerdirectives + + # Globals and init() are ok. + - gochecknoglobals + - gochecknoinits + + # Same as `cyclop` linter (see above) + - gocognit + - goconst + - gocyclo + + # TODO and friends are ok. + - godox + + # Check the error handling expressions. Too noisy. + - goerr113 + + # I don't use file headers. + - goheader + + # 1st Go linter, deprecated :( use `revive`. + - golint + + # Reports magic consts. Might be noisy but still good. + - gomnd + + # Allowed/blocked packages to import. I prefer to do it manually. + - gomodguard + + # Printf-like functions must have -f. + - goprintffuncname + + # Groupt declarations, I prefer manually. + - grouper + + # Deprecated. + - ifshort + + # Checks imports aliases, rare. + - importas + + # Forces tiny interfaces, very subjective. + - interfacebloat + + # Accept interfaces, return types. Not always. + - ireturn + + # I don't set line length. 120 is fine by the way ;) + - lll + + # Some log checkers, might be useful. + - loggercheck + + # Maintainability index of each function, subjective. + - maintidx + + # Slice declarations with non-zero initial length. Not my case. + - makezero + + # Deprecated. Use govet `fieldalignment`. + - maligned + + # Enforce tags in un/marshaled structs. Cool but not my case. + - musttag + + # Deeply nested if statements, subjective. + - nestif + + # Forces newlines in some places. + - nlreturn + + # Reports all named returns, not that bad. + - nonamedreturns + + # Deprecated. Replaced by `revive`. + - nosnakecase + + # Finds misuse of Sprintf with host:port in a URL. Cool but rare. + - nosprintfhostport + + # I don't use t.Parallel() that much. + - paralleltest + + # Often non-`_test` package is ok. + - testpackage + + # Compiler can do it too :) + - typecheck + + # I'm fine with long variable names with a small scope. + - varnamelen + + # gofmt,gofumpt covers that (from what I know). + - whitespace + + # Don't find it useful to wrap all errors from external packages. + - wrapcheck + + # Forces you to use empty lines. Great if configured correctly. + # I mean there is an agreement in a team. + - wsl + +linters-settings: + # I'm biased and I'm enabling more than 100 checks + # Might be too much for you. See https://go-critic.com/overview.html + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + # These 3 will detect many cases, but they do sense + # if it's performance oriented code + - hugeParam + - rangeExprCopy + - rangeValCopy + + errcheck: + # Report `a := b.(MyStruct)` when `a, ok := ...` should be. + check-type-assertions: true # Default: false + + # Report skipped checks:`num, _ := strconv.Atoi(numStr)`. + check-blank: true # Default: false + + # Function to skip. + exclude-functions: + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - io.Copy(os.Stdout) + + govet: + disable: + - fieldalignment # I'm ok to waste some bytes + + nakedret: + # No naked returns, ever. + max-func-lines: 1 # Default: 30 + + tagliatelle: + case: + rules: + json: snake # why it's not a `snake` by default?! + yaml: snake # why it's not a `snake` by default?! + xml: camel + bson: camel + avro: snake + mapstructure: kebab + +# See also https://gist.github.com/cristaloleg/dc29ca0ef2fb554de28d94c3c6f6dc88 + +output: + # I prefer the simplest one: `line-number` and saving to `lint.txt` + # + # The `tab` also looks good and with the next release I will switch to it + # (ref: https://github.com/golangci/golangci-lint/issues/3728) + # + # There are more formats which can be used on CI or by your IDE. +# format: line-number:lint.txt + + # I do not find this useful, parameter above already enables filepath + # with a line and column. For me, it's easier to follow the path and + # see the line in an IDE where I see more code and understand it better. +# print-issued-lines: false + + # Must have. Easier to understand the output. + print-linter-name: true + + # No, no skips, everything should be reported. + uniq-by-line: false + + # To be honest no idea when this can be needed, maybe a multi-module setup? + path-prefix: "" + + # Slightly easier to follow the results + getting deterministic output. + sort-results: true + +issues: + # I found it strange to skip the errors, setting 0 to have all the results. + max-issues-per-linter: 0 + + # Same here, nothing should be skipped to not miss errors. + max-same-issues: 0 + + # When set to `true` linter will analyze only new code which are + # not committed or after some specific revision. This is a cool + # feature when you're going to introduce linter into a big project. + # But I prefer going gradually package by package. + # So, it's set to `false` to scan all code. + new: false + + # 2 other params regarding git integration + + # Even with a recent GPT-4 release I still believe that + # I know better how to do my job and fix the suggestions. + fix: false + + exclude-rules: + - path: _test\.go + linters: + - revive + text: "dot-imports:" \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f432a50 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +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, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behaviour that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +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. + +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. + +## Scope + +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. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team: + +- supply@loveholidays.com + +All complaints will be reviewed and investigated and will result in a response that +is deemed necessary and 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. + +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. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3e253cd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Development + +Thanks for contributing! We want to ensure that `go-config-loader` evolves and fulfills +its idea of extensibility and flexibility by seeing continuous improvements +and enhancements, no matter how small or big they might be. + +## How to contribute? + +We follow fairly standard but lenient rules around pull requests and issues. +Please pick a title that describes your change briefly, optionally in the imperative +mood if possible. + +If you have an idea for a feature or want to fix a bug, consider opening an issue +first. We're also happy to discuss and help you open a PR and get your changes +in! + +- If you have a question, + try [creating a GitHub Discussions thread.](https://github.com/loveholidays/go-config-loader/discussions/new) +- If you think you've found a + bug, [open a new issue.](https://github.com/loveholidays/go-config-loader/issues/new/choose) +- or, if you found a bug you'd like to fix, [open a PR.](https://github.com/loveholidays/go-config-loader/compare) +- If you'd like to propose a + change [open a new issue.](https://github.com/loveholidays/go-config-loader/issues/new/choose) + +### What are the issue conventions? + +There are **no strict conventions**, but we do have two templates in place that will fit most +issues, since questions and other discussion start on GitHub Discussions. The bug template is fairly +standard and the rule of thumb is to try to explain **what you expected** and **what you got +instead.** Following this makes it very clear whether it's a known behavior, an unexpected issue, +or an undocumented quirk. + +We do ask that issues _aren’t_ created for questions, or where a bug is likely to be either caused +by misusage or misconfiguration. In short, if you can’t provide a reproduction of the issue, then +it may be the case that you’ve got a question instead. + +### How do I propose changes? + +We follow **no strict process** when it comes to proposing changes. +Simply [raise an issue](https://github.com/loveholidays/go-config-loader/issues/new/choose) +in github. This allows us to track what's being worked on by who and keep our feature requests in a centralised place. + +### What are the PR conventions? + +This also comes with **no strict conventions**. We only ask you to follow the PR template we have +in place more strictly here than the templates for issues, since it asks you to list a summary +(maybe even with a short explanation) and a list of technical changes. + +If you're **resolving** an issue please don't forget to add `Resolve #123` to the description so that +it's automatically linked, so that there's no ambiguity and which issue is being addressed (if any) + +We also typically **name** our PRs with a slightly descriptive title, e.g. `(shortcode) - Title`, +where shortcode is either the name of a package, e.g. `(core)` and the title is an imperative mood +description, e.g. "Update X" or "Refactor Y." + +## How do I set up the project? + +Luckily it's not hard to get started. You can install dependencies +[using `make`](https://www.gnu.org/software/make/). + +```sh +make build +``` + +Other useful make commands: + +```sh +# Unit tests +make test + +# Linting (golangci lint): +make check + +``` + +You can find the main packages in `pkg/*`. + +## How do I test my changes? + +It's always good practice to run the tests when making changes. If you're unsure which packages +may be affected by your new tests or changes you may run `make test` in the root of +the repository. + +## How do I lint my code? + +We ensure consistency in `go-config-loader`'s codebase using `golangci lint`. +The lint can be run can be run using `make check`. It runs as part of the `make build` step of CI and will highlight any +errors when this step is run. +The rules for the lint can be found in `.golangci.yaml`. + +## How do I release new versions of our packages? + +We have a [GitHub Actions workflow](./.github/workflow/go-list.yml) which is triggered whenever a new +tag is created. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..153d416 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a862140 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +#@ Helpers +# from https://www.thapaliya.com/en/writings/well-documented-makefiles/ +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Tools +tools: ## Installs required binaries locally + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install github.com/onsi/ginkgo/v2/ginkgo@latest + +##@ Cleanup +clean: ## Deletes binaries from the bin folder + @echo "== clean" + rm -rfv ./bin + +##@ Tests +test: check ## Run unit tests + @echo "== unit test" + if [ -z "$(DOCKER_RUNNING)" ]; then \ + ginkgo ./...; \ + else \ + ginkgo --skip-file e2e_test.go ./...; \ + fi + +##@ Run static checks +check: tools ## Runs lint, fmt and vet checks against the codebase + golangci-lint run + go fmt ./... + go vet ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..46d883d --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Go Config Loader + +go-config-loader is a library that allows config to be read from yaml files. A common problem with reading from config +files in go is ensuring required values are set. Historically there are two ways of handling this: + +1. Set up config variables as non-nullable and error if a field is set to its zero value. + The issue with this is that a variable's zero value can be a valid input to an application. +2. Set up config variables as nullable and error if a field is set to null. + This solves the issue with zero values but still means every field in the config object needs to be checked. It also + means code that relies on the config now has to handle nullability. + +Introducing go-config-loader. The library allows the setting of a required flag in the object declaration. When the flag +is set, the library will check the config yaml file for the variable and error if it isn't present. This solves the zero +values issue and means there is no need to make config variables nullable. The flag is set to false by default. + +The library also has the ability to interpolate environment variable values into yaml files when the variables are +formatted in the way illustrated below. This can be useful for reading in secrets and sensitive content that should not +be stored in version control. The `api_key` variable will be set to the value of `MY_SECRET_API_KEY` when the config is +loaded. + +```yaml +api_key: "$MY_SECRET_API_KEY" +``` + +## Getting Started + +### Simple Example + +```go +package main + +import ( + config "github.com/loveholidays/go-config-loader" +) + +type genericConfig struct { + DummyConfig1 string `yaml:"dummy_config_1" required:"true"` + DummyConfig2 string `yaml:"dummy_config_2"` + DummyConfig3 string `yaml:"dummy_config_3"` +} + +func main() { + _, err := config.LoadConfiguration[genericConfig]("config.yaml") +} +``` \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..34fdb73 --- /dev/null +++ b/config.go @@ -0,0 +1,97 @@ +package goconfigloader + +import ( + "errors" + "fmt" + "os" + "reflect" + "strings" + + "gopkg.in/yaml.v3" +) + +// LoadConfiguration loads config values from the yaml file passed to it via the configPath variable. It returns a +// struct of type configT. The values of the configT struct will be set to the values inside the yaml file. If a +// value marked as required:"true" in the struct is not present in the yaml file, the function will return an error. +func LoadConfiguration[configT interface{}](configPath string) (*configT, error) { + file, err := os.ReadFile(configPath) + + if err != nil { + return nil, err + } + + expandedYaml, err := expandEnvironmentVariables(file) + + if err != nil { + return nil, err + } + + var config configT + err = unmarshalAndValidate([]byte(expandedYaml), &config) + if err != nil { + return nil, err + } + + return &config, nil +} + +func expandEnvironmentVariables(file []byte) (string, error) { + fileText := string(file) + + var missingKeys []string + expandedYaml := os.Expand(fileText, func(key string) string { + expanded := os.Getenv(key) + if expanded == "" { + missingKeys = append(missingKeys, key) + } + return expanded + }) + + if len(missingKeys) != 0 { + errorMessage := "Missing required environment variables: " + strings.Join(missingKeys, ",") + return "", errors.New(errorMessage) + } + return expandedYaml, nil +} + +func unmarshalAndValidate(data []byte, out interface{}) error { + var fieldsMap map[string]interface{} + if err := yaml.Unmarshal(data, &fieldsMap); err != nil { + return err + } + + if err := yaml.Unmarshal(data, out); err != nil { + return err + } + + return validateFields(reflect.ValueOf(out).Elem(), fieldsMap, "") +} + +func validateFields(val reflect.Value, fieldsMap map[string]interface{}, prefix string) error { + valType := val.Type() + for i := 0; i < val.NumField(); i++ { + field := valType.Field(i) + yamlTag := field.Tag.Get("yaml") + required, hasRequiredTag := field.Tag.Lookup("required") + + yamlPath := yamlTag + if prefix != "" { + yamlPath = prefix + "." + yamlTag + } + + if _, found := fieldsMap[yamlTag]; !found && hasRequiredTag && required == "true" { + return fmt.Errorf("required field '%s' is missing in YAML input", yamlPath) + } + + if field.Type.Kind() == reflect.Struct { + nestedFieldsMap, ok := fieldsMap[yamlTag].(map[string]interface{}) + if !ok { + nestedFieldsMap = make(map[string]interface{}) // Handle case where the nested struct is not in the map + } + if err := validateFields(val.Field(i), nestedFieldsMap, yamlPath); err != nil { + return err + } + } + } + return nil +} diff --git a/config.test.bool.yaml b/config.test.bool.yaml new file mode 100644 index 0000000..225da42 --- /dev/null +++ b/config.test.bool.yaml @@ -0,0 +1 @@ +dummy_config_1: false diff --git a/config.test.missing-field.yaml b/config.test.missing-field.yaml new file mode 100644 index 0000000..1adf5bb --- /dev/null +++ b/config.test.missing-field.yaml @@ -0,0 +1 @@ +dummy_config_2: "$DUMMY_CONFIG_2_COMES_FROM_ENV" diff --git a/config.test.nested.missing-pointer.yaml b/config.test.nested.missing-pointer.yaml new file mode 100644 index 0000000..6d92a32 --- /dev/null +++ b/config.test.nested.missing-pointer.yaml @@ -0,0 +1,3 @@ +dummy_config_1: + nested_field_1: false +dummy_config_2: "yep" diff --git a/config.test.nested.missing.yaml b/config.test.nested.missing.yaml new file mode 100644 index 0000000..6c613f9 --- /dev/null +++ b/config.test.nested.missing.yaml @@ -0,0 +1,3 @@ +dummy_config_1: + nested_pointer_1: "yes" +dummy_config_2: "yep" diff --git a/config.test.nested.yaml b/config.test.nested.yaml new file mode 100644 index 0000000..9768346 --- /dev/null +++ b/config.test.nested.yaml @@ -0,0 +1,4 @@ +dummy_config_1: + nested_field_1: false + nested_pointer_1: "yes" +dummy_config_2: "yep" diff --git a/config.test.yaml b/config.test.yaml new file mode 100644 index 0000000..6e46950 --- /dev/null +++ b/config.test.yaml @@ -0,0 +1,5 @@ +inner: + otherNumber: 10 + otherString: $ENV_VAR +someNumber: 12 +someString: some \ No newline at end of file diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..510f224 --- /dev/null +++ b/config_test.go @@ -0,0 +1,123 @@ +//nolint:tagliatelle //Yaml camel case instead of snake case +package goconfigloader_test + +import ( + "fmt" + config "github.com/loveholidays/go-config-loader" + "os" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type TestConfig struct { + InnerConfig InnerConfig `yaml:"inner"` + SomeNumber int `yaml:"someNumber"` + SomeString string `yaml:"someString"` +} + +type InnerConfig struct { + OtherNumber int `yaml:"otherNumber"` + OtherString string `yaml:"otherString"` +} + +type genericConfig struct { + DummyConfig1 string `yaml:"dummy_config_1" required:"true"` + DummyConfig2 string `yaml:"dummy_config_2"` + DummyConfig3 string `yaml:"dummy_config_3"` +} + +type nestedConfig struct { + DummyConfig1 nestedField `yaml:"dummy_config_1"` + DummyConfig2 string `yaml:"dummy_config_2"` +} + +type nestedField struct { + NestedField1 bool `yaml:"nested_field_1" required:"true"` + NestedField2 *string `yaml:"nested_pointer_1" required:"true"` +} + +func TestLoadConfiguration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shared Config") +} + +var _ = Describe("Shared Config", func() { + + Describe("Expanding environment variables in YAML", func() { + It("returns error for missing variables", func() { + cfg, err := config.LoadConfiguration[TestConfig]("config.test.yaml") + + Expect(cfg).To(BeNil()) + Expect(err.Error()).To(Equal("Missing required environment variables: ENV_VAR")) + }) + }) + + Describe("YAML file parsing", func() { + It("unmarshalls into cfg", func() { + os.Clearenv() + _ = os.Setenv("ENV_VAR", "other") + cfg, err := config.LoadConfiguration[TestConfig]("config.test.yaml") + Expect(err).To(BeNil()) + + expected := &TestConfig{ + InnerConfig: InnerConfig{ + OtherNumber: 10, + OtherString: "other", + }, + SomeNumber: 12, + SomeString: "some", + } + + Expect(cfg).To(Equal(expected)) + }) + + }) + + Describe("Required fields", func() { + It("should fail to parse config when required fields are missing", func() { + os.Clearenv() + GinkgoT().Setenv("DUMMY_CONFIG_2_COMES_FROM_ENV", "set from env") + _, err := config.LoadConfiguration[genericConfig]("config.test.missing-field.yaml") + + Expect(err).To(HaveOccurred()) + fmt.Printf("%s", err.Error()) + Expect(err.Error()).To(Equal("required field 'dummy_config_1' is missing in YAML input")) + }) + + It("should not fail when false boolean is explicitly set", func() { + os.Clearenv() + GinkgoT().Setenv("DUMMY_CONFIG_2_COMES_FROM_ENV", "set from env") + _, err := config.LoadConfiguration[configWithBoolean]("config.test.bool.yaml") + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should not fail when false boolean is explicitly set as nested field", func() { + os.Clearenv() + GinkgoT().Setenv("DUMMY_CONFIG_2_COMES_FROM_ENV", "set from env") + _, err := config.LoadConfiguration[nestedConfig]("config.test.nested.yaml") + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should fail when boolean is not set as nested field", func() { + os.Clearenv() + GinkgoT().Setenv("DUMMY_CONFIG_2_COMES_FROM_ENV", "set from env") + _, err := config.LoadConfiguration[nestedConfig]("config.test.nested.missing.yaml") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("required field 'dummy_config_1.nested_field_1' is missing in YAML input")) + }) + + It("should fail when pointer is not set as nested field", func() { + os.Clearenv() + GinkgoT().Setenv("DUMMY_CONFIG_2_COMES_FROM_ENV", "set from env") + _, err := config.LoadConfiguration[nestedConfig]("config.test.nested.missing-pointer.yaml") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("required field 'dummy_config_1.nested_pointer_1' is missing in YAML input")) + }) + }) +}) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..be7bda9 --- /dev/null +++ b/example_test.go @@ -0,0 +1,20 @@ +package goconfigloader_test + +import ( + "fmt" + config "github.com/loveholidays/go-config-loader" +) + +type configWithBoolean struct { + DummyConfig1 bool `yaml:"dummy_config_1" required:"true"` +} + +func ExampleLoadConfiguration() { + cfg, err := config.LoadConfiguration[configWithBoolean]("config.test.bool.yaml") + if err != nil { + return + } + + fmt.Printf("dummy_config_1: %v", cfg.DummyConfig1) + // Output: dummy_config_1: false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d8a28e --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/loveholidays/go-config-loader + +go 1.23 + +toolchain go1.23.0 + +require ( + github.com/onsi/ginkgo/v2 v2.20.2 + github.com/onsi/gomega v1.34.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bbbf62 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA= +github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= +github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= +github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= +github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=