break up Generate
This commit is contained in:
+52
-119
@@ -4,17 +4,11 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/format"
|
"go/format"
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/vektah/gqlparser"
|
|
||||||
"github.com/vektah/gqlparser/ast"
|
"github.com/vektah/gqlparser/ast"
|
||||||
"github.com/vektah/gqlparser/formatter"
|
"github.com/vektah/gqlparser/formatter"
|
||||||
"github.com/vektah/gqlparser/parser"
|
|
||||||
"github.com/vektah/gqlparser/validator"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: package template into the binary using one of those asset thingies
|
// TODO: package template into the binary using one of those asset thingies
|
||||||
@@ -22,14 +16,14 @@ const tmplFilename = "generate/operation.go.tmpl"
|
|||||||
|
|
||||||
var tmpl = template.Must(template.ParseFiles(tmplFilename))
|
var tmpl = template.Must(template.ParseFiles(tmplFilename))
|
||||||
|
|
||||||
type TemplateParams struct {
|
type templateParams struct {
|
||||||
// The name of the package into which to generate the operation-helpers.
|
// The name of the package into which to generate the operation-helpers.
|
||||||
PackageName string
|
PackageName string
|
||||||
// The list of operations for which to generate code.
|
// The list of operations for which to generate code.
|
||||||
Operations []Operation
|
Operations []operation
|
||||||
}
|
}
|
||||||
|
|
||||||
type Operation struct {
|
type operation struct {
|
||||||
// The type of the operation (query, mutation, or subscription).
|
// The type of the operation (query, mutation, or subscription).
|
||||||
Type ast.Operation
|
Type ast.Operation
|
||||||
// The name of the operation, from GraphQL.
|
// The name of the operation, from GraphQL.
|
||||||
@@ -39,7 +33,7 @@ type Operation struct {
|
|||||||
// The body of the operation to send.
|
// The body of the operation to send.
|
||||||
Body string
|
Body string
|
||||||
// The arguments to the operation.
|
// The arguments to the operation.
|
||||||
Args []Argument
|
Args []argument
|
||||||
|
|
||||||
// The type-name for the operation's response type.
|
// The type-name for the operation's response type.
|
||||||
ResponseName string
|
ResponseName string
|
||||||
@@ -47,138 +41,77 @@ type Operation struct {
|
|||||||
ResponseType string
|
ResponseType string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Argument struct {
|
type argument struct {
|
||||||
GoName string
|
GoName string
|
||||||
GoType string
|
GoType string
|
||||||
GraphQLName string
|
GraphQLName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Generate(specFilename, schemaFilename, generatedFilename string) error {
|
func fromASTArg(arg *ast.VariableDefinition, schema *ast.Schema) argument {
|
||||||
// TODO: all the read-parse-and-validate stuff can probably get factored
|
return argument{
|
||||||
// out a bit
|
GraphQLName: arg.Variable,
|
||||||
// TODO: IRL we have to get the schema from GraphQL (maybe we can generate
|
GoName: arg.Variable, // TODO: normalize this to go-style
|
||||||
// that once we can bootstrap) where it comes as JSON, not SDL, so we have
|
GoType: typeForInputType(arg.Type, schema),
|
||||||
// to convert (or add gqlparser support to convert)
|
// TODO: figure out what to do about defaults
|
||||||
text, err := ioutil.ReadFile(schemaFilename)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unreadable schema file %v: %v",
|
|
||||||
schemaFilename, err)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
schema, graphqlError := gqlparser.LoadSchema(
|
func fromASTOperation(op *ast.OperationDefinition, schema *ast.Schema) operation {
|
||||||
&ast.Source{Name: schemaFilename, Input: string(text)})
|
// TODO: we may have to actually get the precise query text, in case we
|
||||||
if graphqlError != nil {
|
// want to be hashing it or something like that. Although maybe
|
||||||
return fmt.Errorf("invalid schema file %v: %v",
|
// there's no reasonable way to do that with several queries in one
|
||||||
schemaFilename, graphqlError)
|
// file.
|
||||||
|
var builder strings.Builder
|
||||||
|
f := formatter.NewFormatter(&builder)
|
||||||
|
f.FormatQueryDocument(&ast.QueryDocument{
|
||||||
|
Operations: ast.OperationList{op},
|
||||||
|
// TODO: handle fragments
|
||||||
|
})
|
||||||
|
|
||||||
|
args := make([]argument, len(op.VariableDefinitions))
|
||||||
|
for i, arg := range op.VariableDefinitions {
|
||||||
|
args[i] = fromASTArg(arg, schema)
|
||||||
}
|
}
|
||||||
|
return operation{
|
||||||
|
Type: op.Operation,
|
||||||
|
Name: op.Name,
|
||||||
|
// TODO: this is actually awkward, because GraphQL doesn't allow
|
||||||
|
// for docstrings on queries (only schemas). So we have to extract
|
||||||
|
// the comment, or omit doc-comments for now.
|
||||||
|
Doc: "TODO",
|
||||||
|
// The newline just makes it format a little nicer
|
||||||
|
Body: "\n" + builder.String(),
|
||||||
|
Args: args,
|
||||||
|
|
||||||
text, err = ioutil.ReadFile(specFilename)
|
// TODO: configure ResponseName format
|
||||||
if err != nil {
|
ResponseName: op.Name + "Response",
|
||||||
return fmt.Errorf("unreadable query-spec file %v: %v",
|
ResponseType: typeForOperation(op, schema),
|
||||||
specFilename, err)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The following is more or less gqlparser.LoadQuery, but we can provide a
|
func Generate(schema *ast.Schema, document *ast.QueryDocument) ([]byte, error) {
|
||||||
// name so we might as well (and we break out the two errors).
|
|
||||||
document, graphqlError := parser.ParseQuery(
|
|
||||||
&ast.Source{Name: specFilename, Input: string(text)})
|
|
||||||
if graphqlError != nil { // ParseQuery returns type *graphql.Error, yuck
|
|
||||||
return fmt.Errorf("invalid query-spec file %v: %v",
|
|
||||||
specFilename, graphqlError)
|
|
||||||
}
|
|
||||||
|
|
||||||
graphqlErrors := validator.Validate(schema, document)
|
|
||||||
if graphqlErrors != nil {
|
|
||||||
return fmt.Errorf("query-spec does not match schema: %v", graphqlErrors)
|
|
||||||
}
|
|
||||||
|
|
||||||
var out io.Writer
|
|
||||||
if generatedFilename == "-" {
|
|
||||||
out = os.Stdout
|
|
||||||
} else {
|
|
||||||
out, err = os.OpenFile(generatedFilename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not open generated file %v: %v",
|
|
||||||
generatedFilename, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: configure this
|
|
||||||
packageName := "example"
|
|
||||||
|
|
||||||
// TODO: this should probably get factored out
|
// TODO: this should probably get factored out
|
||||||
operations := make([]Operation, len(document.Operations))
|
operations := make([]operation, len(document.Operations))
|
||||||
for i, operation := range document.Operations {
|
for i, op := range document.Operations {
|
||||||
// TODO: we may have to actually get the precise query text, in case we
|
operations[i] = fromASTOperation(op, schema)
|
||||||
// want to be hashing it or something like that. Although maybe
|
|
||||||
// there's no reasonable way to do that with several queries in one
|
|
||||||
// file.
|
|
||||||
var builder strings.Builder
|
|
||||||
f := formatter.NewFormatter(&builder)
|
|
||||||
f.FormatQueryDocument(&ast.QueryDocument{
|
|
||||||
Operations: ast.OperationList{operation},
|
|
||||||
// TODO: handle fragments
|
|
||||||
})
|
|
||||||
|
|
||||||
args := make([]Argument, len(operation.VariableDefinitions))
|
|
||||||
for i, arg := range operation.VariableDefinitions {
|
|
||||||
args[i] = Argument{
|
|
||||||
GraphQLName: arg.Variable,
|
|
||||||
GoName: arg.Variable, // TODO: normalize this to go-style
|
|
||||||
GoType: typeForInputType(arg.Type, schema),
|
|
||||||
// TODO: figure out what to do about defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
operations[i] = Operation{
|
|
||||||
Type: operation.Operation,
|
|
||||||
Name: operation.Name,
|
|
||||||
// TODO: this is actually awkward, because GraphQL doesn't allow
|
|
||||||
// for docstrings on queries (only schemas). So we have to extract
|
|
||||||
// the comment, or omit doc-comments for now.
|
|
||||||
Doc: "TODO",
|
|
||||||
// The newline just makes it format a little nicer
|
|
||||||
Body: "\n" + builder.String(),
|
|
||||||
Args: args,
|
|
||||||
|
|
||||||
// TODO: configure ResponseName format
|
|
||||||
ResponseName: operation.Name + "Response",
|
|
||||||
ResponseType: typeForOperation(operation, schema),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data := TemplateParams{
|
data := templateParams{
|
||||||
PackageName: packageName,
|
// TODO: configure PackageName
|
||||||
|
PackageName: "example",
|
||||||
Operations: operations,
|
Operations: operations,
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = tmpl.Execute(&buf, data)
|
err := tmpl.Execute(&buf, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not render template: %v", err)
|
return nil, fmt.Errorf("could not render template: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
formatted, err := format.Source(buf.Bytes())
|
formatted, err := format.Source(buf.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not gofmt template: %v", err)
|
return nil, fmt.Errorf("could not gofmt template: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = out.Write(formatted)
|
return formatted, nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func Main() {
|
|
||||||
var err error
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if len(os.Args) != 4 {
|
|
||||||
err = fmt.Errorf("usage: %s queries.graphql schema.graphql generated.go",
|
|
||||||
os.Args[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = Generate(os.Args[1], os.Args[2], os.Args[3])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package generate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func outputWriter(filename string) (io.Writer, error) {
|
||||||
|
if filename == "-" {
|
||||||
|
return os.Stdout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not open generated file %v: %v",
|
||||||
|
filename, err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseGenerateAndWrite(specFilename, schemaFilename string, out io.Writer) error {
|
||||||
|
schema, err := getSchema(schemaFilename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
document, err := getAndValidateQueries(specFilename, schema)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := Generate(schema, document)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = out.Write(code)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Main() {
|
||||||
|
var err error
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(os.Args) != 4 {
|
||||||
|
err = fmt.Errorf("usage: %s queries.graphql schema.graphql generated.go",
|
||||||
|
os.Args[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := outputWriter(os.Args[3])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ParseGenerateAndWrite(os.Args[1], os.Args[2], out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package generate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
|
||||||
|
"github.com/vektah/gqlparser"
|
||||||
|
"github.com/vektah/gqlparser/ast"
|
||||||
|
"github.com/vektah/gqlparser/parser"
|
||||||
|
"github.com/vektah/gqlparser/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getSchema(filename string) (*ast.Schema, error) {
|
||||||
|
// TODO: IRL we have to get the schema from GraphQL (maybe we can generate
|
||||||
|
// that once we can bootstrap) where it comes as JSON, not SDL, so we have
|
||||||
|
// to convert (or add gqlparser support to convert)
|
||||||
|
text, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unreadable schema file %v: %v", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, graphqlError := gqlparser.LoadSchema(
|
||||||
|
&ast.Source{Name: filename, Input: string(text)})
|
||||||
|
if graphqlError != nil {
|
||||||
|
return nil, fmt.Errorf("invalid schema file %v: %v",
|
||||||
|
filename, graphqlError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAndValidateQueries(filename string, schema *ast.Schema) (*ast.QueryDocument, error) {
|
||||||
|
text, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unreadable query-spec file %v: %v", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following is more or less gqlparser.LoadQuery, but we can provide a
|
||||||
|
// name so we might as well (and we break out the two errors).
|
||||||
|
document, graphqlError := parser.ParseQuery(
|
||||||
|
&ast.Source{Name: filename, Input: string(text)})
|
||||||
|
if graphqlError != nil { // ParseQuery returns type *graphql.Error, yuck
|
||||||
|
return nil, fmt.Errorf("invalid query-spec file %v: %v", filename, graphqlError)
|
||||||
|
}
|
||||||
|
|
||||||
|
graphqlErrors := validator.Validate(schema, document)
|
||||||
|
if graphqlErrors != nil {
|
||||||
|
return nil, fmt.Errorf("query-spec does not match schema: %v", graphqlErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
@@ -2,13 +2,18 @@ github.com/Khan/graphql v0.0.0-20191109005718-3b51154b2bc5 h1:C7qbo6snTa0/lSpVKY
|
|||||||
github.com/Khan/graphql v0.0.0-20191109005718-3b51154b2bc5/go.mod h1:DLkRTcWV7KR4zw1iTm1lpShI3XP3nFmIMs3fHxYAsF0=
|
github.com/Khan/graphql v0.0.0-20191109005718-3b51154b2bc5/go.mod h1:DLkRTcWV7KR4zw1iTm1lpShI3XP3nFmIMs3fHxYAsF0=
|
||||||
github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ=
|
github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ=
|
||||||
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
|
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
|
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
|
||||||
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
|
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/vektah/gqlparser v1.2.0 h1:ntkSCX7F5ZJKl+HIVnmLaO269MruasVpNiMOjX9kgo0=
|
github.com/vektah/gqlparser v1.2.0 h1:ntkSCX7F5ZJKl+HIVnmLaO269MruasVpNiMOjX9kgo0=
|
||||||
github.com/vektah/gqlparser v1.2.0/go.mod h1:bkVf0FX+Stjg/MHnm8mEyubuaArhNEqfQhF+OTiAL74=
|
github.com/vektah/gqlparser v1.2.0/go.mod h1:bkVf0FX+Stjg/MHnm8mEyubuaArhNEqfQhF+OTiAL74=
|
||||||
@@ -19,4 +24,5 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
|||||||
Reference in New Issue
Block a user