diff --git a/README.md b/README.md index 637c3a0..9363381 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,260 @@ -# React + TypeScript + Vite +# InsForge Go SDK -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Official Go SDK for [InsForge](https://insforge.app) – Backend as a Service. -Currently, two official plugins are available: +## Installation -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +```bash +go get github.com/InsForge/insforge-go +``` -## React Compiler +Requires Go 1.21+. -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +## Quick Start -## Expanding the ESLint configuration +```go +package main -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +import ( + "context" + "fmt" + "log" -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... + "github.com/InsForge/insforge-go/insforge" +) - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +func main() { + client := insforge.NewClient(insforge.Config{ + BaseURL: "https://your-app.region.insforge.app", + AnonKey: "your-anon-key", + }) - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); + ctx := context.Background() + + // Sign in + authResult := client.Auth.SignInWithPassword(ctx, "user@example.com", "password123") + if authResult.Error != nil { + log.Fatal(authResult.Error) + } + fmt.Println("Logged in:", authResult.Data.User.Email) + + // Query database + dbResult := client.Database.From("posts"). + Select("id, title, created_at"). + Limit(10). + Execute(ctx) + if dbResult.Error != nil { + log.Fatal(dbResult.Error) + } + fmt.Println("Posts:", dbResult.Data) + + // Upload file + data := []byte("hello world") + storageResult := client.Storage.From("files"). + Upload(ctx, "hello.txt", data, "text/plain") + if storageResult.Error != nil { + log.Fatal(storageResult.Error) + } + + // AI chat + aiResult := client.AI.Chat.Completions.Create(ctx, insforge.ChatCompletionRequest{ + Model: "openai/gpt-4o", + Messages: []insforge.ChatMessage{ + {Role: "user", Content: "Hello!"}, + }, + }) + if aiResult.Error != nil { + log.Fatal(aiResult.Error) + } + fmt.Println(aiResult.Data.Choices[0].Message.Content) +} +``` + +## Modules + +### `client.Auth` + +| Method | Description | +|--------|-------------| +| `SignUp(ctx, email, password, opts...)` | Register a new user | +| `SignInWithPassword(ctx, email, password)` | Login with email/password | +| `SignOut(ctx)` | Logout | +| `SignInWithOAuth(ctx, provider, redirectTo)` | OAuth sign-in | +| `ExchangeOAuthCode(ctx, code, codeVerifier)` | Exchange OAuth code | +| `GetCurrentUser(ctx)` | Get the logged-in user | +| `RefreshSession(ctx, refreshToken)` | Refresh the session | +| `GetProfile(ctx, userID)` | Get user profile | +| `SetProfile(ctx, profile)` | Update current user's profile | +| `ResendVerificationEmail(ctx, email)` | Resend verification email | +| `VerifyEmail(ctx, email, otp)` | Verify email with OTP | +| `SendResetPasswordEmail(ctx, email)` | Send password reset email | +| `ResetPassword(ctx, newPassword, otp)` | Reset password | +| `GetPublicAuthConfig(ctx)` | Get public auth configuration | + +### `client.Database` + +```go +// SELECT +result := client.Database.From("users"). + Select("id, name"). + Eq("active", true). + Order("created_at", false). // false = DESC + Limit(20). + Execute(ctx) + +// INSERT +result := client.Database.From("posts"). + Insert(ctx, []map[string]interface{}{ + {"title": "Hello", "body": "World"}, + }) + +// UPDATE +result := client.Database.From("posts"). + Eq("id", 5). + Update(ctx, map[string]interface{}{"title": "Updated"}) + +// DELETE +result := client.Database.From("posts"). + Eq("id", 5). + Delete(ctx) + +// RPC +result := client.Database.RPC(ctx, "get_stats", map[string]interface{}{"user_id": "abc"}) + +// Raw SQL (admin) +result := client.Database.Query(ctx, "SELECT * FROM users WHERE email = $1", "user@example.com") +``` + +### `client.Storage` + +```go +bucket := client.Storage.From("my-bucket") + +// Upload bytes +result := bucket.Upload(ctx, "path/file.png", fileBytes, "image/png") + +// Upload from io.Reader +result := bucket.UploadReader(ctx, "path/file.png", file, "image/png") + +// Download +result := bucket.Download(ctx, "path/file.png") +fileBytes := result.Data + +// List +result := bucket.List(ctx, &insforge.ListOptions{Prefix: "path/", Limit: 50}) + +// Delete +result := bucket.Remove(ctx, "path/file.png") + +// Get public URL +url := bucket.GetPublicURL("path/file.png") ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x'; -import reactDom from 'eslint-plugin-react-dom'; - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... +### `client.AI` + +```go +// Non-streaming completion +result := client.AI.Chat.Completions.Create(ctx, insforge.ChatCompletionRequest{ + Model: "openai/gpt-4o", + Messages: []insforge.ChatMessage{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "What is 2+2?"}, }, - }, -]); +}) +fmt.Println(result.Data.Choices[0].Message.Content) + +// Streaming +temp := 0.7 +chunks, errs := client.AI.Chat.Completions.CreateStream(ctx, insforge.ChatCompletionRequest{ + Model: "openai/gpt-4o", + Messages: []insforge.ChatMessage{{Role: "user", Content: "Tell me a story"}}, + Temperature: &temp, +}) +for chunk := range chunks { + if len(chunk.Choices) > 0 { + fmt.Print(chunk.Choices[0].Delta.Content) + } +} +if err := <-errs; err != nil { + log.Fatal(err) +} + +// Embeddings +result := client.AI.Embeddings.Create(ctx, insforge.EmbeddingsRequest{ + Model: "openai/text-embedding-3-small", + Input: "Hello world", +}) + +// Image generation +n := 1 +result := client.AI.Images.Generate(ctx, insforge.ImageGenerationRequest{ + Model: "openai/dall-e-3", + Prompt: "A sunset over the ocean", + Size: "1024x1024", + N: &n, +}) +``` + +### `client.Realtime` + +```go +// Connect +err := client.Realtime.Connect(ctx) + +// Subscribe +result, err := client.Realtime.Subscribe(ctx, "chat:room-1") + +// Listen for events +client.Realtime.On("chat:room-1", func(data interface{}) { + fmt.Println("Received:", data) +}) + +// Publish +err = client.Realtime.Publish(ctx, "chat:room-1", "message", map[string]interface{}{ + "text": "Hello!", +}) + +// Unsubscribe +err = client.Realtime.Unsubscribe("chat:room-1") + +// Disconnect +err = client.Realtime.Disconnect() ``` -# insforge-go + +### `client.Functions` + +```go +result := client.Functions.Invoke(ctx, "my-function", &insforge.InvokeOptions{ + Body: map[string]interface{}{"key": "value"}, + Method: "POST", +}) +``` + +### `client.Emails` + +```go +result := client.Emails.Send(ctx, insforge.SendEmailRequest{ + To: "recipient@example.com", + Subject: "Hello", + HTML: "

Hello from InsForge!

", +}) +``` + +## Error Handling + +All SDK methods return a `Result[T]` value containing `Data T` and `Error *InsForgeError`: + +```go +result := client.Auth.SignUp(ctx, "a@b.com", "pass") +if result.Error != nil { + fmt.Println(result.Error.Message) // Human-readable message + fmt.Println(result.Error.StatusCode) // HTTP status code + fmt.Println(result.Error.Code) // Error code string + return +} +fmt.Println(result.Data) +``` + +## License + +Apache-2.0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2429997 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/InsForge/insforge-go + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b537de1 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= diff --git a/insforge/ai.go b/insforge/ai.go new file mode 100644 index 0000000..247e50d --- /dev/null +++ b/insforge/ai.go @@ -0,0 +1,218 @@ +package insforge + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// AI provides AI-related operations: chat, embeddings, images. +type AI struct { + Chat *ChatClient + Embeddings *EmbeddingsClient + Images *ImagesClient + http *httpClient +} + +func newAI(h *httpClient) *AI { + cc := &ChatCompletionsClient{http: h} + return &AI{ + Chat: &ChatClient{Completions: cc}, + Embeddings: &EmbeddingsClient{http: h}, + Images: &ImagesClient{http: h}, + http: h, + } +} + +// ListModels returns the list of available AI models. +func (a *AI) ListModels(ctx context.Context) Result[interface{}] { + raw, err := a.http.get(ctx, "/api/ai/models", nil, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// ------------------------------------------------------------------ // +// Chat +// ------------------------------------------------------------------ // + +// ChatClient exposes chat completions. +type ChatClient struct { + Completions *ChatCompletionsClient +} + +// ChatCompletionsClient creates chat completions. +type ChatCompletionsClient struct { + http *httpClient +} + +// Create sends a chat completion request. Returns *ChatCompletionResponse for +// non-streaming requests. For streaming, use CreateStream. +func (c *ChatCompletionsClient) Create(ctx context.Context, req ChatCompletionRequest) Result[*ChatCompletionResponse] { + req.Stream = false + raw, err := c.http.post(ctx, "/api/ai/chat/completion", req, nil) + if err != nil { + return fail[*ChatCompletionResponse](err) + } + resp, decErr := decode[ChatCompletionResponse](raw) + if decErr != nil { + return fail[*ChatCompletionResponse](decErr) + } + return ok(&resp) +} + +// StreamChunk is a single server-sent event chunk during streaming. +type StreamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Delta struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` +} + +// CreateStream sends a streaming chat completion request. +// The returned channel emits chunks until the stream ends (or an error occurs). +// The error channel receives at most one error, then is closed. +func (c *ChatCompletionsClient) CreateStream(ctx context.Context, req ChatCompletionRequest) (<-chan StreamChunk, <-chan error) { + chunks := make(chan StreamChunk) + errs := make(chan error, 1) + + req.Stream = true + + go func() { + defer close(chunks) + defer close(errs) + + b, err := json.Marshal(req) + if err != nil { + errs <- err + return + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.http.baseURL+"/api/ai/chat/completion", + bytes.NewReader(b), + ) + if err != nil { + errs <- err + return + } + httpReq.Header.Set("Content-Type", "application/json") + if token := c.http.authToken(); token != "" { + httpReq.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range c.http.headers { + httpReq.Header.Set(k, v) + } + + resp, err := c.http.client.Do(httpReq) + if err != nil { + errs <- err + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + var body map[string]interface{} + json.Unmarshal(raw, &body) + if body != nil { + errs <- errorFromBody(body, resp.StatusCode) + } else { + errs <- fmt.Errorf("stream error: %d", resp.StatusCode) + } + return + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + return + } + var chunk StreamChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + continue + } + select { + case chunks <- chunk: + case <-ctx.Done(): + return + } + } + if err := scanner.Err(); err != nil { + errs <- err + } + }() + + return chunks, errs +} + +// ------------------------------------------------------------------ // +// Embeddings +// ------------------------------------------------------------------ // + +// EmbeddingsClient creates text embeddings. +type EmbeddingsClient struct { + http *httpClient +} + +// EmbeddingsRequest is the payload for an embeddings request. +type EmbeddingsRequest struct { + Model string `json:"model"` + Input interface{} `json:"input"` // string or []string + Dimensions *int `json:"dimensions,omitempty"` + EncodingFormat string `json:"encoding_format,omitempty"` +} + +// Create generates embeddings for the given input. +func (e *EmbeddingsClient) Create(ctx context.Context, req EmbeddingsRequest) Result[interface{}] { + raw, err := e.http.post(ctx, "/api/ai/embeddings", req, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// ------------------------------------------------------------------ // +// Images +// ------------------------------------------------------------------ // + +// ImagesClient generates images. +type ImagesClient struct { + http *httpClient +} + +// ImageGenerationRequest is the payload for an image generation request. +type ImageGenerationRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N *int `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Images interface{} `json:"images,omitempty"` +} + +// Generate creates images from a text prompt. +func (i *ImagesClient) Generate(ctx context.Context, req ImageGenerationRequest) Result[interface{}] { + raw, err := i.http.post(ctx, "/api/ai/image/generation", req, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} diff --git a/insforge/auth.go b/insforge/auth.go new file mode 100644 index 0000000..4e61dd2 --- /dev/null +++ b/insforge/auth.go @@ -0,0 +1,225 @@ +package insforge + +import ( + "context" + "encoding/json" +) + +// Auth provides authentication and user management operations. +type Auth struct { + http *httpClient +} + +func newAuth(h *httpClient) *Auth { return &Auth{http: h} } + +func decode[T any](raw interface{}) (T, error) { + var out T + b, err := json.Marshal(raw) + if err != nil { + return out, err + } + if err := json.Unmarshal(b, &out); err != nil { + return out, err + } + return out, nil +} + +// SignUp creates a new user account. +func (a *Auth) SignUp(ctx context.Context, email, password string, opts ...map[string]interface{}) Result[map[string]interface{}] { + body := map[string]interface{}{"email": email, "password": password} + if len(opts) > 0 { + for k, v := range opts[0] { + body[k] = v + } + } + raw, err := a.http.post(ctx, "/api/auth/users", body, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// SignInWithPassword authenticates with email and password. +// On success the access token is stored automatically. +func (a *Auth) SignInWithPassword(ctx context.Context, email, password string) Result[*Session] { + raw, err := a.http.post(ctx, "/api/auth/sessions", map[string]interface{}{ + "email": email, "password": password, + }, nil) + if err != nil { + return fail[*Session](err) + } + session, decErr := decode[Session](raw) + if decErr != nil { + return fail[*Session](decErr) + } + if session.AccessToken != "" { + a.http.setAccessToken(session.AccessToken) + } + return ok(&session) +} + +// SignOut logs out the current user and clears the stored token. +func (a *Auth) SignOut(ctx context.Context) Result[map[string]interface{}] { + raw, err := a.http.post(ctx, "/api/auth/logout", nil, nil) + a.http.setAccessToken("") + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// SignInWithOAuth initiates an OAuth sign-in. Returns a URL to redirect the user. +func (a *Auth) SignInWithOAuth(ctx context.Context, provider string, redirectTo string) Result[map[string]interface{}] { + body := map[string]interface{}{"provider": provider} + if redirectTo != "" { + body["redirectTo"] = redirectTo + } + raw, err := a.http.post(ctx, "/api/auth/oauth/"+provider, body, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// ExchangeOAuthCode exchanges an OAuth authorization code for an access token. +func (a *Auth) ExchangeOAuthCode(ctx context.Context, code, codeVerifier string) Result[*Session] { + body := map[string]interface{}{"code": code} + if codeVerifier != "" { + body["codeVerifier"] = codeVerifier + } + raw, err := a.http.post(ctx, "/api/auth/oauth/exchange", body, nil) + if err != nil { + return fail[*Session](err) + } + session, decErr := decode[Session](raw) + if decErr != nil { + return fail[*Session](decErr) + } + if session.AccessToken != "" { + a.http.setAccessToken(session.AccessToken) + } + return ok(&session) +} + +// GetCurrentUser refreshes the session and returns the current user. +func (a *Auth) GetCurrentUser(ctx context.Context) Result[*User] { + raw, err := a.http.post(ctx, "/api/auth/refresh", nil, nil) + if err != nil { + return fail[*User](err) + } + session, decErr := decode[Session](raw) + if decErr != nil { + return fail[*User](decErr) + } + if session.AccessToken != "" { + a.http.setAccessToken(session.AccessToken) + } + return ok(session.User) +} + +// RefreshSession refreshes the current session, optionally with an explicit refresh token. +func (a *Auth) RefreshSession(ctx context.Context, refreshToken string) Result[*Session] { + var body interface{} + if refreshToken != "" { + body = map[string]interface{}{"refreshToken": refreshToken} + } + raw, err := a.http.post(ctx, "/api/auth/refresh", body, nil) + if err != nil { + return fail[*Session](err) + } + session, decErr := decode[Session](raw) + if decErr != nil { + return fail[*Session](decErr) + } + if session.AccessToken != "" { + a.http.setAccessToken(session.AccessToken) + } + return ok(&session) +} + +// GetProfile returns the profile for the given user ID. +func (a *Auth) GetProfile(ctx context.Context, userID string) Result[map[string]interface{}] { + raw, err := a.http.get(ctx, "/api/auth/profiles/"+userID, nil, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// SetProfile updates the current user's profile. +func (a *Auth) SetProfile(ctx context.Context, profile map[string]interface{}) Result[map[string]interface{}] { + raw, err := a.http.patch(ctx, "/api/auth/profiles/current", profile, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// ResendVerificationEmail resends the email verification message. +func (a *Auth) ResendVerificationEmail(ctx context.Context, email string) Result[map[string]interface{}] { + raw, err := a.http.post(ctx, "/api/auth/email/send-verification", map[string]interface{}{"email": email}, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// VerifyEmail verifies an email address using an OTP code. +func (a *Auth) VerifyEmail(ctx context.Context, email, otp string) Result[map[string]interface{}] { + raw, err := a.http.post(ctx, "/api/auth/email/verify", map[string]interface{}{"email": email, "otp": otp}, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// SendResetPasswordEmail sends a password reset email. +func (a *Auth) SendResetPasswordEmail(ctx context.Context, email string) Result[map[string]interface{}] { + raw, err := a.http.post(ctx, "/api/auth/email/send-reset-password", map[string]interface{}{"email": email}, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// ResetPassword resets the password using an OTP received via email. +func (a *Auth) ResetPassword(ctx context.Context, newPassword, otp string) Result[map[string]interface{}] { + raw, err := a.http.post(ctx, "/api/auth/email/reset-password", map[string]interface{}{ + "newPassword": newPassword, "otp": otp, + }, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// ExchangeResetPasswordToken exchanges a password reset link token for a short-lived OTP. +func (a *Auth) ExchangeResetPasswordToken(ctx context.Context, token string) Result[map[string]interface{}] { + raw, err := a.http.post(ctx, "/api/auth/email/exchange-reset-password-token", map[string]interface{}{ + "token": token, + }, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} + +// GetPublicAuthConfig returns the public authentication configuration. +func (a *Auth) GetPublicAuthConfig(ctx context.Context) Result[map[string]interface{}] { + raw, err := a.http.get(ctx, "/api/auth/public-config", nil, nil) + if err != nil { + return fail[map[string]interface{}](err) + } + m, _ := raw.(map[string]interface{}) + return ok(m) +} diff --git a/insforge/auth_test.go b/insforge/auth_test.go new file mode 100644 index 0000000..ffff0b0 --- /dev/null +++ b/insforge/auth_test.go @@ -0,0 +1,95 @@ +package insforge_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/InsForge/insforge-go/insforge" +) + +func newTestClient(t *testing.T, mux *http.ServeMux) *insforge.Client { + t.Helper() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return insforge.NewClient(insforge.Config{BaseURL: srv.URL, AnonKey: "test-key"}) +} + +func writeJSON(w http.ResponseWriter, code int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func TestSignUp_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/auth/users", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + writeJSON(w, 201, map[string]interface{}{"id": "user-1", "email": "a@b.com"}) + }) + + client := newTestClient(t, mux) + result := client.Auth.SignUp(context.Background(), "a@b.com", "secret") + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.Data["id"] != "user-1" { + t.Fatalf("expected user-1, got %v", result.Data["id"]) + } +} + +func TestSignUp_Error(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/auth/users", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 400, map[string]interface{}{ + "error": map[string]interface{}{"message": "Email taken", "code": "EMAIL_EXISTS"}, + }) + }) + + client := newTestClient(t, mux) + result := client.Auth.SignUp(context.Background(), "a@b.com", "secret") + if result.Error == nil { + t.Fatal("expected error, got nil") + } + if result.Error.Message != "Email taken" { + t.Fatalf("expected 'Email taken', got %q", result.Error.Message) + } +} + +func TestSignIn_StoresToken(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/auth/sessions", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]interface{}{ + "accessToken": "jwt-abc", + "refreshToken": "refresh-xyz", + "user": map[string]interface{}{"id": "u1", "email": "a@b.com"}, + }) + }) + + client := newTestClient(t, mux) + result := client.Auth.SignInWithPassword(context.Background(), "a@b.com", "secret") + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.Data.AccessToken != "jwt-abc" { + t.Fatalf("expected jwt-abc, got %s", result.Data.AccessToken) + } +} + +func TestSignOut_ClearsToken(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/auth/logout", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]interface{}{"message": "ok"}) + }) + + client := newTestClient(t, mux) + client.SetAccessToken("jwt-abc") + result := client.Auth.SignOut(context.Background()) + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } +} diff --git a/insforge/client.go b/insforge/client.go new file mode 100644 index 0000000..f6fd69c --- /dev/null +++ b/insforge/client.go @@ -0,0 +1,78 @@ +// Package insforge is the official Go SDK for InsForge – Backend as a Service. +// +// Usage: +// +// client := insforge.NewClient(insforge.Config{ +// BaseURL: "https://your-app.region.insforge.app", +// AnonKey: "your-anon-key", +// }) +// +// ctx := context.Background() +// +// // Auth +// result := client.Auth.SignInWithPassword(ctx, "user@example.com", "password") +// if result.Error != nil { +// log.Fatal(result.Error) +// } +// +// // Database +// result2 := client.Database.From("posts").Limit(10).Execute(ctx) +// +// // Storage +// result3 := client.Storage.From("avatars").Upload(ctx, "photo.png", data, "image/png") +package insforge + +// Config holds the configuration for the InsForge client. +type Config struct { + // BaseURL is your InsForge backend URL, e.g. "https://your-app.region.insforge.app". + // Defaults to "http://localhost:7130". + BaseURL string + + // AnonKey is your project's anonymous API key (from backend metadata). + AnonKey string + + // Headers are optional extra headers sent with every request. + Headers map[string]string +} + +// Client is the main InsForge SDK client. +type Client struct { + Auth *Auth + Database *Database + Storage *Storage + AI *AI + Functions *Functions + Realtime *Realtime + Emails *Emails + + http *httpClient +} + +// NewClient creates and returns a new InsForge client. +func NewClient(cfg Config) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = "http://localhost:7130" + } + h := newHTTPClient(cfg.BaseURL, cfg.AnonKey, cfg.Headers) + return &Client{ + Auth: newAuth(h), + Database: newDatabase(h), + Storage: newStorage(h), + AI: newAI(h), + Functions: newFunctions(h), + Realtime: newRealtime(h), + Emails: newEmails(h), + http: h, + } +} + +// SetAccessToken sets the access token for authenticated requests. +// This is called automatically after sign-in, but can be set manually. +func (c *Client) SetAccessToken(token string) { + c.http.setAccessToken(token) +} + +// GetHTTPClient returns the underlying HTTP client for advanced use. +func (c *Client) GetHTTPClient() *httpClient { + return c.http +} diff --git a/insforge/database.go b/insforge/database.go new file mode 100644 index 0000000..f30c9da --- /dev/null +++ b/insforge/database.go @@ -0,0 +1,244 @@ +package insforge + +import ( + "context" + "fmt" + "net/url" + "strconv" +) + +// Database provides PostgREST-style query building for database operations. +type Database struct { + http *httpClient +} + +func newDatabase(h *httpClient) *Database { return &Database{http: h} } + +// From returns a QueryBuilder for the given table. +func (d *Database) From(table string) *QueryBuilder { + return &QueryBuilder{http: d.http, table: table, selectCols: "*"} +} + +// RPC calls a PostgreSQL function by name. +func (d *Database) RPC(ctx context.Context, fn string, args map[string]interface{}) Result[interface{}] { + if args == nil { + args = map[string]interface{}{} + } + raw, err := d.http.post(ctx, "/api/database/rpc", map[string]interface{}{"function": fn, "args": args}, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// Query executes a raw parameterized SQL query (admin only). +func (d *Database) Query(ctx context.Context, sql string, params ...interface{}) Result[interface{}] { + body := map[string]interface{}{"sql": sql} + if len(params) > 0 { + body["params"] = params + } + raw, err := d.http.post(ctx, "/api/database/advance/query", body, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// ------------------------------------------------------------------ // +// QueryBuilder +// ------------------------------------------------------------------ // + +// QueryBuilder builds and executes a query against a table. +type QueryBuilder struct { + http *httpClient + table string + selectCols string + filters []string + orderBy string + limitVal *int + offsetVal *int + countMode bool +} + +// Select specifies which columns to retrieve. +func (q *QueryBuilder) Select(cols string) *QueryBuilder { + q.selectCols = cols + return q +} + +// Count enables returning the total count of matching rows. +func (q *QueryBuilder) Count() *QueryBuilder { + q.countMode = true + return q +} + +// Eq adds an equality filter: column = value. +func (q *QueryBuilder) Eq(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=eq.%v", col, val)) + return q +} + +// Neq adds a not-equal filter. +func (q *QueryBuilder) Neq(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=neq.%v", col, val)) + return q +} + +// Gt adds a greater-than filter. +func (q *QueryBuilder) Gt(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=gt.%v", col, val)) + return q +} + +// Gte adds a greater-than-or-equal filter. +func (q *QueryBuilder) Gte(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=gte.%v", col, val)) + return q +} + +// Lt adds a less-than filter. +func (q *QueryBuilder) Lt(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=lt.%v", col, val)) + return q +} + +// Lte adds a less-than-or-equal filter. +func (q *QueryBuilder) Lte(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=lte.%v", col, val)) + return q +} + +// Like adds a LIKE filter. +func (q *QueryBuilder) Like(col, pattern string) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=like.%s", col, pattern)) + return q +} + +// ILike adds a case-insensitive LIKE filter. +func (q *QueryBuilder) ILike(col, pattern string) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=ilike.%s", col, pattern)) + return q +} + +// Is adds an IS filter (e.g. IS NULL, IS TRUE). +func (q *QueryBuilder) Is(col string, val interface{}) *QueryBuilder { + q.filters = append(q.filters, fmt.Sprintf("%s=is.%v", col, val)) + return q +} + +// In adds an IN filter. +func (q *QueryBuilder) In(col string, values ...interface{}) *QueryBuilder { + s := "" + for i, v := range values { + if i > 0 { + s += "," + } + s += fmt.Sprintf("%v", v) + } + q.filters = append(q.filters, fmt.Sprintf("%s=in.(%s)", col, s)) + return q +} + +// Order sets the ordering column and direction. +func (q *QueryBuilder) Order(col string, ascending bool) *QueryBuilder { + dir := "asc" + if !ascending { + dir = "desc" + } + q.orderBy = col + "." + dir + return q +} + +// Limit sets the maximum number of rows to return. +func (q *QueryBuilder) Limit(n int) *QueryBuilder { + q.limitVal = &n + return q +} + +// Offset sets the row offset for pagination. +func (q *QueryBuilder) Offset(n int) *QueryBuilder { + q.offsetVal = &n + return q +} + +func (q *QueryBuilder) buildFilterParams() url.Values { + p := url.Values{} + for _, f := range q.filters { + idx := 0 + for idx < len(f) && f[idx] != '=' { + idx++ + } + if idx < len(f) { + p.Set(f[:idx], f[idx+1:]) + } + } + return p +} + +func (q *QueryBuilder) buildParams() url.Values { + p := url.Values{} + p.Set("select", q.selectCols) + for _, f := range q.filters { + idx := 0 + for idx < len(f) && f[idx] != '=' { + idx++ + } + if idx < len(f) { + p.Set(f[:idx], f[idx+1:]) + } + } + if q.orderBy != "" { + p.Set("order", q.orderBy) + } + if q.limitVal != nil { + p.Set("limit", strconv.Itoa(*q.limitVal)) + } + if q.offsetVal != nil { + p.Set("offset", strconv.Itoa(*q.offsetVal)) + } + if q.countMode { + p.Set("count", "exact") + } + return p +} + +// Execute runs the SELECT query. +func (q *QueryBuilder) Execute(ctx context.Context) Result[interface{}] { + raw, err := q.http.get(ctx, "/api/database/records/"+q.table, q.buildParams(), nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// Insert inserts one or more records. +func (q *QueryBuilder) Insert(ctx context.Context, records interface{}) Result[interface{}] { + raw, err := q.http.post(ctx, "/api/database/records/"+q.table, records, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// Update updates records matching current filters with the given values. +func (q *QueryBuilder) Update(ctx context.Context, values map[string]interface{}) Result[interface{}] { + path := "/api/database/records/" + q.table + params := q.buildFilterParams() + if len(params) > 0 { + path += "?" + params.Encode() + } + raw, err := q.http.patch(ctx, path, values, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// Delete deletes records matching current filters. +func (q *QueryBuilder) Delete(ctx context.Context) Result[interface{}] { + raw, err := q.http.delete(ctx, "/api/database/records/"+q.table, q.buildParams(), nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} diff --git a/insforge/database_test.go b/insforge/database_test.go new file mode 100644 index 0000000..f5d866b --- /dev/null +++ b/insforge/database_test.go @@ -0,0 +1,52 @@ +package insforge_test + +import ( + "context" + "net/http" + "testing" +) + +func TestDatabase_SelectAll(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/database/records/users", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + writeJSON(w, 200, []map[string]interface{}{{"id": 1, "name": "Alice"}}) + }) + + client := newTestClient(t, mux) + result := client.Database.From("users").Select("*").Execute(context.Background()) + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } +} + +func TestDatabase_Insert(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/database/records/posts", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + writeJSON(w, 201, []map[string]interface{}{{"id": 1, "title": "Hello"}}) + }) + + client := newTestClient(t, mux) + result := client.Database.From("posts").Insert(context.Background(), []map[string]interface{}{{"title": "Hello"}}) + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } +} + +func TestDatabase_RPC(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/database/rpc", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]interface{}{"result": 42}) + }) + + client := newTestClient(t, mux) + result := client.Database.RPC(context.Background(), "my_func", map[string]interface{}{"x": 1}) + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } +} diff --git a/insforge/email.go b/insforge/email.go new file mode 100644 index 0000000..c595057 --- /dev/null +++ b/insforge/email.go @@ -0,0 +1,19 @@ +package insforge + +import "context" + +// Emails sends transactional emails. +type Emails struct { + http *httpClient +} + +func newEmails(h *httpClient) *Emails { return &Emails{http: h} } + +// Send sends a transactional email. +func (e *Emails) Send(ctx context.Context, req SendEmailRequest) Result[interface{}] { + raw, err := e.http.post(ctx, "/api/email/send-raw", req, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} diff --git a/insforge/errors.go b/insforge/errors.go new file mode 100644 index 0000000..7bbaa56 --- /dev/null +++ b/insforge/errors.go @@ -0,0 +1,37 @@ +package insforge + +import "fmt" + +// InsForgeError is returned by all SDK operations on failure. +type InsForgeError struct { + Message string `json:"message"` + StatusCode int `json:"statusCode"` + Code string `json:"error"` + NextActions string `json:"nextActions,omitempty"` +} + +func (e *InsForgeError) Error() string { + if e.Code != "" { + return fmt.Sprintf("InsForgeError[%d %s]: %s", e.StatusCode, e.Code, e.Message) + } + return fmt.Sprintf("InsForgeError[%d]: %s", e.StatusCode, e.Message) +} + +func newError(message, code string, statusCode int) *InsForgeError { + return &InsForgeError{Message: message, StatusCode: statusCode, Code: code} +} + +func errorFromBody(body map[string]interface{}, statusCode int) *InsForgeError { + errField, _ := body["error"] + switch v := errField.(type) { + case map[string]interface{}: + msg, _ := v["message"].(string) + code, _ := v["code"].(string) + details, _ := v["details"].(string) + return &InsForgeError{Message: msg, StatusCode: statusCode, Code: code, NextActions: details} + case string: + return &InsForgeError{Message: v, StatusCode: statusCode} + default: + return &InsForgeError{Message: fmt.Sprintf("%v", body), StatusCode: statusCode} + } +} diff --git a/insforge/functions.go b/insforge/functions.go new file mode 100644 index 0000000..0b60ace --- /dev/null +++ b/insforge/functions.go @@ -0,0 +1,58 @@ +package insforge + +import "context" + +// Functions invokes deployed serverless edge functions. +type Functions struct { + http *httpClient +} + +func newFunctions(h *httpClient) *Functions { return &Functions{http: h} } + +// InvokeOptions configures a function invocation. +type InvokeOptions struct { + Body interface{} + Headers map[string]string + Method string // default: POST +} + +// Invoke calls a deployed edge function by its slug. +func (f *Functions) Invoke(ctx context.Context, slug string, opts *InvokeOptions) Result[interface{}] { + method := "POST" + var body interface{} + var headers map[string]string + + if opts != nil { + if opts.Method != "" { + method = opts.Method + } + body = opts.Body + headers = opts.Headers + } + + path := "/functions/" + slug + + var ( + raw interface{} + err error + ) + switch method { + case "GET": + raw, err = f.http.get(ctx, path, nil, headers) + case "POST": + raw, err = f.http.post(ctx, path, body, headers) + case "PUT": + raw, err = f.http.put(ctx, path, body, headers) + case "PATCH": + raw, err = f.http.patch(ctx, path, body, headers) + case "DELETE": + raw, err = f.http.delete(ctx, path, nil, headers) + default: + raw, err = f.http.post(ctx, path, body, headers) + } + + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} diff --git a/insforge/http_client.go b/insforge/http_client.go new file mode 100644 index 0000000..3e7b77f --- /dev/null +++ b/insforge/http_client.go @@ -0,0 +1,194 @@ +package insforge + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// httpClient wraps net/http.Client with InsForge-specific behaviour. +type httpClient struct { + baseURL string + anonKey string + accessToken string + mu sync.RWMutex + headers map[string]string + client *http.Client +} + +func newHTTPClient(baseURL, anonKey string, headers map[string]string) *httpClient { + return &httpClient{ + baseURL: strings.TrimRight(baseURL, "/"), + anonKey: anonKey, + headers: headers, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *httpClient) setAccessToken(token string) { + c.mu.Lock() + defer c.mu.Unlock() + c.accessToken = token +} + +func (c *httpClient) authToken() string { + c.mu.RLock() + defer c.mu.RUnlock() + if c.accessToken != "" { + return c.accessToken + } + return c.anonKey +} + +func (c *httpClient) buildRequest(ctx context.Context, method, path string, body interface{}, extraHeaders map[string]string) (*http.Request, error) { + fullURL := c.baseURL + path + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + if token := c.authToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + return req, nil +} + +func (c *httpClient) do(req *http.Request) (interface{}, error) { + resp, err := c.client.Do(req) + if err != nil { + return nil, &InsForgeError{Message: err.Error(), StatusCode: 0} + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &InsForgeError{Message: "failed to read response body", StatusCode: resp.StatusCode} + } + + if resp.StatusCode == 204 || len(raw) == 0 { + return nil, nil + } + + var parsed interface{} + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, &InsForgeError{Message: string(raw), StatusCode: resp.StatusCode} + } + + if resp.StatusCode >= 400 { + if m, ok := parsed.(map[string]interface{}); ok { + return nil, errorFromBody(m, resp.StatusCode) + } + return nil, &InsForgeError{Message: string(raw), StatusCode: resp.StatusCode} + } + return parsed, nil +} + +func (c *httpClient) get(ctx context.Context, path string, params url.Values, extraHeaders map[string]string) (interface{}, error) { + fullPath := path + if len(params) > 0 { + fullPath += "?" + params.Encode() + } + req, err := c.buildRequest(ctx, http.MethodGet, fullPath, nil, extraHeaders) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *httpClient) post(ctx context.Context, path string, body interface{}, extraHeaders map[string]string) (interface{}, error) { + req, err := c.buildRequest(ctx, http.MethodPost, path, body, extraHeaders) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *httpClient) put(ctx context.Context, path string, body interface{}, extraHeaders map[string]string) (interface{}, error) { + req, err := c.buildRequest(ctx, http.MethodPut, path, body, extraHeaders) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *httpClient) patch(ctx context.Context, path string, body interface{}, extraHeaders map[string]string) (interface{}, error) { + req, err := c.buildRequest(ctx, http.MethodPatch, path, body, extraHeaders) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *httpClient) delete(ctx context.Context, path string, params url.Values, extraHeaders map[string]string) (interface{}, error) { + fullPath := path + if len(params) > 0 { + fullPath += "?" + params.Encode() + } + req, err := c.buildRequest(ctx, http.MethodDelete, fullPath, nil, extraHeaders) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *httpClient) uploadRaw(ctx context.Context, path string, data []byte, contentType string) (interface{}, error) { + fullURL := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodPut, fullURL, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + if token := c.authToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + return c.do(req) +} + +func (c *httpClient) downloadBytes(ctx context.Context, path string) ([]byte, error) { + fullURL := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + if token := c.authToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, &InsForgeError{Message: err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, &InsForgeError{StatusCode: resp.StatusCode, Message: "download failed"} + } + return io.ReadAll(resp.Body) +} diff --git a/insforge/realtime.go b/insforge/realtime.go new file mode 100644 index 0000000..2872c6a --- /dev/null +++ b/insforge/realtime.go @@ -0,0 +1,332 @@ +package insforge + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + eioOpen = "0" + eioMessage = "4" + sioConnect = "0" + sioEvent = "2" + sioAck = "3" +) + +// EventCallback is the function signature for realtime event handlers. +type EventCallback func(data interface{}) + +// Realtime provides real-time pub/sub via Socket.IO/WebSocket. +type Realtime struct { + http *httpClient + conn *websocket.Conn + mu sync.RWMutex + writeMu sync.Mutex // protects WebSocket writes + listeners map[string][]EventCallback + subscribed map[string]struct{} + connected bool + sid string + ackID int + pendingAcks map[int]chan interface{} +} + +func newRealtime(h *httpClient) *Realtime { + return &Realtime{ + http: h, + listeners: make(map[string][]EventCallback), + subscribed: make(map[string]struct{}), + pendingAcks: make(map[int]chan interface{}), + } +} + +// IsConnected reports whether the realtime connection is active. +func (r *Realtime) IsConnected() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.connected +} + +// ConnectionState returns the current connection state: "connected", "connecting", or "disconnected". +func (r *Realtime) ConnectionState() string { + r.mu.RLock() + defer r.mu.RUnlock() + if r.connected { + return "connected" + } + if r.conn != nil { + return "connecting" + } + return "disconnected" +} + +// SocketID returns the Socket.IO session ID if connected. +func (r *Realtime) SocketID() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.sid +} + +// GetSubscribedChannels returns the list of currently subscribed channels. +func (r *Realtime) GetSubscribedChannels() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.subscribed)) + for ch := range r.subscribed { + out = append(out, ch) + } + return out +} + +// Connect opens a WebSocket connection to the realtime server. +func (r *Realtime) Connect(ctx context.Context) error { + base := r.http.baseURL + wsBase := strings.Replace(strings.Replace(base, "https://", "wss://", 1), "http://", "ws://", 1) + rawURL := fmt.Sprintf("%s/socket.io/?EIO=4&transport=websocket", wsBase) + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + header := http.Header{} + conn, _, err := dialer.DialContext(ctx, rawURL, header) + if err != nil { + return &InsForgeError{Message: "websocket connect failed: " + err.Error()} + } + r.conn = conn + + // Read EIO OPEN packet + _, _, err = conn.ReadMessage() + if err != nil { + return &InsForgeError{Message: "failed to read EIO open: " + err.Error()} + } + + // Send SIO CONNECT with auth payload (backend reads socket.handshake.auth.token) + token := r.http.authToken() + connectMsg := eioMessage + sioConnect + if token != "" { + authPayload, _ := json.Marshal(map[string]string{"token": token}) + connectMsg += string(authPayload) + } + r.writeMu.Lock() + writeErr := conn.WriteMessage(websocket.TextMessage, []byte(connectMsg)) + r.writeMu.Unlock() + if writeErr != nil { + return &InsForgeError{Message: "failed to send SIO connect: " + writeErr.Error()} + } + + r.mu.Lock() + r.connected = true + r.mu.Unlock() + + go r.recvLoop() + return nil +} + +// Disconnect closes the WebSocket connection. +func (r *Realtime) Disconnect() error { + r.mu.Lock() + r.connected = false + r.mu.Unlock() + if r.conn != nil { + return r.conn.Close() + } + return nil +} + +// Subscribe subscribes to a channel. +func (r *Realtime) Subscribe(ctx context.Context, channel string) (map[string]interface{}, error) { + if !r.IsConnected() { + if err := r.Connect(ctx); err != nil { + return nil, err + } + } + + r.mu.Lock() + r.ackID++ + id := r.ackID + ch := make(chan interface{}, 1) + r.pendingAcks[id] = ch + r.mu.Unlock() + + payload, _ := json.Marshal([]interface{}{"realtime:subscribe", map[string]interface{}{"channel": channel}}) + msg := fmt.Sprintf("%s%s%d%s", eioMessage, sioEvent, id, string(payload)) + r.writeMu.Lock() + err := r.conn.WriteMessage(websocket.TextMessage, []byte(msg)) + r.writeMu.Unlock() + if err != nil { + return nil, &InsForgeError{Message: "subscribe write failed: " + err.Error()} + } + + select { + case result := <-ch: + m, _ := result.(map[string]interface{}) + if ok, _ := m["ok"].(bool); ok { + r.mu.Lock() + r.subscribed[channel] = struct{}{} + r.mu.Unlock() + } + return m, nil + case <-time.After(10 * time.Second): + return map[string]interface{}{"ok": false, "channel": channel}, &InsForgeError{Message: "subscribe timeout"} + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Unsubscribe unsubscribes from a channel. +func (r *Realtime) Unsubscribe(channel string) error { + payload, _ := json.Marshal([]interface{}{"realtime:unsubscribe", map[string]interface{}{"channel": channel}}) + msg := fmt.Sprintf("%s%s%s", eioMessage, sioEvent, string(payload)) + r.writeMu.Lock() + err := r.conn.WriteMessage(websocket.TextMessage, []byte(msg)) + r.writeMu.Unlock() + if err != nil { + return err + } + r.mu.Lock() + delete(r.subscribed, channel) + r.mu.Unlock() + return nil +} + +// Publish publishes an event to a channel. +func (r *Realtime) Publish(ctx context.Context, channel, event string, payload interface{}) error { + if !r.IsConnected() { + if err := r.Connect(ctx); err != nil { + return err + } + } + data, _ := json.Marshal([]interface{}{"realtime:publish", map[string]interface{}{ + "channel": channel, "event": event, "payload": payload, + }}) + r.writeMu.Lock() + err := r.conn.WriteMessage(websocket.TextMessage, []byte(eioMessage+sioEvent+string(data))) + r.writeMu.Unlock() + return err +} + +// On registers a handler for the given event. +func (r *Realtime) On(event string, cb EventCallback) { + r.mu.Lock() + defer r.mu.Unlock() + r.listeners[event] = append(r.listeners[event], cb) +} + +// Off removes all handlers for the given event. +func (r *Realtime) Off(event string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.listeners, event) +} + +// Once registers a one-time handler that is removed after it fires once. +func (r *Realtime) Once(event string, cb EventCallback) { + var wrapper EventCallback + wrapper = func(data interface{}) { + cb(data) + r.Off(event) + } + r.On(event, wrapper) +} + +func (r *Realtime) emit(event string, data interface{}) { + r.mu.RLock() + cbs := append([]EventCallback{}, r.listeners[event]...) + r.mu.RUnlock() + for _, cb := range cbs { + cb(data) + } +} + +func (r *Realtime) recvLoop() { + for { + _, raw, err := r.conn.ReadMessage() + if err != nil { + r.mu.Lock() + r.connected = false + r.mu.Unlock() + r.emit("disconnect", err.Error()) + return + } + r.handleMessage(string(raw)) + } +} + +func (r *Realtime) handleMessage(raw string) { + if len(raw) < 2 { + return + } + if string(raw[0]) != eioMessage { + return + } + sio := raw[1:] + if len(sio) == 0 { + return + } + sioType := string(sio[0]) + rest := sio[1:] + + switch sioType { + case sioConnect: + var info map[string]interface{} + if err := json.Unmarshal([]byte(rest), &info); err == nil { + if sid, ok := info["sid"].(string); ok { + r.mu.Lock() + r.sid = sid + r.mu.Unlock() + } + } + r.emit("connect", nil) + + case sioEvent: + ackID, dataStr := parseAckAndData(rest) + var arr []interface{} + if err := json.Unmarshal([]byte(dataStr), &arr); err != nil || len(arr) == 0 { + return + } + _ = ackID + eventName, _ := arr[0].(string) + var eventData interface{} + if len(arr) > 1 { + eventData = arr[1] + } + r.emit(eventName, eventData) + + case sioAck: + ackID, dataStr := parseAckAndData(rest) + var arr []interface{} + json.Unmarshal([]byte(dataStr), &arr) + var result interface{} + if len(arr) > 0 { + result = arr[0] + } + r.mu.Lock() + ch, ok := r.pendingAcks[ackID] + if ok { + delete(r.pendingAcks, ackID) + } + r.mu.Unlock() + if ok && ch != nil { + ch <- result + } + } +} + +func parseAckAndData(s string) (int, string) { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == 0 { + return -1, s + } + id := 0 + for j := 0; j < i; j++ { + id = id*10 + int(s[j]-'0') + } + return id, s[i:] +} diff --git a/insforge/storage.go b/insforge/storage.go new file mode 100644 index 0000000..19c2f6a --- /dev/null +++ b/insforge/storage.go @@ -0,0 +1,233 @@ +package insforge + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Storage provides file storage operations. +type Storage struct { + http *httpClient +} + +func newStorage(h *httpClient) *Storage { return &Storage{http: h} } + +// From returns a StorageBucket for the given bucket name. +func (s *Storage) From(bucketName string) *StorageBucket { + return &StorageBucket{http: s.http, bucket: bucketName} +} + +// StorageBucket wraps operations for a specific bucket. +type StorageBucket struct { + http *httpClient + bucket string +} + +func (b *StorageBucket) base() string { + return "/api/storage/buckets/" + b.bucket +} + +// GetPublicURL returns the public URL for an object in the bucket. +func (b *StorageBucket) GetPublicURL(path string) string { + clean := strings.TrimLeft(path, "/") + return fmt.Sprintf("%s%s/objects/%s", b.http.baseURL, b.base(), clean) +} + +// Upload uploads raw bytes to the given path in the bucket. +func (b *StorageBucket) Upload(ctx context.Context, path string, data []byte, contentType string) Result[interface{}] { + if contentType == "" { + contentType = "application/octet-stream" + } + clean := strings.TrimLeft(path, "/") + + // Request upload strategy + stratRaw, err := b.http.post(ctx, b.base()+"/upload-strategy", map[string]interface{}{ + "filename": clean, "contentType": contentType, "size": len(data), + }, nil) + if err != nil { + return fail[interface{}](err) + } + + strategy, _ := stratRaw.(map[string]interface{}) + method, _ := strategy["method"].(string) + uploadURL, _ := strategy["uploadUrl"].(string) + key, _ := strategy["key"].(string) + if key == "" { + key = clean + } + + if method == "presigned" && uploadURL != "" { + // S3 presigned POST: multipart/form-data with strategy fields + file content + var body bytes.Buffer + mw := multipart.NewWriter(&body) + // Write all signed fields first (policy, signature, key, etc.) + if fields, ok := strategy["fields"].(map[string]interface{}); ok { + for k, v := range fields { + _ = mw.WriteField(k, fmt.Sprintf("%v", v)) + } + } + // File content must be last + fw, err := mw.CreateFormFile("file", key) + if err != nil { + return fail[interface{}](err) + } + if _, err = fw.Write(data); err != nil { + return fail[interface{}](err) + } + mw.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &body) + if err != nil { + return fail[interface{}](err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + resp, err := b.http.client.Do(req) + if err != nil { + return fail[interface{}](err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fail[interface{}](&InsForgeError{StatusCode: resp.StatusCode, Message: "presigned upload failed"}) + } + // Confirm if required + confirmReq, _ := strategy["confirmRequired"].(bool) + if confirmReq { + confirmURL, _ := strategy["confirmUrl"].(string) + // confirmUrl may be a relative path or full URL; strip base URL if present + confirmPath := strings.Replace(confirmURL, b.http.baseURL, "", 1) + raw, err := b.http.post(ctx, confirmPath, map[string]interface{}{"size": len(data), "contentType": contentType}, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) + } + return ok[interface{}](map[string]interface{}{"key": key}) + } + + // Direct upload: use the uploadUrl from strategy (has the URL-encoded key) + directPath := strings.Replace(uploadURL, b.http.baseURL, "", 1) + if directPath == "" { + directPath = b.base() + "/objects/" + key + } + raw, err := b.http.uploadRaw(ctx, directPath, data, contentType) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// UploadReader uploads from an io.Reader. +func (b *StorageBucket) UploadReader(ctx context.Context, path string, r io.Reader, contentType string) Result[interface{}] { + data, err := io.ReadAll(r) + if err != nil { + return fail[interface{}](&InsForgeError{Message: "failed to read upload data: " + err.Error()}) + } + return b.Upload(ctx, path, data, contentType) +} + +// Download downloads an object and returns its raw bytes. +func (b *StorageBucket) Download(ctx context.Context, path string) Result[[]byte] { + clean := strings.TrimLeft(path, "/") + + // Get download strategy + stratRaw, err := b.http.post(ctx, b.base()+"/objects/"+clean+"/download-strategy", + map[string]interface{}{"expiresIn": 3600}, nil) + if err != nil { + // Fallback: direct download + data, derr := b.http.downloadBytes(ctx, b.base()+"/objects/"+clean) + if derr != nil { + return fail[[]byte](err) + } + return ok(data) + } + + strategy, _ := stratRaw.(map[string]interface{}) + method, _ := strategy["method"].(string) + downloadURL, _ := strategy["url"].(string) + + if downloadURL == "" { + data, err := b.http.downloadBytes(ctx, b.base()+"/objects/"+clean) + if err != nil { + return fail[[]byte](err) + } + return ok(data) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return fail[[]byte](err) + } + // For direct downloads, include auth headers + if method == "direct" { + if token := b.http.authToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range b.http.headers { + req.Header.Set(k, v) + } + } + resp, err := b.http.client.Do(req) + if err != nil { + return fail[[]byte](err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fail[[]byte](&InsForgeError{StatusCode: resp.StatusCode, Message: "download failed"}) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return fail[[]byte](err) + } + return ok(data) +} + +// List lists objects in the bucket. +func (b *StorageBucket) List(ctx context.Context, opts *ListOptions) Result[interface{}] { + params := url.Values{} + params.Set("limit", "100") + params.Set("offset", "0") + if opts != nil { + if opts.Prefix != "" { + params.Set("prefix", opts.Prefix) + } + if opts.Search != "" { + params.Set("search", opts.Search) + } + if opts.Limit > 0 { + params.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Offset > 0 { + params.Set("offset", strconv.Itoa(opts.Offset)) + } + } + raw, err := b.http.get(ctx, b.base()+"/objects", params, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// Remove deletes an object from the bucket. +func (b *StorageBucket) Remove(ctx context.Context, path string) Result[interface{}] { + clean := strings.TrimLeft(path, "/") + raw, err := b.http.delete(ctx, b.base()+"/objects/"+clean, nil, nil) + if err != nil { + return fail[interface{}](err) + } + return ok[interface{}](raw) +} + +// ListOptions controls list pagination and filtering. +type ListOptions struct { + Prefix string + Search string + Limit int + Offset int +} diff --git a/insforge/storage_test.go b/insforge/storage_test.go new file mode 100644 index 0000000..b2ed42c --- /dev/null +++ b/insforge/storage_test.go @@ -0,0 +1,44 @@ +package insforge_test + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestStorage_GetPublicURL(t *testing.T) { + mux := http.NewServeMux() + client := newTestClient(t, mux) + url := client.Storage.From("avatars").GetPublicURL("user.png") + if !strings.Contains(url, "/api/storage/buckets/avatars/objects/user.png") { + t.Fatalf("unexpected URL: %s", url) + } +} + +func TestStorage_List(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/storage/buckets/avatars/objects", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]interface{}{"objects": []map[string]interface{}{{"key": "a.png"}}}) + }) + client := newTestClient(t, mux) + result := client.Storage.From("avatars").List(context.Background(), nil) + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } +} + +func TestStorage_Remove(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/storage/buckets/avatars/objects/a.png", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("expected DELETE, got %s", r.Method) + } + writeJSON(w, 200, map[string]interface{}{"message": "deleted"}) + }) + client := newTestClient(t, mux) + result := client.Storage.From("avatars").Remove(context.Background(), "a.png") + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } +} diff --git a/insforge/types.go b/insforge/types.go new file mode 100644 index 0000000..008fbe3 --- /dev/null +++ b/insforge/types.go @@ -0,0 +1,137 @@ +package insforge + +// Result is the standard response wrapper returned by most SDK methods. +type Result[T any] struct { + Data T + Error *InsForgeError +} + +func ok[T any](data T) Result[T] { + return Result[T]{Data: data} +} + +func fail[T any](err error) Result[T] { + var zero T + if e, ok := err.(*InsForgeError); ok { + return Result[T]{Data: zero, Error: e} + } + return Result[T]{Data: zero, Error: &InsForgeError{Message: err.Error()}} +} + +// --- Auth types --- + +// User represents an InsForge user account. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + EmailVerified bool `json:"emailVerified"` + Providers []string `json:"providers"` + Profile map[string]interface{} `json:"profile"` + Metadata map[string]interface{} `json:"metadata"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Session holds authentication tokens. +type Session struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt string `json:"expiresAt"` + User *User `json:"user"` +} + +// --- Database types --- + +// ColumnType enumerates supported column types. +type ColumnType string + +const ( + ColumnTypeString ColumnType = "string" + ColumnTypeDate ColumnType = "date" + ColumnTypeDatetime ColumnType = "datetime" + ColumnTypeInteger ColumnType = "integer" + ColumnTypeFloat ColumnType = "float" + ColumnTypeBoolean ColumnType = "boolean" + ColumnTypeUUID ColumnType = "uuid" + ColumnTypeJSON ColumnType = "json" +) + +// Column describes a table column schema. +type Column struct { + ColumnName string `json:"columnName"` + Type ColumnType `json:"type"` + IsPrimaryKey bool `json:"isPrimaryKey"` + IsNullable bool `json:"isNullable"` + IsUnique bool `json:"isUnique"` + DefaultValue string `json:"defaultValue,omitempty"` +} + +// --- Storage types --- + +// StorageFile describes an object stored in a bucket. +type StorageFile struct { + Key string `json:"key"` + BucketName string `json:"bucketName"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + UploadedAt string `json:"uploadedAt"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// --- AI types --- + +// ChatMessage represents a single message in a conversation. +type ChatMessage struct { + Role string `json:"role"` + Content interface{} `json:"content"` + ToolCalls interface{} `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +// ChatCompletionRequest is the payload for a chat completion call. +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + TopP *float64 `json:"topP,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools interface{} `json:"tools,omitempty"` + ToolChoice interface{} `json:"toolChoice,omitempty"` + WebSearch interface{} `json:"webSearch,omitempty"` + FileParser interface{} `json:"fileParser,omitempty"` + Thinking bool `json:"thinking,omitempty"` +} + +// ChatCompletionResponse is the response from a (non-streaming) chat completion. +type ChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Message ChatMessage `json:"message"` + Delta ChatMessage `json:"delta"` + Finish string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +// --- Email types --- + +// SendEmailRequest is the payload for sending an email. +type SendEmailRequest struct { + To interface{} `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html"` + CC interface{} `json:"cc,omitempty"` + BCC interface{} `json:"bcc,omitempty"` + From string `json:"from,omitempty"` + ReplyTo string `json:"replyTo,omitempty"` + Text string `json:"text,omitempty"` +}