diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..07e90c0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,40 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{bat,cmd}] +end_of_line = crlf +indent_size = 2 +max_line_length = 120 + +[*.go] +indent_style = tab +max_line_length = 120 + +[*.json] +indent_size = 2 +max_line_length = 120 + +[*.md] +indent_size = 2 + +[*.ps1] +end_of_line = lf +max_line_length = 120 + +[*.sh] +end_of_line = lf +indent_size = 2 +max_line_length = 120 + +[*.{yml,yaml}] +indent_size = 2 +max_line_length = 120 + +[{go.mod,go.sum}] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c2c08db --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,75 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders 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, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [`dev@salad.com`](mailto:dev@salad.com). All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). + +Community Impact guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..c0a28dd --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +Thanks for being here and for being awesome! 👍 + +The following sections outline the different ways to contribute to the project. + +## Discussing + +The easiest way to contribute to the project is by participating in [GitHub discussions](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions). The community often chimes in with helpful advice when you have a question, and you may also find yourself providing answers and helping others. Be sure to review the [code of conduct](./CODE_OF_CONDUCT.md) before participating. + +_Please do not use GitHub issues to ask a question._ We will politely close a GitHub issue that asks a question and kindly refer you to one of the aforementioned avenues. + +## Reporting Bugs + +We're sorry if this happened to you! Consider jumping into [GitHub discussions](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions) first. The community may have already found a solution. + +You can create a [GitHub issue](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/issues) to report bugs. You can also create an [official support request](mailto:cloud@salad.com) if you have a specific question that should be answered by a team member. + +## Requesting Features + +We love a good idea. Do you have one? Consider jumping into [GitHub discussions](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions) first. The community may have some interesting insights. + +You can create a [GitHub issue](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/issues) to request new features. + +## Reporting Security Vulnerabilities + +We take security seriously, and we appreciate your cooperation in disclosing vulnerabilities to us responsibly. Refer to our [security policy](./SECURITY.md) for more details. + +_Please do not use public GitHub issues to report a security vulnerability._ + +## Changing Code + +Interested in changing the world? + +First, take note that the code in this project is automatically generated by [liblab](https://liblab.com/) using the official OpenAPI Specification document for the SaladCloud API. Not all code contributions are accepted as they may simply be overwritten the next time the code is automatically generated. + +Before starting on any code contribution, please discuss it with the team first to ensure it is compatible with this project's toolchain and fits in the product roadmap. We will politely close a GitHub pull request that is not compatible with or cannot be maintained by this project's toolchain. + +Additionally, please consider taking a moment to read Miguel de Icaza's blog post titled [Open Source Contribution Etiquette](https://tirania.org/blog/archive/2010/Dec-31.html) and Ilya Grigorik's blog post titled [Don't "Push" Your Pull Requests](https://www.igvita.com/2011/12/19/dont-push-your-pull-requests/). diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f9fd826 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +name: Report a bug +description: Let us know if you encountered an issue. +labels: + - bug +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this bug report! We encourage you to start with a [GitHub discussion](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions) to ensure this issue is new. If this issue is security related, please disclose vulnerabilities to us responsibly as a [GitHub security advisory](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/security/advisories/new). + - type: textarea + id: actual + attributes: + label: What actually happened? + description: Please provide as much information about the issue as possible. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + description: Please provide as much information about the issue as possible. + validations: + required: true + - type: textarea + id: repro + attributes: + label: How can we reproduce it? + description: Please provide as succinct a reproduction as possible. + validations: + required: true + - type: textarea + id: extra + attributes: + label: Anything else we should know? + - type: textarea + id: sdkVersion + attributes: + label: What is the version of your SaladCloud SDK? + description: e.g. 1.0.1 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..13bd938 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Request support + url: https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions + about: Please use GitHub discussions for general chat and community-provided support. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..dcbaeab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Request a feature +description: Let us know if you have ideas for issues you would like to see supported. +labels: + - enhancement +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this feature request! We encourage you to start with a [GitHub discussion](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions) to ensure this issue fits in the roadmap. If this issue is security related, please disclose vulnerabilities to us responsibly as a [GitHub security advisory](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/security/advisories/new). + - type: textarea + id: feature + attributes: + label: What do you want? + description: Please provide as much information about the issue as possible. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Why is it needed? + description: Please provide as much information about the issue as possible. + validations: + required: true + - type: textarea + id: extra + attributes: + label: Anything else we should know? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..bc6f0af --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..5e4e60c --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,14 @@ +# Security + +## Reporting Security Vulnerabilities + +We take security seriously, and we appreciate your cooperation in disclosing vulnerabilities to us responsibly. + +_Please do not use public GitHub issues to report a security vulnerability._ + +Instead, please do one of the following: + +- Open a GitHub security advisory on the [GitHub repository](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/security/advisories/new) +- Send an email to [`dev@salad.com`](mailto:dev@salad.com) + +Please include as much information as you can provide to help us better understand the nature and scope of the issue. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..7327c36 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,3 @@ +# Support + +We use [GitHub discussions](https://github.com/SaladTechnologies/salad-cloud-imds-sdk-go/discussions) for general chat and community-provided support. The community often chimes in with helpful advice when you have a question, and you may also find yourself providing answers and helping others. Be sure to review the [code of conduct](./CODE_OF_CONDUCT.md) before participating. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0c75515 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish Package + +on: + release: + types: + - published + +env: + MANIFEST_PATH: .manifest.json + +jobs: + publish: + name: Publish Package + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Read module name + id: read_module_name + run: echo "module_name=$(jq -r '.config.languageOptions.go.goModuleName' $MANIFEST_PATH)" >> "$GITHUB_OUTPUT" + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install Dependencies + run: go mod download + - name: List Package to Request Indexing + run: GOPROXY=proxy.golang.org go list -m ${{ steps.read_module_name.outputs.module_name }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..d782c81 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,41 @@ +name: Handle Stale Items + +on: + schedule: + - cron: "0 10 * * *" + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + stale: + name: Handle Stale Items + runs-on: ubuntu-latest + steps: + - name: Apply stale policy + uses: actions/stale@v8 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 30 + days-before-close: 7 + operations-per-run: 25 + remove-stale-when-updated: true + stale-issue-label: "stale" + exempt-issue-labels: "no-stale,help%20wanted,good%20first%20issue" + stale-issue-message: > + There hasn't been activity on this issue in 30 days. + + This issue has been marked stale and will be closed in 7 days if no further activity occurs. + + Issues with the labels `no-stale`, `help wanted`, and `good first issue` are exempt from this policy. + + stale-pr-label: "stale" + exempt-pr-labels: "no-stale" + stale-pr-message: > + There hasn't been any activity on this pull request in 30 days. + + This pull request has been marked stale and will be closed in 7 days if no further activity occurs. + + Pull requests with the label `no-stale` are exempt from this policy. diff --git a/.manifest.json b/.manifest.json new file mode 100644 index 0000000..4b1a98d --- /dev/null +++ b/.manifest.json @@ -0,0 +1,242 @@ +{ + "liblabVersion": "2.1.31", + "date": "2024-09-05T21:55:00.842Z", + "config": { + "language": "go", + "apiId": 1126, + "sdkName": "salad-cloud-imds-sdk", + "sdkVersion": "0.9.0-alpha.1", + "liblabVersion": "2", + "deliveryMethods": ["zip"], + "languages": ["go"], + "specFilePath": "spec.yaml", + "docs": ["snippets"], + "languageOptions": { + "csharp": { + "packageId": "Salad.Cloud.IMDS.SDK", + "authors": [ + { + "name": "salad" + }, + { + "name": "seniorquico" + } + ], + "githubRepoName": "salad-cloud-imds-sdk-dotnet", + "homepage": "https://github.com/saladtechnologies/salad-cloud-imds-sdk-dotnet", + "ignoreFiles": [".gitignore", "LICENSE"], + "liblabVersion": "2", + "sdkVersion": "0.9.0-alpha.1", + "targetBranch": "main" + }, + "go": { + "goModuleName": "github.com/saladtechnologies/salad-cloud-imds-sdk-go", + "githubRepoName": "salad-cloud-imds-sdk-go", + "ignoreFiles": [".gitignore", "LICENSE"], + "liblabVersion": "2", + "sdkVersion": "0.9.0-alpha.1", + "targetBranch": "main" + }, + "java": { + "groupId": "com.salad.cloud", + "artifactId": "imds-sdk", + "developers": [ + { + "name": "SaladCloud Developers", + "email": "dev@salad.com", + "organization": "Salad Technologies", + "organizationUrl": "https://salad.com" + }, + { + "name": "Kyle Dodson", + "email": "kyle@salad.com", + "organization": "Salad Technologies", + "organizationUrl": "https://salad.com" + } + ], + "githubRepoName": "salad-cloud-imds-sdk-java", + "homepage": "https://github.com/saladtechnologies/salad-cloud-imds-sdk-java", + "ignoreFiles": [".gitignore", "LICENSE"], + "liblabVersion": "2", + "sdkVersion": "0.9.0-alpha.1", + "targetBranch": "main" + }, + "python": { + "alwaysInitializeOptionals": true, + "classifiers": [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries" + ], + "projectUrls": { + "Homepage": "https://github.com/saladtechnologies/salad-cloud-imds-sdk-python", + "Documentation": "https://docs.salad.com", + "Repository": "https://github.com/SaladTechnologies/salad-cloud-imds-sdk-python.git", + "Issues": "https://github.com/SaladTechnologies/salad-cloud-imds-sdk-python/issues" + }, + "pypiPackageName": "salad-cloud-imds-sdk", + "authors": [ + { + "email": "dev@salad.com", + "name": "SaladCloud Developers" + }, + { + "email": "kyle@salad.com", + "name": "Kyle Dodson" + } + ], + "githubRepoName": "salad-cloud-imds-sdk-python", + "ignoreFiles": [".gitignore", "LICENSE"], + "liblabVersion": "2", + "sdkVersion": "0.9.0-alpha.1", + "targetBranch": "main" + }, + "typescript": { + "bundle": true, + "exportClassDefault": false, + "httpClient": "fetch", + "npmName": "salad-cloud-imds-sdk", + "npmOrg": "saladtechnologies-oss", + "authors": [ + { + "email": "dev@salad.com", + "name": "SaladCloud Developers" + }, + { + "email": "kyle@salad.com", + "name": "Kyle Dodson" + } + ], + "githubRepoName": "salad-cloud-imds-sdk-javascript", + "homepage": "https://github.com/saladtechnologies/salad-cloud-imds-sdk-javascript", + "ignoreFiles": [".gitignore", "LICENSE"], + "liblabVersion": "2", + "sdkVersion": "0.9.0-alpha.1", + "targetBranch": "main" + } + }, + "publishing": { + "githubOrg": "SaladTechnologies" + }, + "apiName": "SaladCloud IMDS", + "apiVersion": "0.9.0-alpha.1", + "devContainer": true, + "generateEnv": true, + "includeOptionalSnippetParameters": true, + "inferServiceNames": false, + "license": { + "type": "MIT", + "name": "MIT", + "url": "https://opensource.org/licenses/MIT", + "path": "MIT.ejs" + }, + "responseHeaders": false, + "retry": { + "enabled": true, + "maxAttempts": 3, + "retryDelay": 150, + "maxDelay": 5000, + "retryDelayJitter": 50, + "backOffFactor": 2, + "httpCodesToRetry": [408, 429, 500, 502, 503, 504], + "httpMethodsToRetry": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] + }, + "multiTenant": true, + "hooksLocation": { + "bucketKey": "7017/hooks.zip", + "bucketName": "prod-liblab-api-stack-hooks" + }, + "includeWatermark": false, + "goModuleName": "github.com/saladtechnologies/salad-cloud-imds-sdk-go", + "githubRepoName": "salad-cloud-imds-sdk-go", + "ignoreFiles": [".gitignore", "LICENSE"], + "targetBranch": "main", + "deliveryMethod": "zip", + "hooks": { + "enabled": true, + "sourceDir": "/tmp/resources/hooks" + }, + "usesFormData": false, + "authentication": {}, + "environmentVariables": [], + "fileOutput": "/tmp", + "httpLibrary": { + "name": "axios", + "packages": { + "axios": "^1.7.4" + }, + "languages": ["typescript"] + }, + "auth": [], + "customQueries": { + "paths": [], + "rawQueries": [], + "queriesData": [] + } + }, + "files": [ + "internal/validation/validate_array_length.go", + "internal/validation/validate_array_unique.go", + "internal/validation/validate_required.go", + "internal/validation/validate_max.go", + "internal/validation/validate_min.go", + "internal/validation/validate_multiple_of.go", + "internal/validation/validate_pattern.go", + "pkg/saladcloudimdssdk/saladcloudimdssdk.go", + "go.mod", + "internal/clients/rest/client.go", + "internal/utils/utils.go", + "internal/clients/rest/httptransport/request.go", + "internal/clients/rest/httptransport/response.go", + "internal/clients/rest/httptransport/error_response.go", + "internal/clients/rest/handlers/handler_chain.go", + "cmd/examples/example.go", + "internal/clients/rest/handlers/terminating_handler.go", + "internal/clients/rest/handlers/hook_handler.go", + "internal/clients/rest/hooks/hook.go", + "internal/clients/rest/hooks/custom_hook.go", + "internal/clients/rest/handlers/default_headers_handler.go", + "internal/clients/rest/handlers/retry_handler.go", + "internal/clients/rest/handlers/request_validation_handler.go", + "internal/validation/validation.go", + "internal/clients/rest/handlers/response_validation_handler.go", + "internal/unmarshal/unmarshal.go", + "internal/clients/rest/handlers/unmarshal_handler.go", + "internal/unmarshal/to_complex_object.go", + "internal/unmarshal/to_object.go", + "internal/unmarshal/to_primitive.go", + "internal/marshal/from_complex_object.go", + "pkg/saladcloudimdssdkconfig/config.go", + "internal/configmanager/config_manager.go", + "pkg/shared/salad_cloud_imds_sdk_response.go", + "pkg/shared/salad_cloud_imds_sdk_error.go", + "pkg/saladcloudimdssdkconfig/environments.go", + "./LICENSE", + ".env.example", + "documentation/snippets/v1-reallocate-post.md", + "documentation/snippets/v1-status-get.md", + "documentation/snippets/v1-token-get.md", + "documentation/models/reallocate_container.md", + "documentation/models/container_status.md", + "documentation/models/container_token.md", + "pkg/metadata/metadata_service.go", + "pkg/metadata/reallocate_container.go", + "pkg/metadata/container_status.go", + "pkg/metadata/container_token.go", + "documentation/services/metadata_service.md", + "README.md" + ] +} diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..1bb1ac7 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,11 @@ +endOfLine: "auto" +printWidth: 120 +semi: false +singleQuote: true +trailingComma: "all" +overrides: + - files: + - "*.yaml" + - "*.yml" + options: + singleQuote: false diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1d34144 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "github.vscode-github-actions", + "golang.go", + "ms-vscode-remote.remote-containers", + "streetsidesoftware.code-spell-checker" + ], + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bfc91aa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,36 @@ +{ + "cSpell.languageSettings": [ + { + "languageId": "go", + "includeRegExpList": ["CStyleComment", "string"], + "ignoreRegExpList": [ + "import\\s*\\((.|[\r\n])*?\\)", + "import\\s*.*\".*?\"", + "//\\s*go:generate.*", + "//\\s*nolint:.*" + ] + } + ], + "editor.formatOnSave": true, + "editor.minimap.maxColumn": 120, + "editor.renderWhitespace": "all", + "editor.rulers": [120], + "files.associations": { + "CODEOWNERS": "ignore" + }, + "git.branchProtection": ["main"], + "go.lintTool": "golangci-lint", + "go.toolsManagement.autoUpdate": true, + "gopls": { + "ui.semanticTokens": true + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1611d5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Salad Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bff03a5..f54f155 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# SaladCloud IMDS SDK for Go +# SaladCloudImdsSdk Go SDK 0.9.0-alpha.1 + +Welcome to the SaladCloudImdsSdk SDK documentation. This guide will help you get started with integrating and using the SaladCloudImdsSdk SDK in your project. + +## Versions + +- API version: `0.9.0-alpha.1` +- SDK version: `0.9.0-alpha.1` + +## About the API + +The SaladCloud Instance Metadata Service (IMDS). Please refer to the [SaladCloud API Documentation](https://docs.salad.com/api-reference) for more details. + +## Table of Contents + +- [Setup & Configuration](#setup--configuration) + - [Supported Language Versions](#supported-language-versions) + - [Installation](#installation) +- [Services](#services) +- [Models](#models) +- [License](#license) + +# Setup & Configuration + +## Supported Language Versions + +This SDK is compatible with the following versions: `Go >= 1.19.0` + +## Services + +The SDK provides various services to interact with the API. + +
+Below is a list of all available services with links to their detailed documentation: + +| Name | +| :------------------------------------------------------------ | +| [MetadataService](documentation/services/metadata_service.md) | + +
+ +## Models + +The SDK includes several models that represent the data structures used in API requests and responses. These models help in organizing and managing the data efficiently. + +
+Below is a list of all available models with links to their detailed documentation: + +| Name | Description | +| :------------------------------------------------------------------ | :------------------------------------------------------- | +| [ReallocateContainer](documentation/models/reallocate_container.md) | Represents a request to reallocate a container. | +| [ContainerStatus](documentation/models/container_status.md) | Represents the health statuses of the running container. | +| [ContainerToken](documentation/models/container_token.md) | Represents the identity token of the running container. | + +
+ +## License + +This SDK is licensed under the MIT License. + +See the [LICENSE](LICENSE) file for more details. diff --git a/cmd/examples/example.go b/cmd/examples/example.go new file mode 100644 index 0000000..5a886cc --- /dev/null +++ b/cmd/examples/example.go @@ -0,0 +1,55 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/metadata" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" +) + +func main() { + loadEnv() + + config := saladcloudimdssdkconfig.NewConfig() + client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + + request := metadata.ReallocateContainer{} + request.SetReason("Reason") + + response, err := client.Metadata.ReallocateContainer(context.Background(), request) + if err != nil { + panic(err) + } + fmt.Printf("%+v", response) +} + +func loadEnv() error { + file, err := os.Open(".env") + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + os.Setenv(key, value) + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} diff --git a/documentation/models/container_status.md b/documentation/models/container_status.md new file mode 100644 index 0000000..bf9ffa7 --- /dev/null +++ b/documentation/models/container_status.md @@ -0,0 +1,10 @@ +# ContainerStatus + +Represents the health statuses of the running container. + +**Properties** + +| Name | Type | Required | Description | +| :------ | :--- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Ready | bool | ✅ | `true` if the running container is ready. If a readiness probe is defined, this returns the latest result of the probe. If a readiness probe is not defined but a startup probe is defined, this returns the same value as the `started` property. If neither a readiness probe nor a startup probe are defined, returns `true`. | +| Started | bool | ✅ | `true` if the running container is started. If a startup probe is defined, this returns the latest result of the probe. If a startup probe is not defined, returns `true`. | diff --git a/documentation/models/container_token.md b/documentation/models/container_token.md new file mode 100644 index 0000000..7c78a3f --- /dev/null +++ b/documentation/models/container_token.md @@ -0,0 +1,9 @@ +# ContainerToken + +Represents the identity token of the running container. + +**Properties** + +| Name | Type | Required | Description | +| :--- | :----- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Jwt | string | ✅ | The JSON Web Token (JWT) that may be used to identify the running container. The JWT may be verified using the JSON Web Key Set (JWKS) available at https://matrix-rest-api.salad.com/.well-known/stash-jwks.json. | diff --git a/documentation/models/reallocate_container.md b/documentation/models/reallocate_container.md new file mode 100644 index 0000000..b55427c --- /dev/null +++ b/documentation/models/reallocate_container.md @@ -0,0 +1,9 @@ +# ReallocateContainer + +Represents a request to reallocate a container. + +**Properties** + +| Name | Type | Required | Description | +| :----- | :----- | :------- | :---------------------------------------------------------------------------------------------------------------------------- | +| Reason | string | ✅ | The reason for reallocating the container. This value is reported to SaladCloud support for quality assurance of Salad Nodes. | diff --git a/documentation/services/metadata_service.md b/documentation/services/metadata_service.md new file mode 100644 index 0000000..878929f --- /dev/null +++ b/documentation/services/metadata_service.md @@ -0,0 +1,129 @@ +# MetadataService + +A list of all methods in the `MetadataService` service. Click on the method name to view detailed information about that method. + +| Methods | Description | +| :------------------------------------------ | :------------------------------------------------------ | +| [ReallocateContainer](#reallocatecontainer) | Reallocates the running container to another Salad Node | +| [GetContainerStatus](#getcontainerstatus) | Gets the health statuses of the running container | +| [GetContainerToken](#getcontainertoken) | Gets the identity token of the running container | + +## ReallocateContainer + +Reallocates the running container to another Salad Node + +- HTTP Method: `POST` +- Endpoint: `/v1/reallocate` + +**Parameters** + +| Name | Type | Required | Description | +| :------------------ | :------------------ | :------- | :-------------------------- | +| ctx | Context | ✅ | Default go language context | +| reallocateContainer | ReallocateContainer | ✅ | | + +**Return Type** + +`any` + +**Example Usage Code Snippet** + +```go +import ( + "fmt" + "encoding/json" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/metadata" +) + +config := saladcloudimdssdkconfig.NewConfig() +client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + + +request := metadata.ReallocateContainer{} +request.SetReason("Reason") + +response, err := client.Metadata.ReallocateContainer(context.Background(), request) +if err != nil { + panic(err) +} + +fmt.Print(response) +``` + +## GetContainerStatus + +Gets the health statuses of the running container + +- HTTP Method: `GET` +- Endpoint: `/v1/status` + +**Parameters** + +| Name | Type | Required | Description | +| :--- | :------ | :------- | :-------------------------- | +| ctx | Context | ✅ | Default go language context | + +**Return Type** + +`ContainerStatus` + +**Example Usage Code Snippet** + +```go +import ( + "fmt" + "encoding/json" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" +) + +config := saladcloudimdssdkconfig.NewConfig() +client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + +response, err := client.Metadata.GetContainerStatus(context.Background()) +if err != nil { + panic(err) +} + +fmt.Print(response) +``` + +## GetContainerToken + +Gets the identity token of the running container + +- HTTP Method: `GET` +- Endpoint: `/v1/token` + +**Parameters** + +| Name | Type | Required | Description | +| :--- | :------ | :------- | :-------------------------- | +| ctx | Context | ✅ | Default go language context | + +**Return Type** + +`ContainerToken` + +**Example Usage Code Snippet** + +```go +import ( + "fmt" + "encoding/json" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" +) + +config := saladcloudimdssdkconfig.NewConfig() +client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + +response, err := client.Metadata.GetContainerToken(context.Background()) +if err != nil { + panic(err) +} + +fmt.Print(response) +``` diff --git a/documentation/snippets/v1-reallocate-post.md b/documentation/snippets/v1-reallocate-post.md new file mode 100644 index 0000000..5c3a1ac --- /dev/null +++ b/documentation/snippets/v1-reallocate-post.md @@ -0,0 +1,24 @@ +```go +import ( + "fmt" + "encoding/json" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/metadata" +) + +config := saladcloudimdssdkconfig.NewConfig() +client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + + +request := metadata.ReallocateContainer{} +request.SetReason("Reason") + +response, err := client.Metadata.ReallocateContainer(context.Background(), request) +if err != nil { + panic(err) +} + +fmt.Print(response) + +``` diff --git a/documentation/snippets/v1-status-get.md b/documentation/snippets/v1-status-get.md new file mode 100644 index 0000000..c2f1cae --- /dev/null +++ b/documentation/snippets/v1-status-get.md @@ -0,0 +1,19 @@ +```go +import ( + "fmt" + "encoding/json" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" +) + +config := saladcloudimdssdkconfig.NewConfig() +client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + +response, err := client.Metadata.GetContainerStatus(context.Background()) +if err != nil { + panic(err) +} + +fmt.Print(response) + +``` diff --git a/documentation/snippets/v1-token-get.md b/documentation/snippets/v1-token-get.md new file mode 100644 index 0000000..009217f --- /dev/null +++ b/documentation/snippets/v1-token-get.md @@ -0,0 +1,19 @@ +```go +import ( + "fmt" + "encoding/json" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdk" +) + +config := saladcloudimdssdkconfig.NewConfig() +client := saladcloudimdssdk.NewSaladCloudImdsSdk(config) + +response, err := client.Metadata.GetContainerToken(context.Background()) +if err != nil { + panic(err) +} + +fmt.Print(response) + +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cff40f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/saladtechnologies/salad-cloud-imds-sdk-go + +go 1.18 diff --git a/internal/clients/rest/client.go b/internal/clients/rest/client.go new file mode 100644 index 0000000..31b6bc9 --- /dev/null +++ b/internal/clients/rest/client.go @@ -0,0 +1,39 @@ +package rest + +import ( + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/handlers" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/hooks" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" +) + +type RestClient[T any] struct { + handlers *handlers.HandlerChain[T] +} + +func NewRestClient[T any](config saladcloudimdssdkconfig.Config) *RestClient[T] { + defaultHeadersHandler := handlers.NewDefaultHeadersHandler[T]() + retryHandler := handlers.NewRetryHandler[T]() + responseValidationHandler := handlers.NewResponseValidationHandler[T]() + unmarshalHandler := handlers.NewUnmarshalHandler[T]() + requestValidationHandler := handlers.NewRequestValidationHandler[T]() + hookHandler := handlers.NewHookHandler[T](hooks.NewCustomHook()) + terminatingHandler := handlers.NewTerminatingHandler[T]() + + handlers := handlers.BuildHandlerChain[T](). + AddHandler(defaultHeadersHandler). + AddHandler(retryHandler). + AddHandler(responseValidationHandler). + AddHandler(unmarshalHandler). + AddHandler(requestValidationHandler). + AddHandler(hookHandler). + AddHandler(terminatingHandler) + + return &RestClient[T]{ + handlers: handlers, + } +} + +func (client *RestClient[T]) Call(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + return client.handlers.CallApi(request) +} diff --git a/internal/clients/rest/handlers/default_headers_handler.go b/internal/clients/rest/handlers/default_headers_handler.go new file mode 100644 index 0000000..a4130ff --- /dev/null +++ b/internal/clients/rest/handlers/default_headers_handler.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "errors" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +type DefaultHeadersHandler[T any] struct { + defaultHeaders map[string]string + nextHandler Handler[T] +} + +func NewDefaultHeadersHandler[T any]() *DefaultHeadersHandler[T] { + defaultHeaders := map[string]string{ + "User-Agent": "go/1.18", + "Content-type": "application/json", + } + + return &DefaultHeadersHandler[T]{ + defaultHeaders: defaultHeaders, + nextHandler: nil, + } +} + +func (h *DefaultHeadersHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + if h.nextHandler == nil { + err := errors.New("Handler chain terminated without terminating handler") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + nextRequest := request.Clone() + + for key, value := range h.defaultHeaders { + nextRequest.SetHeader(key, value) + } + + return h.nextHandler.Handle(nextRequest) +} + +func (h *DefaultHeadersHandler[T]) SetNext(handler Handler[T]) { + h.nextHandler = handler +} diff --git a/internal/clients/rest/handlers/handler_chain.go b/internal/clients/rest/handlers/handler_chain.go new file mode 100644 index 0000000..a1d13c5 --- /dev/null +++ b/internal/clients/rest/handlers/handler_chain.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +type Handler[T any] interface { + Handle(req httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) + SetNext(handler Handler[T]) +} + +type HandlerChain[T any] struct { + head Handler[T] + tail Handler[T] +} + +func BuildHandlerChain[T any]() *HandlerChain[T] { + return &HandlerChain[T]{} +} + +func (chain *HandlerChain[T]) AddHandler(handler Handler[T]) *HandlerChain[T] { + if chain.head == nil { + chain.head = handler + chain.tail = handler + return chain + } + + chain.tail.SetNext(handler) + chain.tail = handler + + return chain +} + +func (chain *HandlerChain[T]) CallApi(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + return chain.head.Handle(request) +} diff --git a/internal/clients/rest/handlers/hook_handler.go b/internal/clients/rest/handlers/hook_handler.go new file mode 100644 index 0000000..101e4c1 --- /dev/null +++ b/internal/clients/rest/handlers/hook_handler.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "errors" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/hooks" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +type HookHandler[T any] struct { + nextHandler Handler[T] + hook hooks.Hook +} + +func NewHookHandler[T any](hook hooks.Hook) *HookHandler[T] { + return &HookHandler[T]{ + hook: hook, + nextHandler: nil, + } +} + +func (h *HookHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + if h.nextHandler == nil { + err := errors.New("Handler chain terminated without terminating handler") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + clonedReq := request.Clone() + hookReq := h.hook.BeforeRequest(&clonedReq, clonedReq.Config.HookParams) + + nextRequest, ok := hookReq.(*httptransport.Request) + if !ok { + err := errors.New("hook returned invalid request") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + response, err := h.nextHandler.Handle(*nextRequest) + if err != nil && err.IsHttpError { + clonedError := err.Clone() + hookError := h.hook.OnError(hookReq, &clonedError, clonedReq.Config.HookParams) + nextError, ok := hookError.(*httptransport.ErrorResponse[T]) + if !ok { + err := errors.New("hook returned invalid error") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + return nil, nextError + } else if err != nil { + return nil, err + } + + clonedResp := response.Clone() + hookResp := h.hook.AfterResponse(hookReq, &clonedResp, clonedReq.Config.HookParams) + nextResponse, ok := hookResp.(*httptransport.Response[T]) + if !ok { + err := errors.New("hook returned invalid response") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + return nextResponse, nil +} + +func (h *HookHandler[T]) SetNext(handler Handler[T]) { + h.nextHandler = handler +} diff --git a/internal/clients/rest/handlers/request_validation_handler.go b/internal/clients/rest/handlers/request_validation_handler.go new file mode 100644 index 0000000..dfd6482 --- /dev/null +++ b/internal/clients/rest/handlers/request_validation_handler.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "errors" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/validation" +) + +type RequestValidationHandler[T any] struct { + nextHandler Handler[T] +} + +func NewRequestValidationHandler[T any]() *RequestValidationHandler[T] { + return &RequestValidationHandler[T]{ + nextHandler: nil, + } +} + +func (h *RequestValidationHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + if h.nextHandler == nil { + err := errors.New("Handler chain terminated without terminating handler") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + err := validation.ValidateData(request.Body) + if err != nil { + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + err = validation.ValidateData(request.Options) + if err != nil { + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + return h.nextHandler.Handle(request) +} + +func (h *RequestValidationHandler[T]) SetNext(handler Handler[T]) { + h.nextHandler = handler +} diff --git a/internal/clients/rest/handlers/response_validation_handler.go b/internal/clients/rest/handlers/response_validation_handler.go new file mode 100644 index 0000000..056ef0e --- /dev/null +++ b/internal/clients/rest/handlers/response_validation_handler.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "errors" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/validation" +) + +type ResponseValidationHandler[T any] struct { + nextHandler Handler[T] +} + +func NewResponseValidationHandler[T any]() *ResponseValidationHandler[T] { + return &ResponseValidationHandler[T]{ + nextHandler: nil, + } +} + +func (h *ResponseValidationHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + if h.nextHandler == nil { + err := errors.New("Handler chain terminated without terminating handler") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + resp, handlerError := h.nextHandler.Handle(request) + if handlerError != nil { + return nil, handlerError + } + + err := validation.ValidateData(resp.Data) + if err != nil { + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + return resp, nil +} + +func (h *ResponseValidationHandler[T]) SetNext(handler Handler[T]) { + h.nextHandler = handler +} diff --git a/internal/clients/rest/handlers/retry_handler.go b/internal/clients/rest/handlers/retry_handler.go new file mode 100644 index 0000000..0fe1934 --- /dev/null +++ b/internal/clients/rest/handlers/retry_handler.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "errors" + "time" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +const ( + maxRetries = 3 + retryDelay = 150 * time.Millisecond +) + +type RetryHandler[T any] struct { + nextHandler Handler[T] +} + +func NewRetryHandler[T any]() *RetryHandler[T] { + return &RetryHandler[T]{ + nextHandler: nil, + } +} + +func (h *RetryHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + if h.nextHandler == nil { + err := errors.New("Handler chain terminated without terminating handler") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + var err *httptransport.ErrorResponse[T] + for tryCount := 0; tryCount < maxRetries; tryCount++ { + nextRequest := request.Clone() + + var resp *httptransport.Response[T] + resp, err = h.nextHandler.Handle(nextRequest) + if err != nil { + return nil, err + } + + if resp.StatusCode < 400 { + return resp, nil + } + + backoffDuration := time.Duration(tryCount) * retryDelay + time.Sleep(backoffDuration) + } + return nil, httptransport.NewErrorResponse[T](err, nil) +} + +func (h *RetryHandler[T]) SetNext(handler Handler[T]) { + h.nextHandler = handler +} diff --git a/internal/clients/rest/handlers/terminating_handler.go b/internal/clients/rest/handlers/terminating_handler.go new file mode 100644 index 0000000..c98c561 --- /dev/null +++ b/internal/clients/rest/handlers/terminating_handler.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +type TerminatingHandler[T any] struct { + httpClient *http.Client +} + +func NewTerminatingHandler[T any]() *TerminatingHandler[T] { + return &TerminatingHandler[T]{ + httpClient: &http.Client{Timeout: time.Second * 10}, + } +} + +func (h *TerminatingHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + requestClone := request.Clone() + req, err := requestClone.CreateHttpRequest() + if err != nil { + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + transportResponse, responseErr := httptransport.NewResponse[T](resp) + if responseErr != nil { + return nil, httptransport.NewErrorResponse[T](responseErr, transportResponse) + } + + if transportResponse.StatusCode >= 400 { + err := fmt.Errorf("HTTP request failed with status code %d", transportResponse.StatusCode) + return nil, httptransport.NewErrorResponse[T](err, transportResponse) + } + + return transportResponse, nil +} + +func (h *TerminatingHandler[T]) SetNext(handler Handler[T]) { + fmt.Println("WARNING: SetNext should not be called on the terminating handler.") +} diff --git a/internal/clients/rest/handlers/unmarshal_handler.go b/internal/clients/rest/handlers/unmarshal_handler.go new file mode 100644 index 0000000..0ef8d5a --- /dev/null +++ b/internal/clients/rest/handlers/unmarshal_handler.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "errors" + "fmt" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/unmarshal" +) + +type UnmarshalHandler[T any] struct { + nextHandler Handler[T] +} + +func NewUnmarshalHandler[T any]() *UnmarshalHandler[T] { + return &UnmarshalHandler[T]{ + nextHandler: nil, + } +} + +func (h *UnmarshalHandler[T]) Handle(request httptransport.Request) (*httptransport.Response[T], *httptransport.ErrorResponse[T]) { + if h.nextHandler == nil { + err := errors.New("Handler chain terminated without terminating handler") + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + resp, handlerError := h.nextHandler.Handle(request) + if handlerError != nil { + return nil, handlerError + } + + target := new(T) + err := unmarshal.Unmarshal(resp.Body, target) + if err != nil { + err := fmt.Errorf("failed to unmarshal response body into struct: %v", err) + return nil, httptransport.NewErrorResponse[T](err, nil) + } + + resp.Data = *target + + return resp, nil +} + +func (h *UnmarshalHandler[T]) SetNext(handler Handler[T]) { + h.nextHandler = handler +} diff --git a/internal/clients/rest/hooks/custom_hook.go b/internal/clients/rest/hooks/custom_hook.go new file mode 100644 index 0000000..757a305 --- /dev/null +++ b/internal/clients/rest/hooks/custom_hook.go @@ -0,0 +1,26 @@ +package hooks + +import ( + "fmt" +) + +type CustomHook struct{} + +func NewCustomHook() Hook { + return &CustomHook{} +} + +func (h *CustomHook) BeforeRequest(req Request, params map[string]string) Request { + req.SetHeader("Metadata", "true") + return req +} + +func (h *CustomHook) AfterResponse(req Request, resp Response, params map[string]string) Response { + fmt.Printf("AfterResponse: %#v\n", resp) + return resp +} + +func (h *CustomHook) OnError(req Request, resp ErrorResponse, params map[string]string) ErrorResponse { + fmt.Printf("On Error: %#v\n", resp) + return resp +} diff --git a/internal/clients/rest/hooks/hook.go b/internal/clients/rest/hooks/hook.go new file mode 100644 index 0000000..bdbfacb --- /dev/null +++ b/internal/clients/rest/hooks/hook.go @@ -0,0 +1,46 @@ +package hooks + +type Hook interface { + BeforeRequest(req Request, params map[string]string) Request + AfterResponse(req Request, resp Response, params map[string]string) Response + OnError(req Request, resp ErrorResponse, params map[string]string) ErrorResponse +} + +type Request interface { + GetMethod() string + SetMethod(method string) + GetBaseUrl() string + SetBaseUrl(baseUrl string) + GetPath() string + SetPath(path string) + GetPathParam(param string) string + SetPathParam(param string, value any) + GetHeader(header string) string + SetHeader(header string, value string) + GetQueryParam(header string) string + SetQueryParam(header string, value string) + GetOptions() any + SetOptions(options any) + GetBody() any + SetBody(body any) +} + +type Response interface { + GetStatusCode() int + SetStatusCode(statusCode int) + GetHeader(header string) string + SetHeader(header string, value string) + GetBody() []byte + SetBody(body []byte) +} + +type ErrorResponse interface { + Error() string + GetError() error + GetStatusCode() int + SetStatusCode(statusCode int) + GetHeader(header string) string + SetHeader(header string, value string) + GetBody() []byte + SetBody(body []byte) +} diff --git a/internal/clients/rest/httptransport/error_response.go b/internal/clients/rest/httptransport/error_response.go new file mode 100644 index 0000000..5d77509 --- /dev/null +++ b/internal/clients/rest/httptransport/error_response.go @@ -0,0 +1,45 @@ +package httptransport + +import "fmt" + +type ErrorResponse[T any] struct { + Err error + IsHttpError bool + Response[T] +} + +func NewErrorResponse[T any](err error, resp *Response[T]) *ErrorResponse[T] { + if resp == nil { + return &ErrorResponse[T]{ + Err: err, + IsHttpError: false, + } + } + + return &ErrorResponse[T]{ + Err: err, + IsHttpError: true, + Response: *resp, + } +} + +func (r *ErrorResponse[T]) Clone() ErrorResponse[T] { + if r == nil { + return ErrorResponse[T]{} + } + + clone := *r + clone.Headers = make(map[string]string) + for header, value := range r.Headers { + clone.Headers[header] = value + } + return clone +} + +func (r *ErrorResponse[T]) Error() string { + return fmt.Sprintf("%s", r.Err) +} + +func (r *ErrorResponse[T]) GetError() error { + return r.Err +} diff --git a/internal/clients/rest/httptransport/request.go b/internal/clients/rest/httptransport/request.go new file mode 100644 index 0000000..1fc2388 --- /dev/null +++ b/internal/clients/rest/httptransport/request.go @@ -0,0 +1,240 @@ +package httptransport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" +) + +type paramMap struct { + Key string + Value string +} + +type Request struct { + Context context.Context + Method string + Path string + Headers map[string]string + QueryParams map[string]string + PathParams map[string]string + Options any + Body any + Config saladcloudimdssdkconfig.Config +} + +func NewRequest(ctx context.Context, method string, path string, config saladcloudimdssdkconfig.Config) Request { + return Request{ + Context: ctx, + Method: method, + Path: path, + Headers: make(map[string]string), + QueryParams: make(map[string]string), + PathParams: make(map[string]string), + Config: config, + } +} + +func (r *Request) Clone() Request { + if r == nil { + return Request{ + Headers: make(map[string]string), + QueryParams: make(map[string]string), + PathParams: make(map[string]string), + } + } + + clone := *r + clone.PathParams = utils.CloneMap(r.PathParams) + clone.Headers = utils.CloneMap(r.Headers) + clone.QueryParams = utils.CloneMap(r.QueryParams) + + return clone +} + +func (r *Request) GetMethod() string { + return r.Method +} + +func (r *Request) SetMethod(method string) { + r.Method = method +} + +func (r *Request) GetBaseUrl() string { + return *r.Config.BaseUrl +} + +func (r *Request) SetBaseUrl(baseUrl string) { + r.Config.SetBaseUrl(baseUrl) +} + +func (r *Request) GetPath() string { + return r.Path +} + +func (r *Request) SetPath(path string) { + r.Path = path +} + +func (r *Request) GetHeader(header string) string { + return r.Headers[header] +} + +func (r *Request) SetHeader(header string, value string) { + r.Headers[header] = value +} + +func (r *Request) GetPathParam(param string) string { + return r.PathParams[param] +} + +func (r *Request) SetPathParam(param string, value any) { + r.PathParams[param] = fmt.Sprintf("%v", value) +} + +func (r *Request) GetQueryParam(header string) string { + return r.QueryParams[header] +} + +func (r *Request) SetQueryParam(header string, value string) { + r.QueryParams[header] = value +} + +func (r *Request) GetOptions() any { + return r.Options +} + +func (r *Request) SetOptions(options any) { + r.Options = options +} + +func (r *Request) GetBody() any { + return r.Body +} + +func (r *Request) SetBody(body any) { + r.Body = body +} + +func (r *Request) GetContext() context.Context { + return r.Context +} + +func (r *Request) SetContext(ctx context.Context) { + r.Context = ctx +} + +func (r *Request) CreateHttpRequest() (*http.Request, error) { + requestUrl := r.getRequestUrl() + + requestBody, err := r.bodyToBytesReader() + if err != nil { + return nil, err + } + + var httpRequest *http.Request + if requestBody == nil { + httpRequest, err = http.NewRequestWithContext(r.Context, r.Method, requestUrl, nil) + } else { + httpRequest, err = http.NewRequestWithContext(r.Context, r.Method, requestUrl, requestBody) + } + + httpRequest.Header = r.getRequestHeaders() + + return httpRequest, err +} + +func (r *Request) getRequestUrl() string { + requestPath := r.Path + for paramName, paramValue := range r.PathParams { + placeholder := "{" + paramName + "}" + requestPath = strings.ReplaceAll(requestPath, placeholder, url.PathEscape(paramValue)) + } + + requestOptions := "" + params := r.getRequestQueryParams() + if len(params) > 0 { + requestOptions = fmt.Sprintf("?%s", params.Encode()) + } + + return *r.Config.BaseUrl + requestPath + requestOptions +} + +func (r *Request) bodyToBytesReader() (*bytes.Reader, error) { + if r.Body == nil { + return nil, nil + } + + marshalledBody, err := json.Marshal(r.Body) + if err != nil { + return nil, err + } + reqBody := bytes.NewReader(marshalledBody) + + return reqBody, nil +} + +func (r *Request) getRequestQueryParams() url.Values { + params := url.Values{} + for key, value := range r.QueryParams { + params.Add(key, value) + } + + for _, p := range tagsToMap("queryParam", r.Options) { + params.Add(p.Key, p.Value) + } + + return params +} + +func (r *Request) getRequestHeaders() http.Header { + headers := http.Header{} + for key, value := range r.Headers { + headers.Add(key, value) + } + + for _, p := range tagsToMap("headerParam", r.Options) { + headers.Add(p.Key, p.Value) + } + + return headers +} + +func tagsToMap(tag string, obj any) []paramMap { + tagMap := make([]paramMap, 0) + + if obj == nil { + return tagMap + } + + values := utils.GetReflectValue(reflect.ValueOf(obj)) + for i := 0; i < values.NumField(); i++ { + key, found := values.Type().Field(i).Tag.Lookup(tag) + if !found || values.Field(i).Type().Kind() == reflect.Pointer && values.Field(i).IsNil() { + continue + } + + field := utils.GetReflectValue(values.Field(i)) + + fieldKind := utils.GetReflectKind(field.Type()) + if fieldKind == reflect.Array || fieldKind == reflect.Slice { + for j := 0; j < field.Len(); j++ { + p := paramMap{Key: key, Value: fmt.Sprint(field.Index(j))} + tagMap = append(tagMap, p) + } + } else { + p := paramMap{Key: key, Value: fmt.Sprint(field)} + tagMap = append(tagMap, p) + } + } + + return tagMap +} diff --git a/internal/clients/rest/httptransport/response.go b/internal/clients/rest/httptransport/response.go new file mode 100644 index 0000000..6cb54df --- /dev/null +++ b/internal/clients/rest/httptransport/response.go @@ -0,0 +1,78 @@ +package httptransport + +import ( + "io" + "net/http" +) + +type Response[T any] struct { + StatusCode int + Headers map[string]string + Body []byte + Data T +} + +func (r *Response[T]) Clone() Response[T] { + if r == nil { + return Response[T]{ + Headers: make(map[string]string), + } + } + + clone := *r + clone.Headers = make(map[string]string) + for header, value := range r.Headers { + clone.Headers[header] = value + } + return clone +} + +func NewResponse[T any](resp *http.Response) (*Response[T], error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, NewErrorResponse[T](err, nil) + } + + responseHeaders := make(map[string]string) + for key := range resp.Header { + responseHeaders[key] = resp.Header.Get(key) + } + + placeholderData := new(T) + return &Response[T]{ + StatusCode: resp.StatusCode, + Headers: responseHeaders, + Body: body, + Data: *placeholderData, + }, nil +} + +func (r *Response[T]) GetStatusCode() int { + return r.StatusCode +} + +func (r *Response[T]) SetStatusCode(statusCode int) { + r.StatusCode = statusCode +} + +func (r *Response[T]) GetHeaders() map[string]string { + return r.Headers +} + +func (r *Response[T]) GetHeader(header string) string { + return r.Headers[header] +} + +func (r *Response[T]) SetHeader(header string, value string) { + r.Headers[header] = value +} + +func (r *Response[T]) GetBody() []byte { + return r.Body +} + +func (r *Response[T]) SetBody(body []byte) { + r.Body = body +} diff --git a/internal/configmanager/config_manager.go b/internal/configmanager/config_manager.go new file mode 100644 index 0000000..ba7a766 --- /dev/null +++ b/internal/configmanager/config_manager.go @@ -0,0 +1,21 @@ +package configmanager + +import "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + +type ConfigManager struct { + Metadata saladcloudimdssdkconfig.Config +} + +func NewConfigManager(config saladcloudimdssdkconfig.Config) *ConfigManager { + return &ConfigManager{ + Metadata: config, + } +} + +func (c *ConfigManager) SetBaseUrl(baseUrl string) { + c.Metadata.SetBaseUrl(baseUrl) +} + +func (c *ConfigManager) GetMetadata() *saladcloudimdssdkconfig.Config { + return &c.Metadata +} diff --git a/internal/marshal/from_complex_object.go b/internal/marshal/from_complex_object.go new file mode 100644 index 0000000..7f6c62e --- /dev/null +++ b/internal/marshal/from_complex_object.go @@ -0,0 +1,22 @@ +package marshal + +import ( + "encoding/json" + "errors" + "reflect" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func FromComplexObject(obj any) ([]byte, error) { + types := utils.GetReflectType(reflect.TypeOf(obj)) + values := utils.GetReflectValue(reflect.ValueOf(obj)) + + for i := 0; i < types.NumField(); i++ { + if !values.Field(i).IsNil() { + return json.Marshal(values.Field(i).Interface()) + } + } + + return nil, errors.New("cannot marshal complex object, no non-nil fields found") +} diff --git a/internal/unmarshal/to_complex_object.go b/internal/unmarshal/to_complex_object.go new file mode 100644 index 0000000..5066e06 --- /dev/null +++ b/internal/unmarshal/to_complex_object.go @@ -0,0 +1,276 @@ +package unmarshal + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/validation" +) + +type candidate struct { + obj any + valid bool + requiredCount int + optionalCount int + kind reflect.Kind +} + +func ToComplexObject[T any](data []byte, result *T) error { + err := unmarshalIntoProps(data, result) + if err != nil { + return err + } + + candidates := createCandidatesFromProps(result) + chosenCandidateIndex := chooseCandidateIndex(candidates) + if chosenCandidateIndex == -1 { + return errors.New("cannot unmarshal response, no valid candidate found") + } + removeOtherCandidates(result, chosenCandidateIndex) + + return nil +} + +// Try to Unmarshal the input data into the properties of a given struct. +func unmarshalIntoProps(data []byte, obj any) error { + types := reflect.TypeOf(obj).Elem() + values := reflect.ValueOf(obj).Elem() + + for i := 0; i < types.NumField(); i++ { + fieldType := types.Field(i) + kind := utils.GetReflectKind(fieldType.Type) + if kind == reflect.Struct || kind == reflect.Array || kind == reflect.Slice || kind == reflect.Map { + unmarshalledValue := reflect.New(fieldType.Type) + err := json.Unmarshal(data, unmarshalledValue.Interface()) + if err != nil { + continue + } + + value := unmarshalledValue.Elem() + values.Field(i).Set(value) + } else if kind == reflect.String { + strValue := string(data) + values.Field(i).Set(reflect.ValueOf(&strValue)) + } else if kind == reflect.Float32 || kind == reflect.Float64 { + value, err := strconv.ParseFloat(string(data), 64) + if err == nil { + values.Field(i).Set(reflect.ValueOf(&value)) + } + } else if kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || kind == reflect.Int32 || kind == reflect.Int64 { + value, err := strconv.ParseInt(string(data), 10, 64) + if err == nil { + values.Field(i).Set(reflect.ValueOf(&value)) + } + } else if kind == reflect.Bool { + value, err := strconv.ParseBool(string(data)) + if err == nil { + values.Field(i).Set(reflect.ValueOf(&value)) + } + } else if kind == reflect.Interface { + values.Field(i).Set(reflect.ValueOf(string(data))) + } else { + return fmt.Errorf("cannot unmarshal response, unsupported type: %s", kind) + } + } + + return nil +} + +func createCandidatesFromProps(obj any) []candidate { + values := utils.GetReflectValue(reflect.ValueOf(obj)) + types := utils.GetReflectType(reflect.TypeOf(obj)) + + candidates := make([]candidate, 0) + for i := 0; i < types.NumField(); i++ { + fieldValue := values.Field(i) + kind := utils.GetReflectKind(types.Field(i).Type) + + var c candidate + if fieldValue.IsNil() { + c = candidate{ + obj: nil, + valid: false, + requiredCount: 0, + optionalCount: 0, + kind: kind, + } + } else if kind == reflect.Struct { + value := fieldValue.Interface() + c = candidate{ + obj: value, + valid: isValid(value), + requiredCount: countFields(value, validation.IsRequiredField), + optionalCount: countFields(value, validation.IsOptionalField), + kind: kind, + } + } else if kind == reflect.Array || kind == reflect.Slice { + value := fieldValue.Interface() + c = candidate{ + obj: value, + valid: isValid(value), + requiredCount: countArrayFields(value, validation.IsRequiredField), + optionalCount: countArrayFields(value, validation.IsOptionalField), + kind: kind, + } + } else { + value := fieldValue.Interface() + c = candidate{ + obj: value, + valid: true, + requiredCount: 0, + optionalCount: 0, + kind: kind, + } + } + + candidates = append(candidates, c) + } + + return candidates +} + +func countFields(c any, isFieldRequiredOrOptional func(reflect.StructField) bool) int { + values := utils.GetReflectValue(reflect.ValueOf(c)) + types := utils.GetReflectType(reflect.TypeOf(c)) + + if isPrimitive(utils.GetReflectKind(types)) { + return 0 + } + + count := 0 + for i := 0; i < types.NumField(); i++ { + fieldValue := values.Field(i) + fieldType := types.Field(i) + + if fieldValue.IsNil() { + continue + } + + if isFieldRequiredOrOptional(fieldType) { + count++ + } + + kind := utils.GetReflectKind(fieldType.Type) + if kind == reflect.Struct || kind == reflect.Array || kind == reflect.Slice { + count += countFields(fieldValue.Interface(), isFieldRequiredOrOptional) + } + } + + return count +} + +func countArrayFields(candidates any, isFieldRequiredOrOptional func(reflect.StructField) bool) int { + count := 0 + values := utils.GetReflectValue(reflect.ValueOf(candidates)) + for i := 0; i < values.Len(); i++ { + candidate := values.Index(i).Interface() + count += countFields(candidate, isFieldRequiredOrOptional) + } + + return count +} + +func isValid(candidate any) bool { + err := validation.ValidateData(candidate) + return err == nil +} + +func chooseCandidateIndex(candidates []candidate) int { + chosenCandidateIndex := chooseNonPrimitiveCandidate(candidates) + + if chosenCandidateIndex == -1 { + chosenCandidateIndex = choosePrimitiveCandidate(candidates) + } + + return chosenCandidateIndex +} + +func chooseNonPrimitiveCandidate(candidates []candidate) int { + chosenCandidateIndex := -1 + chosenCandidateRequiredCount := -1 + chosenCandidateOptionalCount := -1 + + for i, candidate := range candidates { + if isBetterCandidate(candidate, chosenCandidateRequiredCount, chosenCandidateOptionalCount) { + chosenCandidateIndex = i + chosenCandidateRequiredCount = candidate.requiredCount + chosenCandidateOptionalCount = candidate.optionalCount + } + } + + return chosenCandidateIndex +} + +func isBetterCandidate(c candidate, chosenCandidateRequiredCount int, chosenCandidateOptionalCount int) bool { + if !c.valid || isPrimitive(c.kind) { + return false + } + + if c.requiredCount > chosenCandidateRequiredCount { + return true + } + + if c.requiredCount == chosenCandidateRequiredCount && c.optionalCount > chosenCandidateOptionalCount { + return true + } + + return false +} + +func choosePrimitiveCandidate(candidates []candidate) int { + predicates := []func(kind reflect.Kind) bool{isBool, isInteger, isFloat, isString} + + for _, predicate := range predicates { + chosenCandidateIndex := findFirstNonNil(candidates, predicate) + if chosenCandidateIndex != -1 { + return chosenCandidateIndex + } + } + + return -1 +} + +func removeOtherCandidates(obj any, chosenCandidateIndex int) { + values := utils.GetReflectValue(reflect.ValueOf(obj)) + types := utils.GetReflectType(reflect.TypeOf(obj)) + + for i := 0; i < types.NumField(); i++ { + if i != chosenCandidateIndex { + fieldValue := values.Field(i) + fieldValue.Set(reflect.Zero(fieldValue.Type())) + } + } +} + +func findFirstNonNil(candidates []candidate, predicate func(kind reflect.Kind) bool) int { + for i, c := range candidates { + if c.obj != nil && predicate(c.kind) { + return i + } + } + return -1 +} + +func isPrimitive(kind reflect.Kind) bool { + return isInteger(kind) || isString(kind) || isBool(kind) || isFloat(kind) +} + +func isInteger(kind reflect.Kind) bool { + return kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || kind == reflect.Int32 || kind == reflect.Int64 +} + +func isFloat(kind reflect.Kind) bool { + return kind == reflect.Float32 || kind == reflect.Float64 +} + +func isBool(kind reflect.Kind) bool { + return kind == reflect.Bool +} + +func isString(kind reflect.Kind) bool { + return kind == reflect.String +} diff --git a/internal/unmarshal/to_object.go b/internal/unmarshal/to_object.go new file mode 100644 index 0000000..040b0bc --- /dev/null +++ b/internal/unmarshal/to_object.go @@ -0,0 +1,13 @@ +package unmarshal + +import ( + "encoding/json" +) + +func ToObject(source []byte, target any) error { + err := json.Unmarshal(source, target) + if err != nil { + return err + } + return nil +} diff --git a/internal/unmarshal/to_primitive.go b/internal/unmarshal/to_primitive.go new file mode 100644 index 0000000..42a74c2 --- /dev/null +++ b/internal/unmarshal/to_primitive.go @@ -0,0 +1,44 @@ +package unmarshal + +import ( + "reflect" + "strconv" +) + +func ToString(source []byte, target reflect.Value) error { + target.Elem().SetString(string(source)) + return nil +} + +func ToInt(source []byte, target reflect.Value) error { + intBody, err := strconv.ParseInt(string(source), 10, 64) + if err != nil { + return err + } + + target.Elem().SetInt(intBody) + + return nil +} + +func ToFloat(source []byte, target reflect.Value) error { + floatBody, err := strconv.ParseFloat(string(source), 64) + if err != nil { + return err + } + + target.Elem().SetFloat(floatBody) + + return nil +} + +func ToBool(source []byte, target reflect.Value) error { + boolBody, err := strconv.ParseBool(string(source)) + if err != nil { + return err + } + + target.Elem().SetBool(boolBody) + + return nil +} diff --git a/internal/unmarshal/unmarshal.go b/internal/unmarshal/unmarshal.go new file mode 100644 index 0000000..4006c77 --- /dev/null +++ b/internal/unmarshal/unmarshal.go @@ -0,0 +1,63 @@ +package unmarshal + +import ( + "fmt" + "reflect" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func Unmarshal(source []byte, target any) error { + + targetValue := reflect.ValueOf(target) + if targetValue.Kind() != reflect.Ptr || targetValue.IsNil() { + return fmt.Errorf("target must be a non-nil pointer") + } + + if isComplexObject(target) || isObject(target) || isArray(target) { + return ToObject(source, target) + } else if isString(targetValue.Elem().Kind()) { + return ToString(source, targetValue) + } else if isInteger(targetValue.Elem().Kind()) { + return ToInt(source, targetValue) + } else if isFloat(targetValue.Elem().Kind()) { + return ToFloat(source, targetValue) + } else if isBool(targetValue.Elem().Kind()) { + return ToBool(source, targetValue) + } + + return nil +} + +func isArray(target any) bool { + targetType := reflect.TypeOf(target) + kind := utils.GetReflectKind(targetType) + return kind == reflect.Array || kind == reflect.Slice +} + +func isObject(target any) bool { + targetType := reflect.TypeOf(target) + return utils.GetReflectKind(targetType) == reflect.Struct +} + +func isComplexObject(target any) bool { + targetType := reflect.TypeOf(target) + if utils.GetReflectKind(targetType) != reflect.Struct { + return false + } + + allFieldsAreOneOf := true + + structValue := utils.GetReflectValue(reflect.ValueOf(target)) + for i := 0; i < structValue.NumField(); i++ { + field := structValue.Type().Field(i) + allFieldsAreOneOf = isOneOfField(field) && allFieldsAreOneOf + } + + return allFieldsAreOneOf +} + +func isOneOfField(field reflect.StructField) bool { + _, found := field.Tag.Lookup("oneof") + return found +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..0766a7a --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,36 @@ +package utils + +import "reflect" + +func CloneMap[T any](sourceMap map[string]T) map[string]T { + newMap := make(map[string]T) + for key, value := range sourceMap { + newMap[key] = value + } + + return newMap +} + +func GetReflectValue(fieldValue reflect.Value) reflect.Value { + if fieldValue.Kind() == reflect.Pointer { + return fieldValue.Elem() + } else { + return fieldValue + } +} + +func GetReflectType(fieldType reflect.Type) reflect.Type { + if fieldType.Kind() == reflect.Ptr { + return fieldType.Elem() + } else { + return fieldType + } +} + +func GetReflectKind(fieldType reflect.Type) reflect.Kind { + if fieldType.Kind() == reflect.Pointer { + return fieldType.Elem().Kind() + } else { + return fieldType.Kind() + } +} diff --git a/internal/validation/validate_array_length.go b/internal/validation/validate_array_length.go new file mode 100644 index 0000000..17f8dd1 --- /dev/null +++ b/internal/validation/validate_array_length.go @@ -0,0 +1,65 @@ +package validation + +import ( + "fmt" + "reflect" + "strconv" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func validateArrayLength(field reflect.StructField, value reflect.Value) error { + kind := utils.GetReflectKind(value.Type()) + if kind != reflect.Array && kind != reflect.Slice { + return nil + } + + err := validateMinLength(field, value) + if err != nil { + return err + } + + err = validateMaxLength(field, value) + if err != nil { + return err + } + + return nil +} + +func validateMinLength(field reflect.StructField, value reflect.Value) error { + minLength, found := field.Tag.Lookup("minLength") + if !found { + return nil + } + + minLengthInteger, err := strconv.Atoi(minLength) + if err != nil { + return err + } + + actualLength := value.Len() + if actualLength < minLengthInteger { + return fmt.Errorf("the field myArray needs a minimum length of %v, but it currently has %v", minLengthInteger, actualLength) + } + + return nil +} + +func validateMaxLength(field reflect.StructField, value reflect.Value) error { + maxLength, found := field.Tag.Lookup("maxLength") + if !found { + return nil + } + + maxLengthInteger, err := strconv.Atoi(maxLength) + if err != nil { + return err + } + + if value.Len() > maxLengthInteger { + return fmt.Errorf("too long") + } + + return nil +} diff --git a/internal/validation/validate_array_unique.go b/internal/validation/validate_array_unique.go new file mode 100644 index 0000000..f9c3814 --- /dev/null +++ b/internal/validation/validate_array_unique.go @@ -0,0 +1,28 @@ +package validation + +import ( + "fmt" + "reflect" +) + +func validateArrayIsUnique(field reflect.StructField, value reflect.Value) error { + unique, found := field.Tag.Lookup("uniqueItems") + if !found || unique != "true" { + return nil + } + + if value.Kind() != reflect.Array && value.Kind() != reflect.Slice { + return nil + } + + seen := make(map[any]bool) + for i := 0; i < value.Len(); i++ { + item := value.Index(i).Interface() + if seen[item] { + return fmt.Errorf("the elements of this array must be unique, but this element appeared more than once: %v", item) + } + seen[item] = true + } + + return nil +} diff --git a/internal/validation/validate_max.go b/internal/validation/validate_max.go new file mode 100644 index 0000000..c99a9c8 --- /dev/null +++ b/internal/validation/validate_max.go @@ -0,0 +1,37 @@ +package validation + +import ( + "fmt" + "reflect" + "strconv" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func validateMax(field reflect.StructField, value reflect.Value) error { + maxValue, found := field.Tag.Lookup("max") + if !found || maxValue == "" { + return nil + } + + max, err := strconv.Atoi(maxValue) + if err != nil { + return err + } + + val := utils.GetReflectValue(value) + + if val.CanInt() { + if val.Int() > int64(max) { + return fmt.Errorf("validation Error. Field %s is greater than max value", field.Name) + } + } else if val.CanFloat() { + if val.Float() > float64(max) { + return fmt.Errorf("validation Error. Field %s is greater than max value", field.Name) + } + } else { + return fmt.Errorf("validation Error. Field %s is not a number", field.Name) + } + + return nil +} diff --git a/internal/validation/validate_min.go b/internal/validation/validate_min.go new file mode 100644 index 0000000..053de41 --- /dev/null +++ b/internal/validation/validate_min.go @@ -0,0 +1,41 @@ +package validation + +import ( + "fmt" + "reflect" + "strconv" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func validateMin(field reflect.StructField, value reflect.Value) error { + minValue, found := field.Tag.Lookup("min") + if !found || minValue == "" { + return nil + } + + min, err := strconv.Atoi(minValue) + if err != nil { + return err + } + + if value.IsNil() { + return fmt.Errorf("field %s is required", field.Name) + } + + val := utils.GetReflectValue(value) + + if val.CanInt() { + if val.Int() < int64(min) { + return fmt.Errorf("field %s is less than min value", field.Name) + } + } else if val.CanFloat() { + if val.Float() < float64(min) { + return fmt.Errorf("field %s is less than min value", field.Name) + } + } else { + return fmt.Errorf("field %s is not a number", field.Name) + } + + return nil +} diff --git a/internal/validation/validate_multiple_of.go b/internal/validation/validate_multiple_of.go new file mode 100644 index 0000000..fbe90bc --- /dev/null +++ b/internal/validation/validate_multiple_of.go @@ -0,0 +1,38 @@ +package validation + +import ( + "fmt" + "math" + "reflect" + "strconv" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func validateMultipleOf(field reflect.StructField, value reflect.Value) error { + multipleOfValue, found := field.Tag.Lookup("multipleOf") + if !found || multipleOfValue == "" { + return nil + } + + multipleOf, err := strconv.Atoi(multipleOfValue) + if err != nil { + return err + } + + val := utils.GetReflectValue(value) + + if val.CanInt() { + if val.Int()%int64(multipleOf) != 0 { + return fmt.Errorf("validation Error: Field %s must be a multiple of %v. Value: %v", field.Name, multipleOf, val) + } + } else if val.CanFloat() { + if math.Mod(value.Float(), float64(multipleOf)) != 0 { + return fmt.Errorf("validation Error: Field %s must be a multiple of %v. Value: %v", field.Name, multipleOf, val) + } + } else { + return fmt.Errorf("validation Error: Field %s must a number. Value: %v", field.Name, val) + } + + return nil +} diff --git a/internal/validation/validate_pattern.go b/internal/validation/validate_pattern.go new file mode 100644 index 0000000..654ddb2 --- /dev/null +++ b/internal/validation/validate_pattern.go @@ -0,0 +1,40 @@ +package validation + +import ( + "fmt" + "reflect" + "regexp" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +func validatePattern(field reflect.StructField, value reflect.Value) error { + pattern, found := field.Tag.Lookup("pattern") + if !found { + return nil + } + + compiledRegex, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("regex failed to compile") + } + + if value.IsNil() { + return nil + } + + kind := utils.GetReflectKind(value.Type()) + if kind != reflect.String { + return fmt.Errorf("field %s with value %v cannot match pattern %s because it is not a string", field.Name, value, pattern) + } + + if value.Kind() == reflect.Ptr && !compiledRegex.MatchString(value.Elem().String()) { + return fmt.Errorf("field %s with value %v does not match pattern %s", field.Name, value.Elem().String(), pattern) + } + + if value.Kind() != reflect.Ptr && !compiledRegex.MatchString(value.String()) { + return fmt.Errorf("field %s with value %v does not match pattern %s", field.Name, value.String(), pattern) + } + + return nil +} diff --git a/internal/validation/validate_required.go b/internal/validation/validate_required.go new file mode 100644 index 0000000..6b1a638 --- /dev/null +++ b/internal/validation/validate_required.go @@ -0,0 +1,24 @@ +package validation + +import ( + "fmt" + "reflect" +) + +func validateRequired(fieldType reflect.StructField, fieldValue reflect.Value) error { + if IsRequiredField(fieldType) && fieldValue.IsNil() { + return fmt.Errorf("field %s is required", fieldType.Name) + } + + return nil +} + +func IsRequiredField(fieldType reflect.StructField) bool { + required, found := fieldType.Tag.Lookup("required") + return found && required == "true" +} + +func IsOptionalField(fieldType reflect.StructField) bool { + required, found := fieldType.Tag.Lookup("required") + return !found || required == "" || required == "false" +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..5f93dfe --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,88 @@ +package validation + +import ( + "reflect" + + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/utils" +) + +type validatorFunc = func(fieldType reflect.StructField, fieldValue reflect.Value) error + +func ValidateData(data any) error { + if data == nil { + return nil + } + + dataType := reflect.TypeOf(data) + dataValue := reflect.ValueOf(data) + if utils.GetReflectKind(dataType) == reflect.Struct { + return validateStruct(data) + } else if dataType.Kind() == reflect.Array || dataType.Kind() == reflect.Slice { + return validateArray(dataValue) + } + + return nil +} + +func validateStruct(data any) error { + structValue := utils.GetReflectValue(reflect.ValueOf(data)) + for i := 0; i < structValue.NumField(); i++ { + fieldValue := structValue.Field(i) + fieldType := structValue.Type().Field(i) + + err := validateField(fieldValue, fieldType) + if err != nil { + return err + } + + if fieldValue.IsNil() { + continue + } + + kind := utils.GetReflectKind(fieldType.Type) + if kind == reflect.Struct || kind == reflect.Array || kind == reflect.Slice { + err := ValidateData(fieldValue.Interface()) + if err != nil { + return err + } + } + } + + return nil +} + +func validateArray(value reflect.Value) error { + arrayValue := utils.GetReflectValue(value) + for j := 0; j < arrayValue.Len(); j++ { + err := ValidateData(arrayValue.Index(j).Interface()) + if err != nil { + return err + } + } + + return nil +} + +func validateField(fieldValue reflect.Value, fieldType reflect.StructField) error { + validators := getValidators(fieldType) + for _, validator := range validators { + err := validator(fieldType, fieldValue) + if err != nil { + return err + } + } + + return nil +} + +func getValidators(fieldType reflect.StructField) []validatorFunc { + return []validatorFunc{ + validateRequired, + validatePattern, + validateMultipleOf, + validateMin, + validateMax, + validateArrayIsUnique, + validateArrayLength, + } +} diff --git a/pkg/metadata/container_status.go b/pkg/metadata/container_status.go new file mode 100644 index 0000000..54a5440 --- /dev/null +++ b/pkg/metadata/container_status.go @@ -0,0 +1,31 @@ +package metadata + +// Represents the health statuses of the running container. +type ContainerStatus struct { + // `true` if the running container is ready. If a readiness probe is defined, this returns the latest result of the probe. If a readiness probe is not defined but a startup probe is defined, this returns the same value as the `started` property. If neither a readiness probe nor a startup probe are defined, returns `true`. + Ready *bool `json:"ready,omitempty" required:"true"` + // `true` if the running container is started. If a startup probe is defined, this returns the latest result of the probe. If a startup probe is not defined, returns `true`. + Started *bool `json:"started,omitempty" required:"true"` +} + +func (c *ContainerStatus) SetReady(ready bool) { + c.Ready = &ready +} + +func (c *ContainerStatus) GetReady() *bool { + if c == nil { + return nil + } + return c.Ready +} + +func (c *ContainerStatus) SetStarted(started bool) { + c.Started = &started +} + +func (c *ContainerStatus) GetStarted() *bool { + if c == nil { + return nil + } + return c.Started +} diff --git a/pkg/metadata/container_token.go b/pkg/metadata/container_token.go new file mode 100644 index 0000000..b23b20f --- /dev/null +++ b/pkg/metadata/container_token.go @@ -0,0 +1,18 @@ +package metadata + +// Represents the identity token of the running container. +type ContainerToken struct { + // The JSON Web Token (JWT) that may be used to identify the running container. The JWT may be verified using the JSON Web Key Set (JWKS) available at https://matrix-rest-api.salad.com/.well-known/stash-jwks.json. + Jwt *string `json:"jwt,omitempty" required:"true" maxLength:"1000" minLength:"1"` +} + +func (c *ContainerToken) SetJwt(jwt string) { + c.Jwt = &jwt +} + +func (c *ContainerToken) GetJwt() *string { + if c == nil { + return nil + } + return c.Jwt +} diff --git a/pkg/metadata/metadata_service.go b/pkg/metadata/metadata_service.go new file mode 100644 index 0000000..a44b36f --- /dev/null +++ b/pkg/metadata/metadata_service.go @@ -0,0 +1,79 @@ +package metadata + +import ( + "context" + restClient "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/configmanager" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/shared" +) + +type MetadataService struct { + manager *configmanager.ConfigManager +} + +func NewMetadataService(manager *configmanager.ConfigManager) *MetadataService { + return &MetadataService{ + manager: manager, + } +} + +func (api *MetadataService) getConfig() *saladcloudimdssdkconfig.Config { + return api.manager.GetMetadata() +} + +func (api *MetadataService) SetBaseUrl(baseUrl string) { + config := api.getConfig() + config.SetBaseUrl(baseUrl) +} + +// Reallocates the running container to another Salad Node +func (api *MetadataService) ReallocateContainer(ctx context.Context, reallocateContainer ReallocateContainer) (*shared.SaladCloudImdsSdkResponse[any], *shared.SaladCloudImdsSdkError) { + config := *api.getConfig() + + client := restClient.NewRestClient[any](config) + + request := httptransport.NewRequest(ctx, "POST", "/v1/reallocate", config) + + request.Body = reallocateContainer + + resp, err := client.Call(request) + if err != nil { + return nil, shared.NewSaladCloudImdsSdkError[any](err) + } + + return shared.NewSaladCloudImdsSdkResponse[any](resp), nil +} + +// Gets the health statuses of the running container +func (api *MetadataService) GetContainerStatus(ctx context.Context) (*shared.SaladCloudImdsSdkResponse[ContainerStatus], *shared.SaladCloudImdsSdkError) { + config := *api.getConfig() + + client := restClient.NewRestClient[ContainerStatus](config) + + request := httptransport.NewRequest(ctx, "GET", "/v1/status", config) + + resp, err := client.Call(request) + if err != nil { + return nil, shared.NewSaladCloudImdsSdkError[ContainerStatus](err) + } + + return shared.NewSaladCloudImdsSdkResponse[ContainerStatus](resp), nil +} + +// Gets the identity token of the running container +func (api *MetadataService) GetContainerToken(ctx context.Context) (*shared.SaladCloudImdsSdkResponse[ContainerToken], *shared.SaladCloudImdsSdkError) { + config := *api.getConfig() + + client := restClient.NewRestClient[ContainerToken](config) + + request := httptransport.NewRequest(ctx, "GET", "/v1/token", config) + + resp, err := client.Call(request) + if err != nil { + return nil, shared.NewSaladCloudImdsSdkError[ContainerToken](err) + } + + return shared.NewSaladCloudImdsSdkResponse[ContainerToken](resp), nil +} diff --git a/pkg/metadata/reallocate_container.go b/pkg/metadata/reallocate_container.go new file mode 100644 index 0000000..f69a908 --- /dev/null +++ b/pkg/metadata/reallocate_container.go @@ -0,0 +1,18 @@ +package metadata + +// Represents a request to reallocate a container. +type ReallocateContainer struct { + // The reason for reallocating the container. This value is reported to SaladCloud support for quality assurance of Salad Nodes. + Reason *string `json:"reason,omitempty" required:"true" maxLength:"1000" minLength:"1"` +} + +func (r *ReallocateContainer) SetReason(reason string) { + r.Reason = &reason +} + +func (r *ReallocateContainer) GetReason() *string { + if r == nil { + return nil + } + return r.Reason +} diff --git a/pkg/saladcloudimdssdk/saladcloudimdssdk.go b/pkg/saladcloudimdssdk/saladcloudimdssdk.go new file mode 100644 index 0000000..1e6e227 --- /dev/null +++ b/pkg/saladcloudimdssdk/saladcloudimdssdk.go @@ -0,0 +1,26 @@ +package saladcloudimdssdk + +import ( + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/configmanager" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/metadata" + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/pkg/saladcloudimdssdkconfig" +) + +type SaladCloudImdsSdk struct { + Metadata *metadata.MetadataService + manager *configmanager.ConfigManager +} + +func NewSaladCloudImdsSdk(config saladcloudimdssdkconfig.Config) *SaladCloudImdsSdk { + manager := configmanager.NewConfigManager(config) + return &SaladCloudImdsSdk{ + Metadata: metadata.NewMetadataService(manager), + manager: manager, + } +} + +func (s *SaladCloudImdsSdk) SetBaseUrl(baseUrl string) { + s.manager.SetBaseUrl(baseUrl) +} + +// c029837e0e474b76bc487506e8799df5e3335891efe4fb02bda7a1441840310c diff --git a/pkg/saladcloudimdssdkconfig/config.go b/pkg/saladcloudimdssdkconfig/config.go new file mode 100644 index 0000000..6093529 --- /dev/null +++ b/pkg/saladcloudimdssdkconfig/config.go @@ -0,0 +1,24 @@ +package saladcloudimdssdkconfig + +type Config struct { + BaseUrl *string + HookParams map[string]string +} + +func NewConfig() Config { + baseUrl := DEFAULT_ENVIRONMENT + newConfig := Config{ + BaseUrl: &baseUrl, + HookParams: make(map[string]string), + } + + return newConfig +} + +func (c *Config) SetBaseUrl(baseUrl string) { + c.BaseUrl = &baseUrl +} + +func (c *Config) GetBaseUrl() string { + return *c.BaseUrl +} diff --git a/pkg/saladcloudimdssdkconfig/environments.go b/pkg/saladcloudimdssdkconfig/environments.go new file mode 100644 index 0000000..9999747 --- /dev/null +++ b/pkg/saladcloudimdssdkconfig/environments.go @@ -0,0 +1,5 @@ +package saladcloudimdssdkconfig + +const ( + DEFAULT_ENVIRONMENT = "http://169.254.169.254" +) diff --git a/pkg/shared/salad_cloud_imds_sdk_error.go b/pkg/shared/salad_cloud_imds_sdk_error.go new file mode 100644 index 0000000..ea66322 --- /dev/null +++ b/pkg/shared/salad_cloud_imds_sdk_error.go @@ -0,0 +1,31 @@ +package shared + +import ( + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +type SaladCloudImdsSdkError struct { + Err error + Body []byte + Metadata SaladCloudImdsSdkErrorMetadata +} + +type SaladCloudImdsSdkErrorMetadata struct { + Headers map[string]string + StatusCode int +} + +func NewSaladCloudImdsSdkError[T any](transportError *httptransport.ErrorResponse[T]) *SaladCloudImdsSdkError { + return &SaladCloudImdsSdkError{ + Err: transportError.GetError(), + Body: transportError.GetBody(), + Metadata: SaladCloudImdsSdkErrorMetadata{ + StatusCode: transportError.GetStatusCode(), + Headers: transportError.GetHeaders(), + }, + } +} + +func (e *SaladCloudImdsSdkError) Error() string { + return e.Err.Error() +} diff --git a/pkg/shared/salad_cloud_imds_sdk_response.go b/pkg/shared/salad_cloud_imds_sdk_response.go new file mode 100644 index 0000000..6904930 --- /dev/null +++ b/pkg/shared/salad_cloud_imds_sdk_response.go @@ -0,0 +1,25 @@ +package shared + +import ( + "github.com/saladtechnologies/salad-cloud-imds-sdk-go/internal/clients/rest/httptransport" +) + +type SaladCloudImdsSdkResponse[T any] struct { + Data T + Metadata SaladCloudImdsSdkResponseMetadata +} + +type SaladCloudImdsSdkResponseMetadata struct { + Headers map[string]string + StatusCode int +} + +func NewSaladCloudImdsSdkResponse[T any](resp *httptransport.Response[T]) *SaladCloudImdsSdkResponse[T] { + return &SaladCloudImdsSdkResponse[T]{ + Data: resp.Data, + Metadata: SaladCloudImdsSdkResponseMetadata{ + StatusCode: resp.StatusCode, + Headers: resp.Headers, + }, + } +}