Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add functionality that allows header-from and footer-from to get references from external sources #763

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/user-guide/configuration/footer-from.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" >}}
Expand Down Expand Up @@ -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"
```
8 changes: 7 additions & 1 deletion docs/user-guide/configuration/header-from.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" >}}
Expand Down Expand Up @@ -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"
```
98 changes: 98 additions & 0 deletions terraform/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
134 changes: 134 additions & 0 deletions terraform/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ the root directory of this source tree.
package terraform

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -451,6 +453,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 {
Expand Down