diff --git a/docs/user-guide/configuration/footer-from.md b/docs/user-guide/configuration/footer-from.md index 6a425fa8..8d77da0d 100644 --- a/docs/user-guide/configuration/footer-from.md +++ b/docs/user-guide/configuration/footer-from.md @@ -10,7 +10,7 @@ toc: true Since `v0.12.0` -Relative path to a file to extract footer for the generated output from. Supported +Relative path to a file or external source (http, https, s3) to extract footer for the generated output from. Supported file formats are `.adoc`, `.md`, `.tf`, and `.txt`. {{< alert type="info" >}} @@ -68,3 +68,9 @@ Read `docs/.footer.md` to extract footer: ```yaml footer-from: "docs/.footer.md" ``` + +Read `https://raw.githubusercontent.com/terraform-docs/terraform-docs/master/terraform/testdata/full-example/doc.md` to extract footer: + +```yaml +header-from: "https://raw.githubusercontent.com/terraform-docs/terraform-docs/master/terraform/testdata/full-example/doc.md" +``` \ No newline at end of file diff --git a/docs/user-guide/configuration/header-from.md b/docs/user-guide/configuration/header-from.md index b5f016ef..fb0c346b 100644 --- a/docs/user-guide/configuration/header-from.md +++ b/docs/user-guide/configuration/header-from.md @@ -10,7 +10,7 @@ toc: true Since `v0.10.0` -Relative path to a file to extract header for the generated output from. Supported +Relative path to a file or external source (http, https, s3) to extract header for the generated output from. Supported file formats are `.adoc`, `.md`, `.tf`, and `.txt`. {{< alert type="info" >}} @@ -68,3 +68,9 @@ Read `docs/.header.md` to extract header: ```yaml header-from: "docs/.header.md" ``` + +Read `https://raw.githubusercontent.com/terraform-docs/terraform-docs/master/terraform/testdata/full-example/doc.md` to extract header: + +```yaml +header-from: "https://raw.githubusercontent.com/terraform-docs/terraform-docs/master/terraform/testdata/full-example/doc.md" +``` diff --git a/terraform/load.go b/terraform/load.go index fea188ba..b57eeadc 100644 --- a/terraform/load.go +++ b/terraform/load.go @@ -11,15 +11,19 @@ the root directory of this source tree. package terraform import ( + "context" "encoding/json" "errors" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" + "time" "github.com/hashicorp/hcl/v2/hclsimple" @@ -114,6 +118,74 @@ func isFileFormatSupported(filename string, section string) (bool, error) { return false, fmt.Errorf("only .adoc, .md, .tf, and .txt formats are supported to read %s from", section) } +func getSource(filename string) string { + // Default source is local + source := "local" + + // Identify another source different from the local for the filename + if strings.HasPrefix(filename, "http") || strings.HasPrefix(filename, "https") || strings.HasPrefix(filename, "s3") { + source = "web" + } + + return source +} + +func sendHTTPRequest(url string) (string, error) { + // Creation of context + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Send GET request + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) // #nosec G107 + if err != nil { + fmt.Println("Error:", err) + return "", err + } + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error:", err) + return "", err + } + + defer func() { + errDefer := resp.Body.Close() + if errDefer != nil { + fmt.Println("Error closing response body:", errDefer) + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Println("Error:", err) + return "", err + } + + return string(body), nil +} + +func createTempFile(config *print.Config, url string, content string) (string, error) { + // Creation of context + fileFormat := getFileFormat(url) + tempFile, err := os.CreateTemp("", "temp-*"+fileFormat) // Pattern with temp-*.* extension + if err != nil { + fmt.Println("Error creating temporary file:", err) + return "", err + } + + // overrride file name, otherwise it will use the URL and not the temp file created + filename := filepath.Join("/", config.ModuleRoot, tempFile.Name()) + + // Write the content to the temporary file + if _, err := tempFile.WriteString(content); err != nil { + fmt.Println("Error writing to temporary file:", err) + return "", err + } + + return filename, nil +} + func loadHeader(config *print.Config) (string, error) { if !config.Sections.Header { return "", nil @@ -140,6 +212,32 @@ func loadSection(config *print.Config, file string, section string) (string, err if ok, err := isFileFormatSupported(file, section); !ok { return "", err } + sourceType := getSource(file) + + if sourceType == "web" { + // Request content of the URL + response, err := sendHTTPRequest(file) + if err != nil { + fmt.Println("Error:", err) + return "", err + } + + // Create temp file with the remote content + filename, err = createTempFile(config, file, response) + if err != nil { + fmt.Println("Error:", err) + return "", err + } + + // Ensure the temporary file is removed + defer func() { + errDefer := os.Remove(filename) + if errDefer != nil { + fmt.Println("Error removing temporary file:", errDefer) + } + }() + } + if info, err := os.Stat(filename); os.IsNotExist(err) || info.IsDir() { if section == "header" && file == "main.tf" { return "", nil // absorb the error to not break workflow for default value of header and missing 'main.tf' diff --git a/terraform/load_test.go b/terraform/load_test.go index 89a87eb1..15e984d8 100644 --- a/terraform/load_test.go +++ b/terraform/load_test.go @@ -11,6 +11,8 @@ the root directory of this source tree. package terraform import ( + "net/http" + "net/http/httptest" "fmt" "os" "path/filepath" @@ -454,6 +456,138 @@ func TestLoadSections(t *testing.T) { }) } } +func TestLoadSectionsFromUrl(t *testing.T) { + tests := []struct { + name string + file string + expected string + wantErr bool + errText string + section string + }{ + { + name: "load module header from url", + file: "https://raw.githubusercontent.com/terraform-docs/terraform-docs/master/terraform/testdata/full-example/doc.md", + expected: "# Custom Header\n\nExample of 'foo_bar' module in `foo_bar.tf`.\n\n- list item 1\n- list item 2\n", + wantErr: false, + errText: "", + section: "header", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + + config := print.NewConfig() + actual, err := loadSection(config, tt.file, tt.section) + if tt.wantErr { + assert.NotNil(err) + assert.Equal(tt.errText, err.Error()) + } else { + assert.Nil(err) + assert.Equal(tt.expected, actual) + } + }) + } +} + +func TestGetSource(t *testing.T) { + tests := []struct { + name string + filename string + expected string + }{ + { + name: "Local file", + filename: "file.txt", + expected: "local", + }, + { + name: "HTTP file", + filename: "http://example.com/file.txt", + expected: "web", + }, + { + name: "HTTPS file", + filename: "https://example.com/file.txt", + expected: "web", + }, + { + name: "S3 file", + filename: "s3://bucket/file.txt", + expected: "web", + }, + { + name: "Empty filename", + filename: "", + expected: "local", + }, + { + name: "Non-standard URL", + filename: "ftp://example.com/file.txt", + expected: "local", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := getSource(tt.filename) + if actual != tt.expected { + t.Errorf("Expected source for %s: %s, got: %s", tt.filename, tt.expected, actual) + } + }) + } +} + +func TestSendHTTPRequest(t *testing.T) { + // Create a mock server + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Mock response")) + }) + mockServer := httptest.NewServer(mockHandler) + defer mockServer.Close() + + tests := []struct { + name string + url string + responseCode int + responseBody string + expectedBody string + expectedError bool + }{ + { + name: "Successful request", + url: mockServer.URL, + responseCode: http.StatusOK, + responseBody: "Mock response", + expectedBody: "Mock response", + expectedError: false, + }, + { + name: "Timeout", + url: "http://unreachable", + responseCode: 0, // No response code due to timeout + responseBody: "", + expectedBody: "", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualBody, actualErr := sendHTTPRequest(tt.url) + + if actualErr != nil && !tt.expectedError { + t.Errorf("Expected no error, got: %v", actualErr) + } + + if actualBody != tt.expectedBody { + t.Errorf("Expected body: %s, got: %s", tt.expectedBody, actualBody) + } + }) + } +} func TestLoadInputs(t *testing.T) { type expected struct {