diff --git a/.github/workflows/staticcheck.yaml b/.github/workflows/staticcheck.yaml
index 4de5bb9..dab1ed0 100644
--- a/.github/workflows/staticcheck.yaml
+++ b/.github/workflows/staticcheck.yaml
@@ -18,7 +18,7 @@ jobs:
echo "Go files are not formatted. Run 'gofmt -s -w .' to fix."
exit 1
fi
- # Running tests
+ # tests
- name: Run tests
run: |
cd api
@@ -28,7 +28,7 @@ jobs:
cd ../storage/
go test -v ./... --count=1
cd ..
- # Spelling check
+ # Spelling
- name: Check spelling
run: |
curl -L -o ./install-misspell.sh https://git.io/misspell
@@ -36,3 +36,9 @@ jobs:
mv ./bin/misspell .
chmod +x ./misspell
./misspell -source=auto -error .
+ # Global api description is bundled
+ - name: check api bundle
+ run: |
+ npm install -g @redocly/cli
+ redocly bundle docs/api.yaml -o docs/bundled.generated.yaml
+ git diff --exit-code docs/bundled.generated.yaml docs/bundled.yaml
diff --git a/README.md b/README.md
index 78308a3..8682348 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,11 @@
A high-performance backend service that generates dynamic banners displaying GitHub user statistics. Perfect for enhancing your GitHub profile README with real-time stats.
+
+
+
+
+
## Overview
GitHub Banners fetches user data from the GitHub API, calculates aggregated statistics (repositories, stars, forks, languages), and renders beautiful SVG banners that automatically update.
@@ -48,8 +53,7 @@ GitHub Banners fetches user data from the GitHub API, calculates aggregated stat
---
## Architecture
-
-
+
### Github Stats Caching Strategy
@@ -62,11 +66,11 @@ GitHub Banners fetches user data from the GitHub API, calculates aggregated stat
### Database Schema
-| Table | Description |
-| -------------- | --------------------------------------- |
-| `banners` | Banner configurations and storage paths |
-| `users` | GitHub user profile data |
-| `repositories` | Repository data linked to users |
+| Table | Description |
+| -------------------------- | --------------------------------------- |
+| `banners` | Banner configurations and storage paths |
+| `github_data.users` | GitHub user profile data |
+| `github_data.repositories` | Repository data linked to users |
---
@@ -89,127 +93,83 @@ cd github-banners
**2. Configure environment variables**
-Root `.env`:
+> Use `.env.example` that lay in every folder
-```env
-API_INTERNAL_SERVER_PORT=80
-# Service authentication
-SERVICES_SECRET_KEY=your_secret_key
-```
-`api/.env`:
+- Root `.env`
+- `api/.env`
+- `renderer/.env`
+- `storage/.env`
-```env
-# CORS
-CORS_ORIGINS=example.com,www.example.com
+**3. Start services**
-# GitHub tokens (comma-separated for load balancing)
-GITHUB_TOKENS=ghp_token1,ghp_token2
+### Dev mode, 80 port without https
-# Rate limiting & Cache
-RATE_LIMIT_RPS=10
-CACHE_TTL=5m
-REQUEST_TIMEOUT=10s
+```bash
+docker compose up --build
+# Detached mode ( only build logs )
+docker compose up --build -d
+```
-# Logging
-LOG_LEVEL=DEBUG
-LOG_FORMAT=json
+#### Production mode, 443 port ( for cloudflare only )
-# PostgreSQL
-POSTGRES_USER=github_banners
-POSTGRES_PASSWORD=your_secure_password
-POSTGRES_DB=github_banners
-DB_HOST=api-psgr
-PGPORT=5432
-```
+Cloudflare configuration:
-`renderer/.env`:
+
-```env
-# DEBUG/INFO/WARN/ERROR
-LOG_LEVEL=INFO
-# text/json
-LOG_FORMAT=json
-# separated by comma list of broker instances
-KAFKA_BROKERS_ADDRS=kafka:9092
-```
-**3. Start services**
+Where to get cert and key:
+
+
-```bash
-docker-compose up --build
-# Detached mode ( only build logs )
-docker-compose up --build -d
-```
+
+Put certificate `cert.pem` and private key `key.pem` to `/etc/nginx/ssl/`
### Development
-```bash
-# Run locally
-cd api && go run main.go
+> For testing consider using "dev" version of docker compose
+```bash
# Run tests
./run_tests.sh
+
+# CI static check:
+# fix formatting
+gofmt -s -w .
+# tests check
+./run_tests.sh
+# spelling ( go install github.com/client9/misspell/cmd/misspell@latest )
+# it will automatically fix all issues
+misspell -source=auto -w .
+# global api bundle ( only if you touched api.yaml files )
+# It will combine description of global api in `/docs/api.yaml`
+# into `bundled.yaml`
+# for install https://redocly.com/docs/cli/installation
+redocly bundle docs/api.yaml -o docs/bundled.yaml
```
---
## Services
-| Service | Port | Description |
-| ---------- | ---------------- | ------------------------ |
-| `api` | 80 ( public ) | Main API service |
-| `api-psgr` | 5432 ( private ) | PostgreSQL database |
-| `renderer` | - | Banner rendering service |
-| `storage` | - | Banner storage service |
-| `kafka` | 9092 ( private ) | Apache Kafka broker |
+| Service | Port | Description |
+| ---------- | ----------------- | ---------------------------- |
+| `nginx` | 80/443 ( public ) | API gateway + static banners |
+| `api` | 80 | Main API service |
+| `api-psgr` | 5432 | PostgreSQL database |
+| `renderer` | 80 | Banner rendering service |
+| `storage` | 80 | Banner storage service |
+| `kafka` | 9092 | Apache Kafka broker |
---
## API Endpoints
-| Method | Endpoint | Description |
-| ------ | ------------------------------------- | -------------------------------------------------------------- |
-| `GET` | `[api-service]/banners/preview` | Get banner preview for a GitHub user ( not fully implemented ) |
-| `POST` | `[api-service]/banners` | Create a new banner ( not implemented ) |
-| `GET` | `[storage-service]/{banner-url-path}` | Get long term banner ( not implemented ) |
-
----
-
-## Project Structure
-
-```
-github-banners/
-├── api/ # Main API service
-│ ├── internal/
-│ │ ├── app/user_stats/ # Background stats updating worker
-│ │ ├── cache/ # In-memory cache
-│ │ ├── config/ # Configuration
-│ │ ├── domain/ # Business logic
-│ │ │ ├── preview/ # Banner preview use case
-│ │ │ └── user_stats/ # Statistics service
-│ │ ├── handlers/ # HTTP handlers
-│ │ ├── infrastructure/ # External integrations
-│ │ │ ├── db/ # Database connection
-│ │ │ ├── github/ # GitHub API client pool
-│ │ │ ├── kafka/ # Kafka producer
-│ │ │ ├── renderer/ # Renderer client
-│ │ │ └── server/ # HTTP server
-│ │ ├── migrations/ # SQL migrations
-│ │ └── repo/ # Storages
-│ │ ├── banners/ # long term banners storage
-│ │ ├── github_user_data/ # github data
-│ └── main.go
-├── renderer/ # Banner rendering service ( partially implemented )
-│ ├── internal/
-│ │ ├── infrastructure/ # External integrations
-│ │ │ ├── kafka/ # kafka consumer group logic
-│ │ ├── handlers/ # Evevnts handling and HTTP requests handling logic
-│ └── main.go
-├── storage/ # Banner storage service ( not implemented )
-├── docker-compose.yaml
-└── run_tests.sh
-```
+| Method | Endpoint | Description |
+| ------ | -------------------- | ---------------------------------------------------------------- |
+| `GET` | `/banners/preview` | Get banner preview for a GitHub user |
+| `POST` | `/banners` | Create a new lont-term banner |
+| `GET` | `/{banner-url-path}` | Get long term banner ( constantly updating since you created it) |
---
diff --git a/api/internal/domain/user_stats/service.go b/api/internal/domain/user_stats/service.go
index 11e93d1..35a3f76 100644
--- a/api/internal/domain/user_stats/service.go
+++ b/api/internal/domain/user_stats/service.go
@@ -34,7 +34,8 @@ func (s *UserStatsService) GetStats(ctx context.Context, username string) (domai
// state >10mins but <24 hours
go func() {
- timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ // usage of context.Background(), cause this operation is idependent of parent context
+ timeoutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, _ = s.RecalculateAndSync(timeoutCtx, username)
}()
diff --git a/docs/api.yaml b/docs/api.yaml
new file mode 100644
index 0000000..9731744
--- /dev/null
+++ b/docs/api.yaml
@@ -0,0 +1,39 @@
+openapi: 3.0.3
+info:
+ title: Github banners
+ version: 1.0.0
+paths:
+ /banners/preview:
+ $ref: '../api/docs/api.yaml#/paths/~1banners~1preview'
+ /banners:
+ $ref: '../api/docs/api.yaml#/paths/~1banners'
+ /banners/{filename}:
+ get:
+ summary: Get stored banner (SVG)
+ description: |
+ Returns a previously created banner from storage (served by nginx).
+
+ If banner is not found, default banner may be returned.
+ operationId: getStoredBanner
+ parameters:
+ - name: filename
+ in: path
+ required: true
+ description: Banner file name without extension
+ schema:
+ type: string
+ example: torvalds-dark
+ responses:
+ '200':
+ description: SVG banner
+ content:
+ image/svg+xml:
+ schema:
+ type: string
+ format: binary
+components:
+ schemas:
+ ErrorResponse:
+ $ref: '../api/docs/api.yaml#/components/schemas/ErrorResponse'
+ CreateBannerRequest:
+ $ref: '../api/docs/api.yaml#/components/schemas/CreateBannerRequest'
diff --git a/docs/bundled.yaml b/docs/bundled.yaml
new file mode 100644
index 0000000..d10a197
--- /dev/null
+++ b/docs/bundled.yaml
@@ -0,0 +1,179 @@
+openapi: 3.0.3
+info:
+ title: Github banners
+ version: 1.0.0
+paths:
+ /banners/preview:
+ get:
+ summary: Get banner preview for a GitHub user
+ description: |
+ Generates and returns an SVG banner with GitHub user statistics.
+
+ The banner includes:
+ - Total repositories count
+ - Original vs forked repositories breakdown
+ - Total stars received
+ - Total forks
+ - Top programming languages used
+ operationId: getBannerPreview
+ parameters:
+ - name: username
+ in: query
+ required: true
+ description: GitHub username
+ schema:
+ type: string
+ example: torvalds
+ - name: type
+ in: query
+ required: true
+ description: Banner type
+ schema:
+ type: string
+ enum:
+ - dark
+ - default
+ example: dark
+ responses:
+ '200':
+ description: Successfully generated banner
+ content:
+ image/svg+xml:
+ schema:
+ type: string
+ format: binary
+ example:
+ '400':
+ description: Invalid request parameters
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ invalid_banner_type:
+ value:
+ error: invalid banner type
+ invalid_inputs:
+ value:
+ error: invalid inputs
+ '404':
+ description: User not found on GitHub
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ user_doesnt_exist:
+ value:
+ error: user doesn't exist
+ '500':
+ description: Internal server error or service unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ cant_get_preview:
+ value:
+ error: can't get preview
+ /banners:
+ post:
+ summary: Create a persistent banner
+ description: |
+ Creates a long-term stored banner for a GitHub user.
+
+ - Generate and store banner in storage service
+ - Return a relative URL for embedding
+ - Support automatic refresh of stored banners
+ operationId: createBanner
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateBannerRequest'
+ responses:
+ '400':
+ description: Invalid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ invalid_json:
+ value:
+ error: invalid json
+ invalid_banner_type:
+ value:
+ error: invalid banner type
+ '404':
+ description: Requested user doesn't exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ user_doesnt_exist:
+ value:
+ error: user doesn't exist
+ '500':
+ description: Server can't create banner due to internal issues
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ cant_create_banner:
+ value:
+ error: can't create banner
+ /banners/{filename}:
+ get:
+ summary: Get stored banner (SVG)
+ description: |
+ Returns a previously created banner from storage (served by nginx).
+
+ If banner is not found, default banner may be returned.
+ operationId: getStoredBanner
+ parameters:
+ - name: filename
+ in: path
+ required: true
+ description: Banner file name without extension
+ schema:
+ type: string
+ example: torvalds-dark
+ responses:
+ '200':
+ description: SVG banner
+ content:
+ image/svg+xml:
+ schema:
+ type: string
+ format: binary
+components:
+ schemas:
+ ErrorResponse:
+ type: object
+ required:
+ - error
+ properties:
+ error:
+ type: string
+ description: Error message describing what went wrong
+ CreateBannerRequest:
+ type: object
+ required:
+ - username
+ - type
+ properties:
+ username:
+ type: string
+ description: GitHub username
+ example: torvalds
+ type:
+ type: string
+ enum:
+ - dark
+ - default
+ description: Type of banner to create
+ example: dark
diff --git a/renderer/docs/api.yaml b/renderer/docs/api.yaml
new file mode 100644
index 0000000..20b1ed7
--- /dev/null
+++ b/renderer/docs/api.yaml
@@ -0,0 +1,141 @@
+openapi: 3.0.3
+info:
+ version: 1.0.0
+ title: GitHub Banners Renderer
+ description: Renderer service for rendering banners using ready data
+paths:
+ /preview:
+ post:
+ summary: Render banner using ready stats
+ description: |
+ Gets statistics for banner render and returns ready SVG image.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BannerInfoV1'
+ example:
+ username: "hurtki"
+ banner_type: "dark"
+ fetched_at: "2024-01-15T12:00:00Z"
+ stats:
+ total_repos: 42
+ original_repos: 30
+ forked_repos: 12
+ total_stars: 150
+ total_forks: 20
+ languages:
+ Go: 18500
+ Python: 4200
+ TypeScript: 1100
+ responses:
+ '200':
+ description: Successfully rendered banner
+ content:
+ image/svg+xml:
+ schema:
+ type: string
+ format: binary
+ example: ''
+ '400':
+ description: Invalid request — bad JSON body, unknown banner type, or invalid username
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ invalid_body:
+ summary: Malformed JSON
+ value:
+ error: "Invalid request body format"
+ invalid_username:
+ summary: Invalid username
+ value:
+ error: "invalid username"
+ invalid_banner_type:
+ summary: Unknown banner type
+ value:
+ error: "invalid banner type"
+ '500':
+ description: Internal server error during rendering
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ example:
+ error: "Internal server error"
+components:
+ schemas:
+ BannerInfoV1:
+ type: object
+ required:
+ - username
+ - banner_type
+ - stats
+ - fetched_at
+ properties:
+ username:
+ type: string
+ description: GitHub username
+ example: "hurtki"
+ banner_type:
+ type: string
+ enum: ['dark', 'default']
+ description: Visual theme of the banner
+ example: "dark"
+ stats:
+ $ref: '#/components/schemas/StatsV1'
+ fetched_at:
+ type: string
+ format: date-time
+ description: Timestamp when the stats were fetched from GitHub
+ example: "2024-01-15T12:00:00Z"
+ StatsV1:
+ type: object
+ required:
+ - total_repos
+ - original_repos
+ - forked_repos
+ - total_stars
+ - total_forks
+ - languages
+ properties:
+ total_repos:
+ type: integer
+ description: Total number of repositories
+ example: 42
+ original_repos:
+ type: integer
+ description: Number of repositories created by the user (non-forks)
+ example: 30
+ forked_repos:
+ type: integer
+ description: Number of forked repositories
+ example: 12
+ total_stars:
+ type: integer
+ description: Total stars received across all repositories
+ example: 150
+ total_forks:
+ type: integer
+ description: Total forks received across all repositories
+ example: 20
+ languages:
+ type: object
+ description: Map of programming language name to total bytes of code
+ additionalProperties:
+ type: integer
+ example:
+ Go: 18500
+ Python: 4200
+ TypeScript: 1100
+ ErrorResponse:
+ type: object
+ required:
+ - error
+ properties:
+ error:
+ type: string
+ description: Error message describing what went wrong
+ example: "Invalid request body format"
diff --git a/storage/docs/api.yaml b/storage/docs/api.yaml
new file mode 100644
index 0000000..90a727d
--- /dev/null
+++ b/storage/docs/api.yaml
@@ -0,0 +1,78 @@
+openapi: 3.0.3
+info:
+ version: 1.0.0
+ title: GitHub Banners storage
+ description: Renderer service for saving banner's data
+paths:
+ /banners:
+ post:
+ summary: Upload file into storage
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SaveRequestV1'
+ examples:
+ save_banner:
+ summary: Example banner upload
+ value:
+ url_path: "hurtki-dark"
+ banner_info: "PHN2ZyBoZWlnaHQ9IjEwMCIgd2lkdGg9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIHI9IjQ1IiBjeD0iNTAiIGN5PSI1MCIgZmlsbD0icmVkIiAvPgo8L3N2Zz4g"
+ banner_format: "svg"
+ responses:
+ '200':
+ description: saved successfully
+ '400':
+ description: invalid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ examples:
+ invalid_url_path:
+ summary: url path in request cannot be used
+ value:
+ error: "invalid url path"
+ invalid_banner_format:
+ summary: banner format in request is not supported
+ value:
+ error: "invalid banner format"
+ '500':
+ description: Server Internal error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ example:
+ error: "can't save banner"
+components:
+ schemas:
+ SaveRequestV1:
+ type: object
+ required:
+ - url_path
+ - banner_info
+ - banner_format
+ properties:
+ url_path:
+ type: string
+ description: unified path ( not os path ) / banner identificator
+ example: "hurtki-dark"
+ banner_info:
+ type: string
+ description: base 64 encoded bytes | banner data
+ example: PHN2ZyBoZWlnaHQ9IjEwMCIgd2lkdGg9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIHI9IjQ1IiBjeD0iNTAiIGN5PSI1MCIgZmlsbD0icmVkIiAvPgo8L3N2Zz4g
+ banner_format:
+ type: string
+ description: format of the banner
+ enum: ["svg"]
+ ErrorResponse:
+ type: object
+ required:
+ - error
+ properties:
+ error:
+ type: string
+ description: Error message describing what went wrong
+ example: "Invalid request body format"