initial commit
This commit is contained in:
commit
54576d9d33
61 changed files with 5569 additions and 0 deletions
140
internal/contract/contract.go
Normal file
140
internal/contract/contract.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
OperationID string `json:"operation_id"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
Version string
|
||||
JSON []byte
|
||||
Endpoints []Endpoint
|
||||
}
|
||||
|
||||
func Parse(content []byte) (Document, error) {
|
||||
var value map[string]any
|
||||
if json.Unmarshal(content, &value) != nil {
|
||||
var yamlValue any
|
||||
if err := yaml.Unmarshal(content, &yamlValue); err != nil {
|
||||
return Document{}, fmt.Errorf("parse OpenAPI document: %w", err)
|
||||
}
|
||||
normalized, err := normalizeYAML(yamlValue)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
value, _ = normalized.(map[string]any)
|
||||
}
|
||||
version, _ := value["openapi"].(string)
|
||||
if version == "" {
|
||||
if swagger, _ := value["swagger"].(string); swagger != "" {
|
||||
version = swagger
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
return Document{}, errors.New("document is not an OpenAPI or Swagger contract")
|
||||
}
|
||||
var endpoints []Endpoint
|
||||
paths, _ := value["paths"].(map[string]any)
|
||||
for path, rawOperations := range paths {
|
||||
operations, _ := rawOperations.(map[string]any)
|
||||
for method, rawOperation := range operations {
|
||||
method = strings.ToUpper(method)
|
||||
if !isHTTPMethod(method) {
|
||||
continue
|
||||
}
|
||||
operation, _ := rawOperation.(map[string]any)
|
||||
operationID, _ := operation["operationId"].(string)
|
||||
endpoints = append(endpoints, Endpoint{Method: method, Path: path, OperationID: operationID})
|
||||
}
|
||||
}
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
if endpoints[i].Path == endpoints[j].Path {
|
||||
return endpoints[i].Method < endpoints[j].Method
|
||||
}
|
||||
return endpoints[i].Path < endpoints[j].Path
|
||||
})
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("encode OpenAPI document: %w", err)
|
||||
}
|
||||
return Document{Version: version, JSON: canonical, Endpoints: endpoints}, nil
|
||||
}
|
||||
|
||||
func ValidateSourceURL(raw string, allowPrivate bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||
return nil, errors.New("source URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if allowPrivate {
|
||||
return parsed, nil
|
||||
}
|
||||
addresses, err := net.LookupIP(parsed.Hostname())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve source host: %w", err)
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if address.IsLoopback() || address.IsPrivate() || address.IsLinkLocalUnicast() || address.IsUnspecified() {
|
||||
return nil, errors.New("private source URLs are disabled")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeYAML(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeYAML(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = normalized
|
||||
}
|
||||
return result, nil
|
||||
case map[any]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
stringKey, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("OpenAPI object contains a non-string key")
|
||||
}
|
||||
normalized, err := normalizeYAML(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[stringKey] = normalized
|
||||
}
|
||||
return result, nil
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for i, item := range typed {
|
||||
normalized, err := normalizeYAML(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = normalized
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return typed, nil
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPMethod(method string) bool {
|
||||
return bytes.Contains([]byte(" GET PUT POST DELETE PATCH HEAD OPTIONS TRACE "), []byte(" "+method+" "))
|
||||
}
|
||||
26
internal/contract/contract_test.go
Normal file
26
internal/contract/contract_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package contract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYAML(t *testing.T) {
|
||||
document, err := Parse([]byte(`openapi: 3.1.0
|
||||
paths:
|
||||
/guesses:
|
||||
get:
|
||||
operationId: listGuesses
|
||||
post:
|
||||
operationId: createGuess
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if document.Version != "3.1.0" || len(document.Endpoints) != 2 {
|
||||
t.Fatalf("unexpected document: %#v", document)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSourceURLRejectsLoopback(t *testing.T) {
|
||||
if _, err := ValidateSourceURL("http://127.0.0.1/openapi.json", false); err == nil {
|
||||
t.Fatal("expected loopback URL to be rejected")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue