Repository: jensneuse/graphql-go-tools (go)

Issue:
#import comments to combine multiple schema files
I found it annoying to copy schema definitions around in order to make parsers happy.
A comment at the top of a file declaring imports will solve the problem.
Nesting should also be possible.
This should be straightforward as we only have to scan for all files declared in input statements and merge them all into one big file.
Keep in mind that paths need to be relative to the file declaring the input statement.

Example:

main.graphql

```graphql
#import "../common/schema.graphql"
```

../common/schema.graphql

```graphql
#import "./specific/schema.graphql"
```

correct imports:
../common/schema.graphql
../common/specific/schema.graphql

wrong imports:
../common/schema.graphql
./specific/schema.graphql

should also allow regex for paths:
https://golang.org/pkg/path/filepath/#Match

Pull request description:
implement import scanner & GraphQL File renderer
This PR implements a file system scanner that understands a simple #import "file" syntax to combine multiple graphql files into one. The file also allows rendering the merged file into an io.Writer.
This will close #100 

Patch:
diff --git a/pkg/imports/fixtures/render_result.golden b/pkg/imports/fixtures/render_result.golden
new file mode 100644
index 00000000..25826a44
--- /dev/null
+++ b/pkg/imports/fixtures/render_result.golden
@@ -0,0 +1,41 @@
+#file: testdata/schema.graphql
+
+
+schema {
+    query: Query
+    mutation: Mutation
+}
+
+
+#file: testdata/scalars/json.graphql
+
+scalar JSON
+
+
+#file: testdata/types/query.graphql
+
+type Query {
+    foo: Foo
+}
+
+
+#file: testdata/nested/nested.graphql
+
+
+type Bar {
+    baz: Int
+}
+
+
+#file: testdata/nested2/nested2.graphql
+
+type Bat {
+    bar: String
+}
+
+
+#file: testdata/deep/deeper/custom_types.graphql
+
+type Foo {
+    field: String
+}
diff --git a/pkg/imports/fixtures/scanner_result.golden b/pkg/imports/fixtures/scanner_result.golden
new file mode 100644
index 00000000..a71140cc
--- /dev/null
+++ b/pkg/imports/fixtures/scanner_result.golden
@@ -0,0 +1,26 @@
+{
+  "RelativePath": "testdata/schema.graphql",
+  "Imports": [
+    {
+      "RelativePath": "testdata/scalars/json.graphql",
+      "Imports": null
+    },
+    {
+      "RelativePath": "testdata/types/query.graphql",
+      "Imports": null
+    },
+    {
+      "RelativePath": "testdata/nested/nested.graphql",
+      "Imports": [
+        {
+          "RelativePath": "testdata/nested2/nested2.graphql",
+          "Imports": null
+        }
+      ]
+    },
+    {
+      "RelativePath": "testdata/deep/deeper/custom_types.graphql",
+      "Imports": null
+    }
+  ]
+}
\ No newline at end of file
diff --git a/pkg/imports/graphql_file.go b/pkg/imports/graphql_file.go
new file mode 100644
index 00000000..cba049b2
--- /dev/null
+++ b/pkg/imports/graphql_file.go
@@ -0,0 +1,76 @@
+package imports
+
+import (
+	"bufio"
+	"github.com/jensneuse/graphql-go-tools/pkg/lexer/literal"
+	"io"
+	"os"
+)
+
+type GraphQLFile struct {
+	RelativePath string
+	Imports      []GraphQLFile
+}
+
+func (g GraphQLFile) Render(printFilePath bool, out io.Writer) error {
+	return g.render(printFilePath, out)
+}
+
+func (g GraphQLFile) render(printFilePath bool, out io.Writer) error {
+	file, err := os.Open(g.RelativePath)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	if printFilePath {
+		err = g.renderFilePath(out)
+		if err != nil {
+			return err
+		}
+	}
+
+	reader := bufio.NewReader(file)
+	for {
+		line, _, err := reader.ReadLine()
+		if err != nil {
+			break
+		}
+
+		if importStatementRegex.Match(line) {
+			continue
+		}
+
+		_, err = out.Write(line)
+		if err != nil {
+			return err
+		}
+
+		_, err = out.Write(literal.LINETERMINATOR)
+		if err != nil {
+			return err
+		}
+	}
+
+	for _, importFile := range g.Imports {
+		_, err = out.Write(literal.LINETERMINATOR)
+		if err != nil {
+			return err
+		}
+		_, err = out.Write(literal.LINETERMINATOR)
+		if err != nil {
+			return err
+		}
+		err = importFile.render(printFilePath, out)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (g GraphQLFile) renderFilePath(out io.Writer) error {
+	_, err := out.Write([]byte("#file: " + g.RelativePath + "\n\n"))
+	return err
+}
diff --git a/pkg/imports/imports.go b/pkg/imports/imports.go
new file mode 100644
index 00000000..a8c5ef02
--- /dev/null
+++ b/pkg/imports/imports.go
@@ -0,0 +1,102 @@
+package imports
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"regexp"
+	"strings"
+)
+
+var (
+	importStatementRegex, _ = regexp.Compile(`(#import "[^";]+")`)
+	pathStatementRegex, _   = regexp.Compile(`"(.*?)"`)
+)
+
+type Scanner struct {
+	knownFiles map[string]struct{}
+}
+
+func (s *Scanner) ScanFile(inputFilePath string) (*GraphQLFile, error) {
+	s.knownFiles = map[string]struct{}{}
+	return s.scanFile(inputFilePath)
+}
+
+func (s *Scanner) scanFile(inputFilePath string) (*GraphQLFile, error) {
+
+	basePath, err := os.Getwd()
+	if err != nil {
+		return nil, err
+	}
+
+	absoluteFilePath, err := filepath.Abs(inputFilePath)
+	if err != nil {
+		return nil, err
+	}
+
+	relativeFilePath, err := filepath.Rel(basePath, absoluteFilePath)
+	if err != nil {
+		return nil, err
+	}
+
+	if _, exists := s.knownFiles[relativeFilePath]; exists {
+		return nil, fmt.Errorf("file forms import cycle: %s", relativeFilePath)
+	}
+
+	s.knownFiles[relativeFilePath] = struct{}{}
+
+	fileDir := filepath.Dir(relativeFilePath)
+
+	content, err := ioutil.ReadFile(inputFilePath)
+	if err != nil {
+		return nil, err
+	}
+
+	file := &GraphQLFile{
+		RelativePath: relativeFilePath,
+	}
+
+	importStatements := importStatementRegex.FindAll(content, -1)
+	for i := 0; i < len(importStatements); i++ {
+		importFilePath := s.importFilePath(string(importStatements[i]))
+		filePathPattern := path.Join(fileDir, importFilePath)
+		if importFilePath != "" {
+			imports, err := s.fileImportsForPattern(filePathPattern)
+			if err != nil {
+				return nil, err
+			}
+			file.Imports = append(file.Imports, imports...)
+		}
+	}
+
+	return file, nil
+}
+
+func (s *Scanner) importFilePath(importStatement string) string {
+	out := pathStatementRegex.FindString(importStatement)
+	out = strings.TrimLeft(out, "\"")
+	out = strings.TrimRight(out, "\"")
+	return out
+}
+
+func (s *Scanner) fileImportsForPattern(pattern string) ([]GraphQLFile, error) {
+	matches, err := filepath.Glob(pattern)
+	if err != nil {
+		return nil, err
+	}
+
+	out := make([]GraphQLFile, 0, len(matches))
+	for _, match := range matches {
+		importFile, err := s.scanFile(match)
+		if err != nil {
+			return nil, err
+		}
+		if importFile == nil {
+			continue
+		}
+		out = append(out, *importFile)
+	}
+	return out, nil
+}


Test patch:
diff --git a/pkg/imports/graphql_file_test.go b/pkg/imports/graphql_file_test.go
new file mode 100644
index 00000000..17e69c9f
--- /dev/null
+++ b/pkg/imports/graphql_file_test.go
@@ -0,0 +1,35 @@
+package imports
+
+import (
+	"bytes"
+	"github.com/jensneuse/diffview"
+	"github.com/sebdah/goldie"
+	"io/ioutil"
+	"testing"
+)
+
+func TestGraphQLFile_Render(t *testing.T) {
+	scanner := Scanner{}
+	file, err := scanner.ScanFile("testdata/schema.graphql")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	out := bytes.Buffer{}
+	err = file.Render(true, &out)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	dump := out.Bytes()
+
+	goldie.Assert(t, "render_result", dump)
+	if t.Failed() {
+		fixture, err := ioutil.ReadFile("./fixtures/render_result.golden")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		diffview.NewGoland().DiffViewBytes("render_result", fixture, dump)
+	}
+}
diff --git a/pkg/imports/imports_test.go b/pkg/imports/imports_test.go
new file mode 100644
index 00000000..9e7d41fb
--- /dev/null
+++ b/pkg/imports/imports_test.go
@@ -0,0 +1,45 @@
+package imports
+
+import (
+	"encoding/json"
+	"github.com/jensneuse/diffview"
+	"github.com/sebdah/goldie"
+	"io/ioutil"
+	"testing"
+)
+
+func TestScanner(t *testing.T) {
+	scanner := Scanner{}
+	file, err := scanner.ScanFile("./testdata/schema.graphql")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	dump, err := json.MarshalIndent(file, "", "  ")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	goldie.Assert(t, "scanner_result", dump)
+	if t.Failed() {
+		fixture, err := ioutil.ReadFile("./fixtures/scanner_result.golden")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		diffview.NewGoland().DiffViewBytes("scanner_result", fixture, dump)
+	}
+}
+
+func TestScannerImportCycle(t *testing.T) {
+	scanner := Scanner{}
+	_, err := scanner.ScanFile("./testdata/import_cycle.graphql")
+	if err == nil {
+		t.Fatal("want err")
+	}
+	want := "file forms import cycle: testdata/cycle/a/a.graphql"
+	got := err.Error()
+	if want != got {
+		t.Fatalf("want err:\n\"%s\"\ngot:\n\"%s\"\n", want, got)
+	}
+}
diff --git a/pkg/imports/testdata/cycle/a/a.graphql b/pkg/imports/testdata/cycle/a/a.graphql
new file mode 100644
index 00000000..c6b098c8
--- /dev/null
+++ b/pkg/imports/testdata/cycle/a/a.graphql
@@ -0,0 +1,1 @@
+#import "../b/b.graphql"
\ No newline at end of file
diff --git a/pkg/imports/testdata/cycle/b/b.graphql b/pkg/imports/testdata/cycle/b/b.graphql
new file mode 100644
index 00000000..6d587f01
--- /dev/null
+++ b/pkg/imports/testdata/cycle/b/b.graphql
@@ -0,0 +1,1 @@
+#import "../a/a.graphql"
\ No newline at end of file
diff --git a/pkg/imports/testdata/deep/deeper/custom_types.graphql b/pkg/imports/testdata/deep/deeper/custom_types.graphql
new file mode 100644
index 00000000..d8738959
--- /dev/null
+++ b/pkg/imports/testdata/deep/deeper/custom_types.graphql
@@ -0,0 +1,3 @@
+type Foo {
+    field: String
+}
\ No newline at end of file
diff --git a/pkg/imports/testdata/deep/deeper/non_graphql.txt b/pkg/imports/testdata/deep/deeper/non_graphql.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/pkg/imports/testdata/import_cycle.graphql b/pkg/imports/testdata/import_cycle.graphql
new file mode 100644
index 00000000..4b5406cc
--- /dev/null
+++ b/pkg/imports/testdata/import_cycle.graphql
@@ -0,0 +1,1 @@
+#import "cycle/a/a.graphql"
\ No newline at end of file
diff --git a/pkg/imports/testdata/nested/nested.graphql b/pkg/imports/testdata/nested/nested.graphql
new file mode 100644
index 00000000..603402f1
--- /dev/null
+++ b/pkg/imports/testdata/nested/nested.graphql
@@ -0,0 +1,5 @@
+#import "../nested2/nested2.graphql"
+
+type Bar {
+    baz: Int
+}
\ No newline at end of file
diff --git a/pkg/imports/testdata/nested2/nested2.graphql b/pkg/imports/testdata/nested2/nested2.graphql
new file mode 100644
index 00000000..e65eac67
--- /dev/null
+++ b/pkg/imports/testdata/nested2/nested2.graphql
@@ -0,0 +1,3 @@
+type Bat {
+    bar: String
+}
\ No newline at end of file
diff --git a/pkg/imports/testdata/scalars/json.graphql b/pkg/imports/testdata/scalars/json.graphql
new file mode 100644
index 00000000..e706f01c
--- /dev/null
+++ b/pkg/imports/testdata/scalars/json.graphql
@@ -0,0 +1,1 @@
+scalar JSON
\ No newline at end of file
diff --git a/pkg/imports/testdata/schema.graphql b/pkg/imports/testdata/schema.graphql
new file mode 100644
index 00000000..ee7d6315
--- /dev/null
+++ b/pkg/imports/testdata/schema.graphql
@@ -0,0 +1,9 @@
+#import "scalars/*.graphql"
+#import "types/query.graphql"
+#import "nested/nested.graphql"
+#import "deep/**/*.graphql"
+
+schema {
+    query: Query
+    mutation: Mutation
+}
\ No newline at end of file
diff --git a/pkg/imports/testdata/types/mutation.graphql b/pkg/imports/testdata/types/mutation.graphql
new file mode 100644
index 00000000..bd659efb
--- /dev/null
+++ b/pkg/imports/testdata/types/mutation.graphql
@@ -0,0 +1,3 @@
+type Mutation {
+    mutateFoo: Foo
+}
\ No newline at end of file
diff --git a/pkg/imports/testdata/types/query.graphql b/pkg/imports/testdata/types/query.graphql
new file mode 100644
index 00000000..21e1521d
--- /dev/null
+++ b/pkg/imports/testdata/types/query.graphql
@@ -0,0 +1,3 @@
+type Query {
+    foo: Foo
+}
\ No newline at end of file