diff --git a/github/config.go b/github/config.go index d5f561c19f..38ce8cf8a3 100644 --- a/github/config.go +++ b/github/config.go @@ -189,7 +189,11 @@ func (injector *previewHeaderInjectorTransport) RoundTrip(req *http.Request) (*h header := req.Header.Get(name) if header == "" { header = value - } else { + // NOTE: Some API endpoints expect a single Accept: application/octet-stream header. + // If one has been set, it's necessary to preserve it as-is, without + // appending previewHeaders value. + // See https://github.com/google/go-github/pull/3392 + } else if !(strings.ToLower(name) == "accept" && header == "application/octet-stream") { header = strings.Join([]string{header, value}, ",") } req.Header.Set(name, header) diff --git a/github/config_test.go b/github/config_test.go index 67d6223031..06eccb34ca 100644 --- a/github/config_test.go +++ b/github/config_test.go @@ -2,6 +2,8 @@ package github import ( "context" + "net/http" + "net/http/httptest" "testing" "github.com/shurcooL/githubv4" @@ -300,3 +302,216 @@ func TestAccConfigMeta(t *testing.T) { } }) } + +func TestPreviewHeaderInjectorTransport_RoundTrip(t *testing.T) { + tests := []struct { + name string + previewHeaders map[string]string + existingHeaders map[string]string + expectedHeaders map[string]string + expectRoundTripCall bool + }{ + { + name: "empty preview headers", + previewHeaders: map[string]string{}, + existingHeaders: map[string]string{"User-Agent": "test"}, + expectedHeaders: map[string]string{"User-Agent": "test"}, + expectRoundTripCall: true, + }, + { + name: "add new preview header", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + }, + existingHeaders: map[string]string{}, + expectedHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + }, + expectRoundTripCall: true, + }, + { + name: "append to existing header", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/json", + }, + expectedHeaders: map[string]string{ + "Accept": "application/json,application/vnd.github.preview+json", + }, + expectRoundTripCall: true, + }, + { + name: "preserve existing Accept application/octet-stream", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectRoundTripCall: true, + }, + { + name: "preserve existing accept application/octet-stream (lowercase)", + previewHeaders: map[string]string{ + "accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "accept": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectRoundTripCall: true, + }, + { + name: "preserve existing Accept application/octet-stream (mixed case)", + previewHeaders: map[string]string{ + "AcCePt": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectRoundTripCall: true, + }, + { + name: "multiple preview headers", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + existingHeaders: map[string]string{}, + expectedHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + expectRoundTripCall: true, + }, + { + name: "append multiple preview headers to existing", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + existingHeaders: map[string]string{ + "Accept": "application/json", + "X-GitHub-Api-Version": "2021-01-01", + }, + expectedHeaders: map[string]string{ + "Accept": "application/json,application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2021-01-01,2022-11-28", + }, + expectRoundTripCall: true, + }, + { + name: "non-accept headers always append", + previewHeaders: map[string]string{ + "X-Custom-Header": "preview-value", + }, + existingHeaders: map[string]string{ + "X-Custom-Header": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "X-Custom-Header": "application/octet-stream,preview-value", + }, + expectRoundTripCall: true, + }, + { + name: "accept header with different value appends", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/json", + }, + expectedHeaders: map[string]string{ + "Accept": "application/json,application/vnd.github.preview+json", + }, + expectRoundTripCall: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock RoundTripper that records the request + var capturedRequest *http.Request + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + capturedRequest = req + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + }, nil + }, + } + + injector := &previewHeaderInjectorTransport{ + rt: mockRT, + previewHeaders: tt.previewHeaders, + } + + // Create a test request with existing headers + req := httptest.NewRequest(http.MethodGet, "https://api.github.com/test", nil) + for name, value := range tt.existingHeaders { + req.Header.Set(name, value) + } + + // Execute RoundTrip + resp, err := injector.RoundTrip(req) + + // Verify no error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify response + if resp == nil { + t.Fatal("expected non-nil response") + } + + // Verify RoundTrip was called on the underlying transport + if tt.expectRoundTripCall && capturedRequest == nil { + t.Fatal("expected RoundTrip to be called on underlying transport") + } + + // Verify headers in the captured request + if capturedRequest != nil { + for name, expectedValue := range tt.expectedHeaders { + actualValue := capturedRequest.Header.Get(name) + if actualValue != expectedValue { + t.Errorf("header %q: expected %q, got %q", name, expectedValue, actualValue) + } + } + + // Verify no unexpected headers were added + for name := range capturedRequest.Header { + if _, exists := tt.expectedHeaders[name]; !exists { + // Allow headers that were in existingHeaders but not in expectedHeaders + if _, wasExisting := tt.existingHeaders[name]; !wasExisting { + t.Errorf("unexpected header %q: %q", name, capturedRequest.Header.Get(name)) + } + } + } + } + }) + } +} + +// mockRoundTripper is a mock implementation of http.RoundTripper for testing +type mockRoundTripper struct { + roundTripFunc func(*http.Request) (*http.Response, error) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if m.roundTripFunc != nil { + return m.roundTripFunc(req) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} diff --git a/github/data_source_github_release_asset.go b/github/data_source_github_release_asset.go new file mode 100644 index 0000000000..030ee8b7bc --- /dev/null +++ b/github/data_source_github_release_asset.go @@ -0,0 +1,161 @@ +package github + +import ( + "context" + "io" + "strconv" + "strings" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubReleaseAsset() *schema.Resource { + return &schema.Resource{ + Description: "Retrieve information about a GitHub release asset.", + ReadContext: dataSourceGithubReleaseAssetRead, + + Schema: map[string]*schema.Schema{ + "asset_id": { + Type: schema.TypeInt, + Required: true, + Description: "ID of the release asset to retrieve", + }, + "owner": { + Type: schema.TypeString, + Required: true, + Description: "Owner of the repository", + }, + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository to retrieve the release asset from", + }, + "body": { + Type: schema.TypeString, + Computed: true, + Description: "The release asset body", + }, + "url": { + Type: schema.TypeString, + Computed: true, + Description: "URL of the asset", + }, + "node_id": { + Type: schema.TypeString, + Computed: true, + Description: "Node ID of the asset", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "File name of the asset", + }, + "label": { + Type: schema.TypeString, + Computed: true, + Description: "Label for the asset", + }, + "content_type": { + Type: schema.TypeString, + Computed: true, + Description: "MIME type of the asset", + }, + "size": { + Type: schema.TypeInt, + Computed: true, + Description: "Asset size in bytes", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date the asset was created", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date the asset was updated", + }, + "browser_download_url": { + Type: schema.TypeString, + Computed: true, + Description: "Browser URL from which the release asset can be downloaded", + }, + }, + } +} + +func dataSourceGithubReleaseAssetRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + repository := d.Get("repository").(string) + owner := d.Get("owner").(string) + + client := meta.(*Owner).v3client + + var err error + var asset *github.ReleaseAsset + + assetID := int64(d.Get("asset_id").(int)) + asset, _, err = client.Repositories.GetReleaseAsset(ctx, owner, repository, assetID) + if err != nil { + return diag.FromErr(err) + } + + var respBody io.ReadCloser + clientCopy := client.Client() + respBody, _, err = client.Repositories.DownloadReleaseAsset(ctx, owner, repository, assetID, clientCopy) + if err != nil { + return diag.FromErr(err) + } + defer respBody.Close() + + buf := new(strings.Builder) + _, err = io.Copy(buf, respBody) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(strconv.FormatInt(asset.GetID(), 10)) + err = d.Set("body", buf.String()) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("url", asset.URL) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("node_id", asset.NodeID) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("name", asset.Name) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("label", asset.Label) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("content_type", asset.ContentType) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("size", asset.Size) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("created_at", asset.CreatedAt.String()) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("created_at", asset.UpdatedAt.String()) + if err != nil { + return diag.FromErr(err) + } + err = d.Set("browser_download_url", asset.BrowserDownloadURL) + if err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_release_asset_test.go b/github/data_source_github_release_asset_test.go new file mode 100644 index 0000000000..142f0bef72 --- /dev/null +++ b/github/data_source_github_release_asset_test.go @@ -0,0 +1,72 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubReleaseAssetDataSource(t *testing.T) { + + testReleaseRepository := testAccConf.testPublicRepository + + // NOTE: the default repository, owner, asset ID, asset name, and asset content + // values can be overridden with GH_TEST* environment variables to exercise + // tests against different release assets in development. + if os.Getenv("GH_TEST_REPOSITORY") != "" { + testReleaseRepository = os.Getenv("GH_TEST_REPOSITORY") + } + + // The terraform-provider-github_6.4.0_manifest.json asset ID from + // https://github.com/integrations/terraform-provider-github/releases/tag/v6.4.0 + testReleaseAssetID := "207956097" + if os.Getenv("GH_TEST_REPOSITORY_RELEASE_ASSET_ID") != "" { + testReleaseAssetID = os.Getenv("GH_TEST_REPOSITORY_RELEASE_ASSET_ID") + } + + testReleaseAssetName := "terraform-provider-github_6.4.0_manifest.json" + if os.Getenv("GH_TEST_REPOSITORY_RELEASE_ASSET_NAME") != "" { + testReleaseAssetName = os.Getenv("GH_TEST_REPOSITORY_RELEASE_ASSET_NAME") + } + + testReleaseAssetContent := "{\n \"version\": 1,\n \"metadata\": {\n \"protocol_versions\": [\n \"5.0\"\n ]\n }\n}\n" + if os.Getenv("GH_TEST_REPOSITORY_RELEASE_ASSET_CONTENT") != "" { + testReleaseAssetContent = os.Getenv("GH_TEST_REPOSITORY_RELEASE_ASSET_CONTENT") + } + + t.Run("queries specified asset ID", func(t *testing.T) { + + config := fmt.Sprintf(` + data "github_release_asset" "test" { + repository = "%s" + owner = "%s" + asset_id = "%s" + } + `, testReleaseRepository, testAccConf.testPublicRepositoryOwner, testReleaseAssetID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "asset_id", testReleaseAssetID, + ), + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "name", testReleaseAssetName, + ), + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "body", testReleaseAssetContent, + ), + ) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + }) +} diff --git a/github/provider.go b/github/provider.go index d96b919522..e2b4ade1b4 100644 --- a/github/provider.go +++ b/github/provider.go @@ -264,6 +264,7 @@ func Provider() *schema.Provider { "github_organization_webhooks": dataSourceGithubOrganizationWebhooks(), "github_ref": dataSourceGithubRef(), "github_release": dataSourceGithubRelease(), + "github_release_asset": dataSourceGithubReleaseAsset(), "github_repositories": dataSourceGithubRepositories(), "github_repository": dataSourceGithubRepository(), "github_repository_autolink_references": dataSourceGithubRepositoryAutolinkReferences(), diff --git a/website/docs/d/release_asset.html.markdown b/website/docs/d/release_asset.html.markdown new file mode 100644 index 0000000000..7b26202241 --- /dev/null +++ b/website/docs/d/release_asset.html.markdown @@ -0,0 +1,84 @@ +--- +layout: "github" +page_title: "GitHub: github_release_asset" +description: |- + Get information on a GitHub release asset. +--- + +# github\_release\_asset + +Use this data source to retrieve information about a GitHub release asset. + +## Example Usage +To retrieve the latest release that is present in a repository: + +```hcl +data "github_release" "example" { + repository = "example-repository" + owner = "example-owner" + retrieve_by = "latest" +} +``` + +To retrieve a specific release asset from a repository based on its ID: + +```hcl +data "github_release_asset" "example" { + repository = "example-repository" + owner = "example-owner" + asset_id = 12345 +} +``` + +To retrieve the first release asset associated with the the latest release in a repository: + +```hcl +data "github_release" "example" { + repository = "example-repository" + owner = "example-owner" + retrieve_by = "latest" +} + +data "github_release_asset" "example" { + repository = "example-repository" + owner = "example-owner" + asset_id = data.github_release.example.assets[0].id +} +``` + +To retrieve all release assets associated with the the latest release in a repository: + +```hcl +data "github_release" "example" { + repository = "example-repository" + owner = "example-owner" + retrieve_by = "latest" +} + +data "github_release_asset" "example" { + count = length(data.github_release.example.assets) + repository = "example-repository" + owner = "example-owner" + asset_id = data.github_release.example.assets[count.index].id +} +``` + +## Argument Reference + +* `repository` - (Required) Name of the repository to retrieve the release from +* `owner` - (Required) Owner of the repository +* `asset_id` - (Required) ID of the release asset to retrieve + +## Attributes Reference + +* `id` - ID of the asset +* `url` - URL of the asset +* `node_id` - Node ID of the asset +* `name` - The file name of the asset +* `label` - Label for the asset +* `content_type` - MIME type of the asset +* `size` - Asset size in bytes +* `created_at` - Date the asset was created +* `updated_at` - Date the asset was last updated +* `browser_download_url` - Browser URL from which the release asset can be downloaded +* `body` - The release asset body