Skip to content
Open
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
6 changes: 3 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ before:
- go mod download

builds:
- id: algorithmvisualizer
binary: algorithmvisualizer
main: ./cmd/algorithmvisualizer
- id: algvis
binary: algvis
main: ./cmd/algvis
env:
- CGO_ENABLED=0
flags:
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Build into bin/ (gitignored) so the binary never collides with the algorithmvisualizer/
# source package at the repo root.
BINARY := bin/algorithmvisualizer
PKG := ./cmd/algorithmvisualizer
BINARY := bin/algvis
PKG := ./cmd/algvis
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
Expand Down
211 changes: 176 additions & 35 deletions algorithmvisualizer/algorithmvisualizer.go
Original file line number Diff line number Diff line change
@@ -1,60 +1,94 @@
// Package algorithmvisualizer is the library behind the algorithmvisualizer command line:
// the HTTP client, request shaping, and the typed data models for algorithmvisualizer.
// Package algorithmvisualizer is the library behind the algvis command line:
// the HTTP client, request shaping, and the typed data models for Algorithm Visualizer.
//
// The Client here is the spine every command shares. It sets a real
// User-Agent, paces requests so a busy session stays polite, and retries the
// transient failures (429 and 5xx) that any public site throws under load.
// Build your endpoint calls and JSON decoding on top of it.
// transient failures (429 and 5xx) that any public API throws under load.
package algorithmvisualizer

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)

// DefaultUserAgent identifies the client to algorithmvisualizer. A real, honest
// User-Agent is both polite and the thing most likely to keep you unblocked.
const DefaultUserAgent = "algorithmvisualizer/dev (+https://github.com/tamnd/algorithmvisualizer-cli)"
const DefaultUserAgent = "algvis/dev (+https://github.com/tamnd/algorithmvisualizer-cli)"

// Client talks to algorithmvisualizer over HTTP.
type Client struct {
HTTP *http.Client
// Config holds constructor parameters.
type Config struct {
BaseURL string
UserAgent string
// Rate is the minimum gap between requests. Zero means no pacing.
Rate time.Duration
Retries int
Rate time.Duration
Retries int
Timeout time.Duration
}

// DefaultConfig returns sensible defaults.
func DefaultConfig() Config {
return Config{
BaseURL: "https://api.github.com",
UserAgent: DefaultUserAgent,
Rate: 500 * time.Millisecond,
Retries: 3,
Timeout: 30 * time.Second,
}
}

// Algorithm is a single algorithm entry.
type Algorithm struct {
Rank int `json:"rank"`
Category string `json:"category"`
Name string `json:"name"`
URL string `json:"url"`
}

// Category is a group of algorithms.
type Category struct {
Rank int `json:"rank"`
Name string `json:"name"`
Count int `json:"count"`
}

// treeResponse is the GitHub Git Trees API response shape.
type treeResponse struct {
Tree []struct {
Path string `json:"path"`
Type string `json:"type"`
} `json:"tree"`
}

last time.Time
// Client talks to the GitHub API for Algorithm Visualizer data.
type Client struct {
cfg Config
httpClient *http.Client
mu sync.Mutex
last time.Time
}

// NewClient returns a Client with sensible defaults: a 30s timeout, a 200ms
// minimum gap between requests, and five retries on transient errors.
func NewClient() *Client {
// NewClient returns a Client with the given config.
func NewClient(cfg Config) *Client {
return &Client{
HTTP: &http.Client{Timeout: 30 * time.Second},
UserAgent: DefaultUserAgent,
Rate: 200 * time.Millisecond,
Retries: 5,
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.Timeout},
}
}

// Get fetches url and returns the response body. It paces and retries according
// to the client's settings. The caller owns nothing extra; the body is read
// fully and closed here.
func (c *Client) Get(ctx context.Context, url string) ([]byte, error) {
func (c *Client) get(ctx context.Context, rawURL string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= c.Retries; attempt++ {
for attempt := 0; attempt <= c.cfg.Retries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff(attempt)):
}
}
body, retry, err := c.do(ctx, url)
body, retry, err := c.do(ctx, rawURL)
if err == nil {
return body, nil
}
Expand All @@ -63,18 +97,19 @@ func (c *Client) Get(ctx context.Context, url string) ([]byte, error) {
return nil, err
}
}
return nil, fmt.Errorf("get %s: %w", url, lastErr)
return nil, fmt.Errorf("get %s: %w", rawURL, lastErr)
}

func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, err error) {
func (c *Client) do(ctx context.Context, rawURL string) (body []byte, retry bool, err error) {
c.pace()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("User-Agent", c.cfg.UserAgent)
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := c.HTTP.Do(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, true, err
}
Expand All @@ -94,12 +129,13 @@ func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, e
return b, false, nil
}

// pace blocks until at least Rate has passed since the previous request.
func (c *Client) pace() {
if c.Rate <= 0 {
c.mu.Lock()
defer c.mu.Unlock()
if c.cfg.Rate <= 0 {
return
}
if wait := c.Rate - time.Since(c.last); wait > 0 {
if wait := c.cfg.Rate - time.Since(c.last); wait > 0 {
time.Sleep(wait)
}
c.last = time.Now()
Expand All @@ -112,3 +148,108 @@ func backoff(attempt int) time.Duration {
}
return d
}

// fetchTree fetches the algorithm tree from GitHub and returns all algorithms.
func (c *Client) fetchTree(ctx context.Context) ([]Algorithm, error) {
raw, err := c.get(ctx, c.cfg.BaseURL+"/repos/algorithm-visualizer/algorithms/git/trees/master?recursive=1")
if err != nil {
return nil, err
}

var resp treeResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, err
}

var out []Algorithm
rank := 0
for _, item := range resp.Tree {
if item.Type != "tree" {
continue
}
parts := strings.SplitN(item.Path, "/", 2)
if len(parts) != 2 {
continue
}
cat, name := parts[0], parts[1]
if strings.HasPrefix(cat, ".") {
continue
}
if strings.Contains(name, "/") {
continue
}
rank++
out = append(out, Algorithm{
Rank: rank,
Category: cat,
Name: name,
URL: "https://github.com/algorithm-visualizer/algorithms/tree/master/" + item.Path,
})
}
return out, nil
}

// List returns algorithms, optionally filtered by category, up to limit.
func (c *Client) List(ctx context.Context, category string, limit int) ([]Algorithm, error) {
all, err := c.fetchTree(ctx)
if err != nil {
return nil, err
}
var out []Algorithm
rank := 0
for _, a := range all {
if category != "" && !strings.EqualFold(a.Category, category) {
continue
}
rank++
a.Rank = rank
out = append(out, a)
if limit > 0 && len(out) >= limit {
break
}
}
return out, nil
}

// Search finds algorithms matching query in name or category.
func (c *Client) Search(ctx context.Context, query string, limit int) ([]Algorithm, error) {
all, err := c.fetchTree(ctx)
if err != nil {
return nil, err
}
q := strings.ToLower(query)
var out []Algorithm
rank := 0
for _, a := range all {
if strings.Contains(strings.ToLower(a.Name), q) || strings.Contains(strings.ToLower(a.Category), q) {
rank++
a.Rank = rank
out = append(out, a)
if limit > 0 && len(out) >= limit {
break
}
}
}
return out, nil
}

// Categories returns all categories with algorithm counts.
func (c *Client) Categories(ctx context.Context) ([]Category, error) {
all, err := c.fetchTree(ctx)
if err != nil {
return nil, err
}
counts := make(map[string]int)
order := []string{}
for _, a := range all {
if _, ok := counts[a.Category]; !ok {
order = append(order, a.Category)
}
counts[a.Category]++
}
var out []Category
for i, cat := range order {
out = append(out, Category{Rank: i + 1, Name: cat, Count: counts[cat]})
}
return out, nil
}
Loading
Loading