Skip to content
79 changes: 79 additions & 0 deletions examples/enterprise-contact/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// enterprise-contact submits an Enterprise interest form to instanode.dev
// when a team's requirements exceed the self-serve Pro tier.
//
// Usage:
//
// INSTANT_TOKEN=<your-token> go run . \
// -email cto@acme.com \
// -company "Acme Corp" \
// -use_case "We need 10 TB Postgres, dedicated infra, and SOC 2 Type II compliance."
//
// INSTANT_TOKEN is optional — anonymous callers are accepted. When set, the
// lead is automatically linked to the caller's team so the instanode.dev team
// can see current usage without a back-and-forth.
package main

import (
"context"
"flag"
"fmt"
"io"
"os"

"github.com/InstaNode-dev/sdk-go/instant"
)

// run is the testable core: creates the lead, writes output to out.
func run(ctx context.Context, c *instant.Client, params *instant.LeadParams, out io.Writer) error {
lead, err := c.CreateLead(ctx, params)
if err != nil {
return err
}
fmt.Fprintf(out, "Enterprise inquiry submitted.\nLead ID: %s\n", lead.ID)
fmt.Fprintf(out, "The instanode.dev team will follow up at %s\n", params.Email)
return nil
}

// realMain is the testable entry point. It accepts args, I/O writers, and an
// env-lookup func so tests can drive every branch without spawning a subprocess.
func realMain(args []string, stdout, stderr io.Writer, lookupEnv func(string) string) int {
fs := flag.NewFlagSet("enterprise-contact", flag.ContinueOnError)
fs.SetOutput(stderr)
email := fs.String("email", "", "Contact email address (required)")
name := fs.String("name", "", "Contact full name (optional)")
company := fs.String("company", "", "Company name (optional)")
useCase := fs.String("use_case", "", "Description of requirements (optional)")
if err := fs.Parse(args); err != nil {
return 1
}
if *email == "" {
fmt.Fprintln(stderr, "error: -email is required")
return 1
}
opts := []instant.Option{}
if tok := lookupEnv("INSTANT_TOKEN"); tok != "" {
opts = append(opts, instant.WithAPIKey(tok))
}
if u := lookupEnv("INSTANODE_API_URL"); u != "" {
opts = append(opts, instant.WithBaseURL(u))
}
c := instant.New(opts...)
if err := run(context.Background(), c, &instant.LeadParams{
Email: *email,
Name: *name,
Company: *company,
UseCase: *useCase,
}, stdout); err != nil {
fmt.Fprintf(stderr, "CreateLead: %v\n", err)
return 1
}
return 0
}

// osExit is a variable so tests can capture the exit code without terminating
// the test binary. Production callers use the default os.Exit.
var osExit = os.Exit

func main() {
osExit(realMain(os.Args[1:], os.Stdout, os.Stderr, os.Getenv))
}
182 changes: 182 additions & 0 deletions examples/enterprise-contact/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package main

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/InstaNode-dev/sdk-go/instant"
)

// testLookup returns a lookupEnv func that maps the given key/value pairs and
// falls back to "" for anything else.
func testLookup(kvs ...string) func(string) string {
m := make(map[string]string, len(kvs)/2)
for i := 0; i+1 < len(kvs); i += 2 {
m[kvs[i]] = kvs[i+1]
}
return func(key string) string { return m[key] }
}

func TestRun_HappyPath(t *testing.T) {
const wantID = "550e8400-e29b-41d4-a716-446655440099"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Company string `json:"company"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Email != "cto@acme.com" {
http.Error(w, "bad email", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"ok":true,"id":"`+wantID+`"}`)
}))
t.Cleanup(srv.Close)

c := instant.New(instant.WithBaseURL(srv.URL))
var out strings.Builder
err := run(context.Background(), c, &instant.LeadParams{
Email: "cto@acme.com",
Company: "Acme Corp",
UseCase: "Need SOC 2 + 10 TB Postgres.",
}, &out)
if err != nil {
t.Fatalf("run: %v", err)
}
s := out.String()
if !strings.Contains(s, wantID) {
t.Errorf("output missing lead ID %q:\n%s", wantID, s)
}
if !strings.Contains(s, "cto@acme.com") {
t.Errorf("output missing email:\n%s", s)
}
}

func TestRun_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":"internal","message":"db down"}`)
}))
t.Cleanup(srv.Close)

c := instant.New(instant.WithBaseURL(srv.URL))
var out strings.Builder
err := run(context.Background(), c, &instant.LeadParams{Email: "a@b.com"}, &out)
if err == nil {
t.Fatal("expected error from server failure")
}
}

func TestRealMain_HappyPath(t *testing.T) {
const wantID = "550e8400-e29b-41d4-a716-446655440077"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"ok":true,"id":"`+wantID+`"}`)
}))
t.Cleanup(srv.Close)

var stdout, stderr strings.Builder
code := realMain(
[]string{"-email", "cto@acme.com", "-company", "Acme Corp"},
&stdout,
&stderr,
testLookup("INSTANODE_API_URL", srv.URL),
)
if code != 0 {
t.Errorf("want exit 0, got %d; stderr: %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), wantID) {
t.Errorf("stdout missing lead ID; got: %q", stdout.String())
}
}

func TestRealMain_MissingEmail(t *testing.T) {
var stdout, stderr strings.Builder
code := realMain([]string{}, &stdout, &stderr, testLookup())
if code != 1 {
t.Errorf("want exit 1, got %d", code)
}
if !strings.Contains(stderr.String(), "error: -email is required") {
t.Errorf("stderr missing error message: %q", stderr.String())
}
}

func TestRealMain_BadFlag(t *testing.T) {
var stdout, stderr strings.Builder
code := realMain([]string{"-unknown-flag-xyz"}, &stdout, &stderr, testLookup())
if code != 1 {
t.Errorf("unknown flag should exit 1, got %d", code)
}
}

func TestRealMain_WithToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"ok":true,"id":"abc-token-test"}`)
}))
t.Cleanup(srv.Close)

var stdout, stderr strings.Builder
code := realMain(
[]string{"-email", "cto@acme.com"},
&stdout,
&stderr,
testLookup("INSTANT_TOKEN", "test-token-xyz", "INSTANODE_API_URL", srv.URL),
)
if code != 0 {
t.Errorf("want exit 0, got %d; stderr: %q", code, stderr.String())
}
if !strings.Contains(gotAuth, "test-token-xyz") {
t.Errorf("Authorization header missing token; got: %q", gotAuth)
}
}

func TestMain_ExitIsCalled(t *testing.T) {
// main() calls osExit(realMain(os.Args[1:], ...)) — we replace osExit to
// capture the code without terminating the test binary.
var gotCode int
osExit = func(code int) { gotCode = code }
defer func() { osExit = os.Exit }()

// os.Args[1:] in the test binary contains -test.* flags that FlagSet
// doesn't recognise → realMain returns 1 → main() passes 1 to osExit.
main()

if gotCode != 1 {
t.Errorf("main() via test args: want exit code 1, got %d", gotCode)
}
}

func TestRealMain_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":"internal_error","message":"db down"}`)
}))
t.Cleanup(srv.Close)

var stdout, stderr strings.Builder
code := realMain(
[]string{"-email", "fail@acme.com"},
&stdout,
&stderr,
testLookup("INSTANODE_API_URL", srv.URL),
)
if code != 1 {
t.Errorf("server error should exit 1, got %d", code)
}
if !strings.Contains(stderr.String(), "CreateLead") {
t.Errorf("stderr missing error prefix; got: %q", stderr.String())
}
}
98 changes: 98 additions & 0 deletions instant/leads.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package instant

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)

// LeadParams holds the fields for an enterprise contact / interest submission.
// Only Email is required; the other fields add context for the instanode.dev team.
type LeadParams struct {
// Email is the contact address. Required. Must be RFC 5322-compliant; max 254 chars.
Email string `json:"email"`

// Name is the contact's full name. Optional — max 128 chars.
Name string `json:"name,omitempty"`

// Company is the organisation name. Optional — max 128 chars.
Company string `json:"company,omitempty"`

// UseCase describes the scale requirements driving the Enterprise inquiry.
// Optional — max 1024 chars.
UseCase string `json:"use_case,omitempty"`
}

// LeadResult is returned by CreateLead on a successful 201 response.
type LeadResult struct {
// OK is always true on success.
OK bool `json:"ok"`

// ID is the UUID of the created enterprise_leads row.
ID string `json:"id"`
}

// CreateLead submits an enterprise contact / interest form to instanode.dev
// (POST /api/v1/leads). Only params.Email is required.
//
// No authentication is required — anonymous callers are accepted. When called
// with a bearer token (INSTANT_TOKEN / WithToken option), the lead is
// automatically linked to the caller's team so the instanode.dev team can see
// the account's current usage without asking for it.
//
// Use CreateLead when the user needs capacity or features beyond the Pro tier:
// dedicated infrastructure, SAML/SSO, SOC 2 compliance, a custom SLA, or any
// other requirement not met by a self-serve paid plan.
//
// Example:
//
// lead, err := client.CreateLead(ctx, &instant.LeadParams{
// Email: "cto@acme.com",
// Name: "Alice Smith",
// Company: "Acme Corp",
// UseCase: "Multi-region Postgres with SOC 2 Type II compliance.",
// })
// if err != nil { log.Fatal(err) }
// fmt.Println("lead ID:", lead.ID)
func (c *Client) CreateLead(ctx context.Context, params *LeadParams) (*LeadResult, error) {
if params == nil {
return nil, fmt.Errorf("CreateLead: params must not be nil")
}
if params.Email == "" {
return nil, fmt.Errorf("CreateLead: Email is required")
}

raw, _ := json.Marshal(params) // LeadParams contains only string fields; Marshal never fails here.

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/leads", bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("CreateLead: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Authorization is handled by the client's RoundTripper (authTransport),
// which injects the Bearer token from c.apiKey when present. No manual
// header injection needed — the http.Client already has it wired.

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("CreateLead: request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusCreated {
var apiErr APIError
if decErr := json.NewDecoder(resp.Body).Decode(&apiErr); decErr == nil && apiErr.Code != "" {
apiErr.StatusCode = resp.StatusCode
return nil, &apiErr
}
return nil, fmt.Errorf("CreateLead: unexpected status %d", resp.StatusCode)
}

var result LeadResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("CreateLead: decode response: %w", err)
}
return &result, nil
}
Loading
Loading