Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Releasing

Make sure your working tree is clean before starting (`make check-clean`).

1. **Run the appropriate release target** on a new branch:

```sh
git checkout -b bump/v1.2.2
make release-patch # 0.0.x
make release-minor # 0.x.0
make release-major # x.0.0
```

This bumps `version.go`, commits the change, and creates an annotated tag locally.

2. **Push the branch and open a PR** (main is branch-protected):

```sh
git push origin bump/v1.2.2
# open a PR and get it merged
```

3. **After the PR is merged, push the tag**:

```sh
git checkout main && git pull
git push origin v1.2.2
```

Tags are not subject to the branch protection rules, so this pushes directly.

4. **Trigger the Go module proxy** to index the new version immediately:

```sh
GOPROXY=proxy.golang.org go list -m github.com/linkvite/go@v1.2.2
```
29 changes: 15 additions & 14 deletions example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,26 @@ func main() {
}
baseURL := os.Getenv("LINKVITE_BASE_URL")

apiKey := os.Getenv("LINKVITE_API_KEY")
if apiKey == "" {
log.Fatal("LINKVITE_API_KEY not set in environment")
}

client, err := linkvite.NewClient(apiKey, linkvite.WithBaseURL(baseURL))
if err != nil {
log.Fatal(err)
}
// apiKey := os.Getenv("LINKVITE_API_KEY")
// if apiKey == "" {
// log.Fatal("LINKVITE_API_KEY not set in environment")
// }

// client, err := linkvite.NewClientWithTokens(
// os.Getenv("LINKVITE_ACCESS_TOKEN"),
// os.Getenv("LINKVITE_REFRESH_TOKEN"),
// linkvite.WithBaseURL(baseURL),
// )
// client, err := linkvite.NewClient(apiKey, linkvite.WithBaseURL(baseURL))
// if err != nil {
// log.Fatal(err)
// }

client, err := linkvite.NewClientWithCredentials(
context.Background(),
os.Getenv("LINKVITE_IDENTIFIER"),
os.Getenv("LINKVITE_PASSWORD"),
linkvite.WithBaseURL(baseURL),
)
if err != nil {
log.Fatal(err)
}

ctx := context.Background()

user, err := client.User.Get(ctx)
Expand Down
110 changes: 106 additions & 4 deletions linkvite.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@
// and share bookmarks. This SDK provides a convenient way to interact
// with the Linkvite API from Go applications.
//
// Basic usage:
// # Authentication
//
// Three authentication methods are supported:
//
// API key:
//
// client, err := linkvite.NewClient("link_your-api-key")
//
// if err != nil {
// log.Fatalf("failed to create client: %v", err)
// }
// Access token (e.g. from a previous session):
//
// client, err := linkvite.NewClientWithTokens(accessToken, refreshToken)
//
// Username or email + password:
//
// client, err := linkvite.NewClientWithCredentials(ctx, "user@example.com", "password")
//
// To obtain tokens without immediately creating a client, use Login:
//
// result, err := linkvite.Login(ctx, "user@example.com", "password")
// // result.AccessToken, result.RefreshToken, result.User
//
// # Basic usage
//
// // Get current user
// user, err := client.User.Get(ctx)
Expand Down Expand Up @@ -253,6 +268,93 @@ func NewClientWithTokens(accessToken, refreshToken string, opts ...Option) (*Cli
return NewClient("", opts...)
}

// Login authenticates with an identifier (username or email) and password,
// returning tokens and the authenticated user. Use the returned tokens with
// NewClientWithTokens to create an authenticated client.
func Login(ctx context.Context, identifier, password string, opts ...Option) (*LoginResult, error) {
if strings.TrimSpace(identifier) == "" {
return nil, fmt.Errorf("linkvite: identifier cannot be empty")
}
if strings.TrimSpace(password) == "" {
return nil, fmt.Errorf("linkvite: password cannot be empty")
}

cfg := &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: DefaultBaseURL,
userAgent: fmt.Sprintf("linkvite-go/%s", Version),
}
for _, opt := range opts {
if opt == nil {
continue
}
if err := opt(cfg); err != nil {
return nil, err
}
}

payload := struct {
Identifier string `json:"identifier"`
Password string `json:"password"`
}{Identifier: identifier, Password: password}

jsonBody, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("linkvite: marshal error: %w", err)
}

authURL := cfg.baseURL + "/v1/auth/login"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("linkvite: request error: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", cfg.userAgent)

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

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("linkvite: read error: %w", err)
}

var apiResp struct {
OK bool `json:"ok"`
Data *LoginResult `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Errors any `json:"errors,omitempty"`
}
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("linkvite: parse error: %w", err)
}

if !apiResp.OK {
return nil, &Error{
StatusCode: resp.StatusCode,
Message: apiResp.Error,
Errors: apiResp.Errors,
}
}

return apiResp.Data, nil
}

// NewClientWithCredentials creates a new client by logging in with an identifier
// (username or email) and password. Options such as WithBaseURL and WithHTTPClient
// are applied to both the login request and the resulting client.
func NewClientWithCredentials(ctx context.Context, identifier, password string, opts ...Option) (*Client, error) {
result, err := Login(ctx, identifier, password, opts...)
if err != nil {
return nil, err
}
return NewClientWithTokens(result.AccessToken, result.RefreshToken, opts...)
}

// RefreshAccessToken refreshes the access token using the refresh token
func (c *Client) RefreshAccessToken(ctx context.Context) error {
if c.refreshToken == "" {
Expand Down
119 changes: 118 additions & 1 deletion linkvite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"testing"
)

const key = "link_test-api-key"
const (
key = "link_test-api-key"
loginEndpoint = "/v1/auth/login"
)

// testClient creates a test client with a mock server.
func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
Expand Down Expand Up @@ -141,6 +144,120 @@ func TestRefreshAccessToken(t *testing.T) {
}
}

func TestLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != loginEndpoint {
t.Errorf("expected path %s, got %s", loginEndpoint, r.URL.Path)
}

var req struct {
Identifier string `json:"identifier"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request: %v", err)
}
if req.Identifier != "testuser" {
t.Errorf("expected identifier 'testuser', got '%s'", req.Identifier)
}
if req.Password != "secret" {
t.Errorf("expected password 'secret', got '%s'", req.Password)
}

_, _ = w.Write(jsonResponse(t, map[string]any{
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"user": map[string]any{
"id": "usr_123",
"name": "Test User",
"username": "testuser",
"email": "test@example.com",
},
}))
}))
defer server.Close()

result, err := Login(context.Background(), "testuser", "secret", WithBaseURL(server.URL))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.AccessToken != "new-access-token" {
t.Errorf("expected access token 'new-access-token', got '%s'", result.AccessToken)
}
if result.RefreshToken != "new-refresh-token" {
t.Errorf("expected refresh token 'new-refresh-token', got '%s'", result.RefreshToken)
}
if result.User.ID != "usr_123" {
t.Errorf("expected user ID 'usr_123', got '%s'", result.User.ID)
}
}

func TestLoginError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write(jsonError(t, "invalid credentials"))
}))
defer server.Close()

_, err := Login(context.Background(), "testuser", "wrongpassword", WithBaseURL(server.URL))
if err == nil {
t.Fatal("expected error, got nil")
}

var apiErr *Error
if !errors.As(err, &apiErr) {
t.Fatalf("expected *Error, got %T", err)
}
if apiErr.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", apiErr.StatusCode)
}
if apiErr.Message != "invalid credentials" {
t.Errorf("expected message 'invalid credentials', got '%s'", apiErr.Message)
}
}

func TestLoginValidation(t *testing.T) {
ctx := context.Background()

if _, err := Login(ctx, "", "password"); err == nil {
t.Error("expected error for empty identifier")
}
if _, err := Login(ctx, "user", ""); err == nil {
t.Error("expected error for empty password")
}
}

func TestNewClientWithCredentials(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != loginEndpoint {
t.Errorf("expected path %s, got %s", loginEndpoint, r.URL.Path)
}
_, _ = w.Write(jsonResponse(t, map[string]any{
"access_token": "cred-access-token",
"refresh_token": "cred-refresh-token",
"user": map[string]any{"id": "usr_456"},
}))
}))
defer server.Close()

client, err := NewClientWithCredentials(context.Background(), "user@example.com", "pass", WithBaseURL(server.URL))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client.accessToken != "cred-access-token" {
t.Errorf("expected accessToken 'cred-access-token', got '%s'", client.accessToken)
}
if client.refreshToken != "cred-refresh-token" {
t.Errorf("expected refreshToken 'cred-refresh-token', got '%s'", client.refreshToken)
}
if client.authType != AuthTypeAccessToken {
t.Errorf("expected authType AuthTypeAccessToken, got %s", client.authType.String())
}
}

func TestNewClientWithOptions(t *testing.T) {
customHTTP := &http.Client{}
client, err := NewClient(key,
Expand Down
7 changes: 7 additions & 0 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,13 @@ type ParsedLink struct {
SiteName string `json:"site_name"`
}

// LoginResult holds the tokens and user info returned after a successful login.
type LoginResult struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}

// Pagination represents pagination information.
type Pagination struct {
Query string `json:"q,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ package linkvite

// Version is the SDK version.
// It is updated whenever a new version of the SDK is released.
const Version = "1.2.1"
const Version = "1.2.2"
Loading