mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 11:02:54 +00:00
+146
@@ -0,0 +1,146 @@
|
||||
# Rustfmt configuration for consistent code formatting across the workspace
|
||||
|
||||
# Maximum line width
|
||||
max_width = 100
|
||||
|
||||
# Use 4 spaces for indentation
|
||||
tab_spaces = 4
|
||||
|
||||
# Use spaces instead of tabs
|
||||
hard_tabs = false
|
||||
|
||||
# Edition to use
|
||||
edition = "2021"
|
||||
|
||||
# How to use heuristics for splitting lines
|
||||
use_small_heuristics = "Max"
|
||||
|
||||
# Reorder import statements
|
||||
reorder_imports = true
|
||||
reorder_modules = true
|
||||
|
||||
# Group imports
|
||||
group_imports = "StdExternalCrate"
|
||||
|
||||
# Format comments
|
||||
comment_width = 80
|
||||
wrap_comments = true
|
||||
normalize_comments = true
|
||||
|
||||
# Format strings
|
||||
format_strings = true
|
||||
|
||||
# Use field init shorthand
|
||||
use_field_init_shorthand = true
|
||||
|
||||
# Use try shorthand
|
||||
use_try_shorthand = true
|
||||
|
||||
# Force explicit type for struct fields where possible
|
||||
force_explicit_abi = true
|
||||
|
||||
# Format code in doc comments
|
||||
format_code_in_doc_comments = true
|
||||
|
||||
# Normalize doc attributes
|
||||
normalize_doc_attributes = true
|
||||
|
||||
# Wrap match arms
|
||||
match_arm_blocks = false
|
||||
match_block_trailing_comma = true
|
||||
|
||||
# Trailing comma for lists
|
||||
trailing_comma = "Vertical"
|
||||
|
||||
# Trailing semicolon
|
||||
trailing_semicolon = true
|
||||
|
||||
# Space before colon
|
||||
space_before_colon = false
|
||||
|
||||
# Space after colon
|
||||
space_after_colon = true
|
||||
|
||||
# Spaces around ranges
|
||||
spaces_around_ranges = false
|
||||
|
||||
# Type punctuation density
|
||||
type_punctuation_density = "Wide"
|
||||
|
||||
# Struct field alignment
|
||||
struct_field_align_threshold = 0
|
||||
|
||||
# Enum discriminant align threshold
|
||||
enum_discrim_align_threshold = 0
|
||||
|
||||
# Inline attribute width
|
||||
inline_attribute_width = 0
|
||||
|
||||
# Format macro matchers
|
||||
format_macro_matchers = true
|
||||
|
||||
# Format macro bodies
|
||||
format_macro_bodies = true
|
||||
|
||||
# Hex literal case
|
||||
hex_literal_case = "Lower"
|
||||
|
||||
# Imports indent
|
||||
imports_indent = "Block"
|
||||
|
||||
# Imports layout
|
||||
imports_layout = "Mixed"
|
||||
|
||||
# Merge derives
|
||||
merge_derives = true
|
||||
|
||||
# Remove nested parens
|
||||
remove_nested_parens = true
|
||||
|
||||
# Short array element width threshold
|
||||
short_array_element_width_threshold = 10
|
||||
|
||||
# Struct lit single line
|
||||
struct_lit_single_line = true
|
||||
|
||||
# Struct variant width threshold
|
||||
struct_variant_width_threshold = 35
|
||||
|
||||
# Skip children
|
||||
skip_children = false
|
||||
|
||||
# Condense wildcard suffixes
|
||||
condense_wildcard_suffixes = true
|
||||
|
||||
# Newline style
|
||||
newline_style = "Unix"
|
||||
|
||||
# Control brace style
|
||||
control_brace_style = "AlwaysSameBrace"
|
||||
|
||||
# Brace style
|
||||
brace_style = "SameLineWhere"
|
||||
|
||||
# Empty item single line
|
||||
empty_item_single_line = true
|
||||
|
||||
# Fn args layout
|
||||
fn_args_layout = "Tall"
|
||||
|
||||
# Fn call width
|
||||
fn_call_width = 80
|
||||
|
||||
# Fn single line
|
||||
fn_single_line = false
|
||||
|
||||
# Where single line
|
||||
where_single_line = false
|
||||
|
||||
# Indent style
|
||||
indent_style = "Block"
|
||||
|
||||
# Item brace style
|
||||
item_brace_style = "SameLineWhere"
|
||||
|
||||
# Literal trailing semicolon
|
||||
literal_trailing_comma = "true"
|
||||
+20
-2
@@ -8,10 +8,28 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
version = "1.0.0"
|
||||
authors = ["TaaS Team <[email protected]>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.94"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.metadata.cargo-audit]
|
||||
# Ignore advisories that are not relevant to this project
|
||||
# advisories = []
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
nursery = "warn"
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
unwrap_in_result = "deny"
|
||||
unimplemented = "deny"
|
||||
todo = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1.52.1", features = ["full"] }
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
The Telecom-as-a-Service (TaaS) Platform is a comprehensive, sovereign cellular connectivity solution that enables organizations to deploy and manage their own private 5G/LTE networks. This full-stack platform provides end-to-end capabilities from core network integration to subscriber management, charging, and developer APIs.
|
||||
|
||||
### API Gateway Integration
|
||||
|
||||
The platform now includes **Traefik API Gateway** for centralized request routing, security, and monitoring. All services are accessible through a unified HTTPS endpoint with built-in rate limiting, authentication, and SSL termination.
|
||||
|
||||
### What It Does
|
||||
|
||||
The TaaS Platform allows enterprises, telecom operators, and system integrators to:
|
||||
@@ -22,6 +26,12 @@ The TaaS Platform allows enterprises, telecom operators, and system integrators
|
||||
|
||||
The platform is built as a microservices architecture with the following core components:
|
||||
|
||||
**API Gateway Layer:**
|
||||
- **Traefik API Gateway**: Centralized entry point providing SSL termination, rate limiting, authentication, and request routing
|
||||
- **Unified HTTPS Endpoint**: All services accessible via `https://api.telecom.com`
|
||||
- **Security Middleware**: JWT authentication, security headers, compression, and retry logic
|
||||
- **Monitoring Dashboard**: Real-time metrics and service health visualization
|
||||
|
||||
**Core Network Services:**
|
||||
- **API Server**: Central BSS (Business Support System) API providing authentication, subscriber management, automation, and plugin system
|
||||
- **Carrier Connector**: ES2+ interface for eSIM profile management and carrier integration
|
||||
@@ -43,6 +53,12 @@ The platform is built as a microservices architecture with the following core co
|
||||
|
||||
### Key Features
|
||||
|
||||
**API Gateway & Security:**
|
||||
- **Unified HTTPS Endpoint**: All services accessible via `https://api.telecom.com`
|
||||
- **Centralized Authentication**: JWT validation with rate limiting per service
|
||||
- **SSL Termination**: Automatic HTTPS with security headers enforcement
|
||||
- **Request Routing**: Intelligent routing with circuit breakers and retry logic
|
||||
|
||||
**Sovereignty & Security:**
|
||||
- Full data sovereignty with on-premise deployment
|
||||
- End-to-end encryption for subscriber data
|
||||
@@ -53,26 +69,28 @@ The platform is built as a microservices architecture with the following core co
|
||||
- eBPF-accelerated packet processing for line-rate throughput
|
||||
- Redis-backed distributed rate limiting and caching
|
||||
- Horizontal scaling with Kubernetes orchestration
|
||||
- Circuit breakers and retry logic for external service resilience
|
||||
- Gateway-level load balancing and connection pooling
|
||||
|
||||
**Developer Experience:**
|
||||
- **Single API Endpoint**: Simplified client integration through gateway
|
||||
- REST and GraphQL APIs with comprehensive documentation
|
||||
- TypeScript SDK for type-safe client integration
|
||||
- Plugin system for extending platform capabilities
|
||||
- Automation framework for network operations
|
||||
|
||||
**Operations:**
|
||||
- Real-time health monitoring with Prometheus integration
|
||||
- **Gateway Dashboard**: Real-time monitoring of all services
|
||||
- **Unified Metrics**: Prometheus integration with gateway-level insights
|
||||
- Automated scaling and service discovery
|
||||
- Centralized logging with structured logs
|
||||
- Chaos engineering capabilities for testing resilience
|
||||
- Health checks and failover automation
|
||||
|
||||
|
||||
**Technology Stack:**
|
||||
|
||||
**Core Languages & Runtimes:**
|
||||
- **Go 1.26**: Core network integration, BSS API, carrier connector
|
||||
- **Rust 1.94**: eBPF packet gateway, real-time charging engine
|
||||
- **Rust 1.95**: eBPF packet gateway, real-time charging engine
|
||||
- **TypeScript/Next.js**: Developer dashboard and SDK
|
||||
|
||||
**Databases:**
|
||||
@@ -105,7 +123,7 @@ The platform is built as a microservices architecture with the following core co
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.26+**: [Download](https://go.dev/dl/)
|
||||
- **Rust 1.94+**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
||||
- **Rust 1.95+**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
||||
- **Node.js 22+**: [Download](https://nodejs.org/)
|
||||
- **pnpm**: `npm install -g pnpm`
|
||||
- **Docker**: [Install](https://docs.docker.com/get-docker/)
|
||||
@@ -117,11 +135,28 @@ The platform is built as a microservices architecture with the following core co
|
||||
|
||||
### Installation
|
||||
|
||||
#### Quick Start with API Gateway (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/nutcas3/telecom-platform.git
|
||||
cd telecom-platform
|
||||
|
||||
# Start with API Gateway (includes all services)
|
||||
./scripts/start-gateway.sh
|
||||
|
||||
# Add domain to hosts file
|
||||
echo "127.0.0.1 api.telecom.com" | sudo tee -a /etc/hosts
|
||||
|
||||
# Access services
|
||||
# API Gateway Dashboard: http://localhost:8080
|
||||
# API Documentation: https://api.telecom.com/api/v1/swagger
|
||||
# Web Dashboard: http://localhost:3000
|
||||
```
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
```bash
|
||||
# Build all components
|
||||
make all
|
||||
|
||||
@@ -143,24 +178,31 @@ cd apps/web-dashboard && pnpm dev
|
||||
|
||||
```
|
||||
telecom-platform/
|
||||
├── apps/
|
||||
│ ├── api-server/ # Go: Developer BSS API
|
||||
│ ├── carrier-connector/ # Go: eSIM ES2+ Provisioning
|
||||
│ ├── charging-engine/ # Rust: OCS Real-time Credit Control
|
||||
│ ├── packet-gateway/ # Rust: eBPF UPF Data Plane
|
||||
│ └── web-dashboard/ # TypeScript: Next.js Frontend
|
||||
├── libs/
|
||||
│ ├── shared-ts-sdk/ # TypeScript: Drop-in Widget SDK
|
||||
│ └── proto/ # Shared Protobufs
|
||||
├── deployments/
|
||||
│ ├── kubernetes/ # K8s manifests
|
||||
│ └── docker/ # Dockerfiles
|
||||
├── docs/ # Architecture & API docs
|
||||
├── scripts/ # Automation scripts
|
||||
├── Makefile # Master build orchestrator
|
||||
├── Cargo.toml # Rust workspace config
|
||||
├── go.work # Go workspace config
|
||||
└── pnpm-workspace.yaml # TypeScript workspace config
|
||||
|-- apps/
|
||||
| |-- api-server/ # Go: Developer BSS API
|
||||
| |-- carrier-connector/ # Go: eSIM ES2+ Provisioning
|
||||
| |-- charging-engine/ # Rust: OCS Real-time Credit Control
|
||||
| |-- packet-gateway/ # Rust: eBPF UPF Data Plane
|
||||
| |-- web-dashboard/ # TypeScript: Next.js Frontend
|
||||
|-- libs/
|
||||
| |-- shared-ts-sdk/ # TypeScript: Drop-in Widget SDK
|
||||
| |-- proto/ # Shared Protobufs
|
||||
|-- deployments/
|
||||
| |-- kubernetes/ # K8s manifests
|
||||
| |-- docker/ # Dockerfiles
|
||||
|-- traefik/ # API Gateway configuration
|
||||
| |-- traefik.yml # Static configuration
|
||||
| |-- dynamic/ # Dynamic middleware config
|
||||
|-- docs/ # Architecture & API docs
|
||||
| |-- gateway-quickstart.md # API Gateway guide
|
||||
| |-- api-gateway.md # Gateway implementation details
|
||||
|-- scripts/ # Automation scripts
|
||||
| |-- start-gateway.sh # Gateway startup script
|
||||
|-- docker-compose.yml # Container orchestration
|
||||
|-- Makefile # Master build orchestrator
|
||||
|-- Cargo.toml # Rust workspace config
|
||||
|-- go.work # Go workspace config
|
||||
|-- pnpm-workspace.yaml # TypeScript workspace config
|
||||
```
|
||||
|
||||
## Development
|
||||
@@ -206,6 +248,8 @@ pnpm dev
|
||||
## Documentation
|
||||
|
||||
- **[API Documentation](./docs/api-spec.yaml)**: OpenAPI 3.0 specification
|
||||
- **[Gateway Quickstart](./docs/gateway-quickstart.md)**: API Gateway setup and configuration
|
||||
- **[API Gateway Guide](./docs/api-gateway.md)**: Implementation details and patterns
|
||||
- **[Architecture Guide](./docs/architecture.md)**: System design and data flows
|
||||
- **[Deployment Guide](./docs/deployment.md)**: Kubernetes and Docker setup
|
||||
|
||||
@@ -227,7 +271,17 @@ pnpm -r test
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
### Docker with API Gateway (Recommended)
|
||||
|
||||
```bash
|
||||
# Start with API Gateway
|
||||
./scripts/start-gateway.sh
|
||||
|
||||
# Or manually
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Docker (Legacy)
|
||||
|
||||
```bash
|
||||
make docker-build
|
||||
@@ -240,6 +294,18 @@ docker-compose up -d
|
||||
kubectl apply -f deployments/kubernetes/
|
||||
```
|
||||
|
||||
### API Gateway Configuration
|
||||
|
||||
The API Gateway provides:
|
||||
|
||||
- **Unified Endpoint**: `https://api.telecom.com`
|
||||
- **Rate Limiting**: Per-service rate limits
|
||||
- **SSL Termination**: Automatic HTTPS
|
||||
- **Authentication**: JWT validation
|
||||
- **Monitoring**: Real-time metrics dashboard
|
||||
|
||||
For detailed setup, see [Gateway Quickstart Guide](./docs/gateway-quickstart.md)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env` files in each service directory:
|
||||
|
||||
@@ -14,22 +14,31 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
csrf "github.com/utrack/gin-csrf"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/discovery"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/graphql"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/middleware"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/monitoring"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/websocket"
|
||||
)
|
||||
|
||||
// @title Telecom Platform API
|
||||
// @version 1.0
|
||||
// @description API for Telecom Platform
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
func main() {
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
db, err := database.NewDatabase(&cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
@@ -72,10 +81,17 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
resolver := graphql.NewResolver(db, cfg)
|
||||
|
||||
router := gin.Default()
|
||||
graphql.SetupGraphQLHandler(router, resolver)
|
||||
|
||||
// Add CSRF protection middleware
|
||||
csrfSecret := os.Getenv("CSRF_SECRET")
|
||||
if csrfSecret == "" {
|
||||
csrfSecret = "change-me-in-production-csrf-secret-32-chars-min"
|
||||
log.Printf("WARNING: Using default CSRF secret, set CSRF_SECRET environment variable")
|
||||
}
|
||||
router.Use(csrf.Middleware(csrf.Options{
|
||||
Secret: csrfSecret,
|
||||
}))
|
||||
|
||||
router.Use(func(c *gin.Context) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c.Next() })
|
||||
@@ -136,7 +152,7 @@ func main() {
|
||||
go func() {
|
||||
log.Printf("Starting API server on port %s", cfg.Server.Port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
log.Printf("Server failed to start: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/metrics"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// startMetricsCollection periodically collects and stores metrics.
|
||||
@@ -45,7 +45,7 @@ func collectMetrics(ctx context.Context, mc *metrics.MetricsCollectorWithTimeSer
|
||||
|
||||
if ts := mc.GetTimeSeriesStorage(); ts != nil {
|
||||
if err := ts.StoreSystemMetrics(ctx, systemMetrics); err != nil {
|
||||
log.Printf("Failed to store system metrics: %v", err)
|
||||
logrus.WithError(err).Error("Failed to store system metrics")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,17 +56,17 @@ func getSubscriberStats(db *database.Database) (total, active, suspended int) {
|
||||
defer cancel()
|
||||
var totalCount, activeCount, suspendedCount int64
|
||||
if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Count(&totalCount).Error; err != nil {
|
||||
log.Printf("Error counting total subscribers: %v", err)
|
||||
logrus.WithError(err).Error("Error counting total subscribers")
|
||||
return 0, 0, 0
|
||||
}
|
||||
if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("status = ?", "active").Count(&activeCount).Error; err != nil {
|
||||
log.Printf("Error counting active subscribers: %v", err)
|
||||
logrus.WithError(err).Error("Error counting active subscribers")
|
||||
return int(totalCount), 0, 0
|
||||
}
|
||||
if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).
|
||||
Where("status IN ?", []string{"suspended", "deactivated", "blocked"}).
|
||||
Count(&suspendedCount).Error; err != nil {
|
||||
log.Printf("Error counting suspended subscribers: %v", err)
|
||||
logrus.WithError(err).Error("Error counting suspended subscribers")
|
||||
return int(totalCount), int(activeCount), 0
|
||||
}
|
||||
return int(totalCount), int(activeCount), int(suspendedCount)
|
||||
@@ -82,7 +82,7 @@ func getActiveSessionsCount(db *database.Database) int {
|
||||
"status IN ? AND end_time IS NULL",
|
||||
[]string{"active", "connected", "established"},
|
||||
).Count(&count).Error; err != nil {
|
||||
log.Printf("Error counting active sessions: %v", err)
|
||||
logrus.WithError(err).Error("Error counting active sessions")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func getActiveSessionsCount(db *database.Database) int {
|
||||
func getCPUUsage() float64 {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
log.Printf("Error reading /proc/stat: %v", err)
|
||||
logrus.WithError(err).Error("Error reading /proc/stat")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func getCPUUsage() float64 {
|
||||
func getMemoryUsage() float64 {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
log.Printf("Error reading /proc/meminfo: %v", err)
|
||||
logrus.WithError(err).Error("Error reading /proc/meminfo")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func getNetworkTX() int64 { return readNetworkStat(9) }
|
||||
func readNetworkStat(fieldIdx int) int64 {
|
||||
data, err := os.ReadFile("/proc/net/dev")
|
||||
if err != nil {
|
||||
log.Printf("Error reading /proc/net/dev: %v", err)
|
||||
logrus.WithError(err).Error("Error reading /proc/net/dev")
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/middleware"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/rbac"
|
||||
@@ -11,13 +13,16 @@ import (
|
||||
// RBAC enforcement uses Casbin when configured, falling back to role-based
|
||||
// middleware otherwise.
|
||||
func registerV1Routes(router *gin.Engine, d *serverDeps) {
|
||||
v1 := router.Group("/v1")
|
||||
v1 := router.Group("/api/v1")
|
||||
|
||||
// Health check
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// Swagger documentation
|
||||
v1.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// Charging engine integration (public for packet-gateway integration)
|
||||
charging := v1.Group("/charging")
|
||||
{
|
||||
@@ -46,7 +51,7 @@ func registerV1Routes(router *gin.Engine, d *serverDeps) {
|
||||
}
|
||||
|
||||
users := protected.Group("/users")
|
||||
applyRBAC(users, d.casbinSvc, "/v1/users", "GET", "admin")
|
||||
applyRBAC(users, d.casbinSvc, "/api/v1/users", "GET", "admin")
|
||||
{
|
||||
users.GET("", d.authH.GetUsers)
|
||||
users.POST("", d.authH.CreateUser)
|
||||
|
||||
+757
-98
@@ -1,117 +1,776 @@
|
||||
// Package docs GENERATED BY SWAG; DO NOT EDIT
|
||||
// This file was generated by swaggo/swag
|
||||
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/swaggo/swag"
|
||||
)
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{.Description}}",
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"termsOfService": "{{.TermsOfService}}",
|
||||
"contact": {
|
||||
"name": "{{.ContactName}}",
|
||||
"url": "{{.ContactURL}}",
|
||||
"email": "{{.ContactEmail}}"
|
||||
},
|
||||
"license": {
|
||||
"name": "{{.LicenseName}}",
|
||||
"url": "{{.LicenseURL}}"
|
||||
},
|
||||
"contact": {},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {},
|
||||
"definitions": {}
|
||||
"paths": {
|
||||
"/api/v1/subscribers": {
|
||||
"get": {
|
||||
"description": "List subscribers with pagination and filtering",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "List subscribers",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Page size",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Subscriber status",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Organization ID",
|
||||
"name": "organization_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search term",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/services.ListSubscribersResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a new subscriber with allocated IMSI",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Create a new subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Subscriber data",
|
||||
"name": "subscriber",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/services.CreateSubscriberRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/imsi/{imsi}": {
|
||||
"get": {
|
||||
"description": "Retrieve subscriber information by IMSI",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Get subscriber by IMSI",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IMSI",
|
||||
"name": "imsi",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieve subscriber information by ID",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Get subscriber by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Update subscriber information",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Update subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Updated subscriber data",
|
||||
"name": "subscriber",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/services.UpdateSubscriberRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/{id}/suspend": {
|
||||
"post": {
|
||||
"description": "Suspend a subscriber and terminate their sessions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Suspend subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/{id}/terminate": {
|
||||
"post": {
|
||||
"description": "Terminate a subscriber and deactivate their eSIM profile",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Terminate subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"handlers.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.AMBR": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"downlink": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uplink": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.PLMN": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mcc": {
|
||||
"type": "string"
|
||||
},
|
||||
"mnc": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ProfileStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"inactive",
|
||||
"active",
|
||||
"downloading",
|
||||
"error"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ProfileStatusInactive",
|
||||
"ProfileStatusActive",
|
||||
"ProfileStatusDownloading",
|
||||
"ProfileStatusError"
|
||||
]
|
||||
},
|
||||
"models.QoSProfile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"arpa": {
|
||||
"type": "integer"
|
||||
},
|
||||
"gbr": {
|
||||
"$ref": "#/definitions/models.AMBR"
|
||||
},
|
||||
"mbr": {
|
||||
"$ref": "#/definitions/models.AMBR"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer"
|
||||
},
|
||||
"qci": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SNSSAI": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sd": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sst": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ServicePlan": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ambr": {
|
||||
"$ref": "#/definitions/models.AMBR"
|
||||
},
|
||||
"arpa": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"data_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"monthly_fee": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sms_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sms_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"voice_rate": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Session": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_used": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dnn": {
|
||||
"type": "string"
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pdu_address": {
|
||||
"type": "string"
|
||||
},
|
||||
"qos": {
|
||||
"$ref": "#/definitions/models.QoSProfile"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"sms_used": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snssai": {
|
||||
"$ref": "#/definitions/models.SNSSAI"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.SessionStatus"
|
||||
},
|
||||
"subscriber_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice_used": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SessionStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"inactive",
|
||||
"terminating"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"SessionStatusActive",
|
||||
"SessionStatusInactive",
|
||||
"SessionStatusTerminating"
|
||||
]
|
||||
},
|
||||
"models.Subscriber": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"balance": {
|
||||
"type": "number"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"euicc_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"imei": {
|
||||
"description": "Device identifier",
|
||||
"type": "string"
|
||||
},
|
||||
"imsi": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"msisdn": {
|
||||
"description": "Mobile phone number",
|
||||
"type": "string"
|
||||
},
|
||||
"opc": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan": {
|
||||
"$ref": "#/definitions/models.ServicePlan"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"profile_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"profile_status": {
|
||||
"$ref": "#/definitions/models.ProfileStatus"
|
||||
},
|
||||
"serving_plmn": {
|
||||
"$ref": "#/definitions/models.PLMN"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.Session"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.SubscriberStatus"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage_records": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.UsageRecord"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SubscriberStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"inactive",
|
||||
"suspended",
|
||||
"terminated",
|
||||
"provisioning"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"SubscriberStatusActive",
|
||||
"SubscriberStatusInactive",
|
||||
"SubscriberStatusSuspended",
|
||||
"SubscriberStatusTerminated",
|
||||
"SubscriberStatusProvisioning"
|
||||
]
|
||||
},
|
||||
"models.UsageRecord": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"billing_cycle": {
|
||||
"type": "string"
|
||||
},
|
||||
"cost": {
|
||||
"type": "number"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"invoice_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"subscriber_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage_type": {
|
||||
"$ref": "#/definitions/models.UsageType"
|
||||
},
|
||||
"volume": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.UsageType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"data",
|
||||
"voice",
|
||||
"sms"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UsageTypeData",
|
||||
"UsageTypeVoice",
|
||||
"UsageTypeSMS"
|
||||
]
|
||||
},
|
||||
"services.CreateSubscriberRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"msisdn",
|
||||
"plan_id"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"euicc_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"msisdn": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services.ListSubscribersResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"subscribers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services.UpdateSubscriberRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.SubscriberStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = struct {
|
||||
Host string
|
||||
BasePath string
|
||||
Schemes []string
|
||||
Title string
|
||||
Description string
|
||||
Version string
|
||||
// License information
|
||||
LicenseName string
|
||||
LicenseURL string
|
||||
// Contact information
|
||||
ContactName string
|
||||
ContactURL string
|
||||
ContactEmail string
|
||||
}{
|
||||
Host: "localhost:8000",
|
||||
BasePath: "/v1",
|
||||
Schemes: []string{"http", "https"},
|
||||
Title: "Telecom Platform API",
|
||||
Description: "A comprehensive telecom platform API for managing subscribers, services, monitoring, deployments, plugins, automation, and billing",
|
||||
Version: "1.0.0",
|
||||
LicenseName: "MIT",
|
||||
LicenseURL: "https://opensource.org/licenses/MIT",
|
||||
ContactName: "Telecom Platform Team",
|
||||
ContactURL: "https://github.com/nutcas3/telecom-platform",
|
||||
ContactEmail: "[email protected]",
|
||||
}
|
||||
|
||||
type s struct{}
|
||||
|
||||
func (s s) ReadDoc() string {
|
||||
sInfo := SwaggerInfo
|
||||
description := `Telecom Platform API
|
||||
|
||||
This API provides comprehensive functionality for managing a telecom platform including:
|
||||
- Subscriber management and eSIM provisioning
|
||||
- Service management and Kubernetes integration
|
||||
- Monitoring and metrics collection
|
||||
- Deployment management
|
||||
- Plugin system
|
||||
- Workflow automation
|
||||
- Billing and invoicing
|
||||
- Configuration management
|
||||
- Authentication and authorization
|
||||
|
||||
## Authentication
|
||||
|
||||
The API uses JWT (JSON Web Token) authentication. To access protected endpoints:
|
||||
1. Register a user account
|
||||
2. Login to obtain an access token
|
||||
3. Include the token in the Authorization header: "Bearer <token>"
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
API requests are rate-limited to ensure fair usage. Current limits:
|
||||
- 100 requests per minute per authenticated user
|
||||
- 10 requests per minute for unauthenticated requests
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP status codes:
|
||||
- 200: Success
|
||||
- 201: Created
|
||||
- 400: Bad Request
|
||||
- 401: Unauthorized
|
||||
- 403: Forbidden
|
||||
- 404: Not Found
|
||||
- 500: Internal Server Error
|
||||
|
||||
Error responses include a JSON object with an "error" field containing a descriptive message.`
|
||||
|
||||
// Replace template placeholders with actual values
|
||||
result := strings.Replace(docTemplate, "{{.Version}}", sInfo.Version, -1)
|
||||
result = strings.Replace(result, "{{.Description}}", description, -1)
|
||||
|
||||
return result
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "",
|
||||
Host: "",
|
||||
BasePath: "",
|
||||
Schemes: []string{},
|
||||
Title: "",
|
||||
Description: "",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(swag.Name, &s{})
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"github.com/swaggo/swag"
|
||||
)
|
||||
|
||||
// @title Telecom Platform API
|
||||
// @version 1.0.0
|
||||
// @description A comprehensive telecom platform API for managing subscribers, services, monitoring, deployments, plugins, automation, and billing
|
||||
@@ -19,7 +15,3 @@ import (
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
func init() {
|
||||
swag.Register(swag.Name, &s{})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"contact": {}
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/subscribers": {
|
||||
"get": {
|
||||
"description": "List subscribers with pagination and filtering",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "List subscribers",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Page size",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Subscriber status",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Organization ID",
|
||||
"name": "organization_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search term",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/services.ListSubscribersResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a new subscriber with allocated IMSI",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Create a new subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Subscriber data",
|
||||
"name": "subscriber",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/services.CreateSubscriberRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/imsi/{imsi}": {
|
||||
"get": {
|
||||
"description": "Retrieve subscriber information by IMSI",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Get subscriber by IMSI",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IMSI",
|
||||
"name": "imsi",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieve subscriber information by ID",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Get subscriber by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Update subscriber information",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Update subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Updated subscriber data",
|
||||
"name": "subscriber",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/services.UpdateSubscriberRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/{id}/suspend": {
|
||||
"post": {
|
||||
"description": "Suspend a subscriber and terminate their sessions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Suspend subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/subscribers/{id}/terminate": {
|
||||
"post": {
|
||||
"description": "Terminate a subscriber and deactivate their eSIM profile",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"subscribers"
|
||||
],
|
||||
"summary": "Terminate subscriber",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Subscriber ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"handlers.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.AMBR": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"downlink": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uplink": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.PLMN": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mcc": {
|
||||
"type": "string"
|
||||
},
|
||||
"mnc": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ProfileStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"inactive",
|
||||
"active",
|
||||
"downloading",
|
||||
"error"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ProfileStatusInactive",
|
||||
"ProfileStatusActive",
|
||||
"ProfileStatusDownloading",
|
||||
"ProfileStatusError"
|
||||
]
|
||||
},
|
||||
"models.QoSProfile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"arpa": {
|
||||
"type": "integer"
|
||||
},
|
||||
"gbr": {
|
||||
"$ref": "#/definitions/models.AMBR"
|
||||
},
|
||||
"mbr": {
|
||||
"$ref": "#/definitions/models.AMBR"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer"
|
||||
},
|
||||
"qci": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SNSSAI": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sd": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sst": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ServicePlan": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ambr": {
|
||||
"$ref": "#/definitions/models.AMBR"
|
||||
},
|
||||
"arpa": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"data_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"monthly_fee": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sms_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sms_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"voice_rate": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Session": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_used": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dnn": {
|
||||
"type": "string"
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pdu_address": {
|
||||
"type": "string"
|
||||
},
|
||||
"qos": {
|
||||
"$ref": "#/definitions/models.QoSProfile"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"sms_used": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snssai": {
|
||||
"$ref": "#/definitions/models.SNSSAI"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.SessionStatus"
|
||||
},
|
||||
"subscriber_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice_used": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SessionStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"inactive",
|
||||
"terminating"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"SessionStatusActive",
|
||||
"SessionStatusInactive",
|
||||
"SessionStatusTerminating"
|
||||
]
|
||||
},
|
||||
"models.Subscriber": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"balance": {
|
||||
"type": "number"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"euicc_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"imei": {
|
||||
"description": "Device identifier",
|
||||
"type": "string"
|
||||
},
|
||||
"imsi": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"msisdn": {
|
||||
"description": "Mobile phone number",
|
||||
"type": "string"
|
||||
},
|
||||
"opc": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan": {
|
||||
"$ref": "#/definitions/models.ServicePlan"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"profile_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"profile_status": {
|
||||
"$ref": "#/definitions/models.ProfileStatus"
|
||||
},
|
||||
"serving_plmn": {
|
||||
"$ref": "#/definitions/models.PLMN"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.Session"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.SubscriberStatus"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage_records": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.UsageRecord"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SubscriberStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"inactive",
|
||||
"suspended",
|
||||
"terminated",
|
||||
"provisioning"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"SubscriberStatusActive",
|
||||
"SubscriberStatusInactive",
|
||||
"SubscriberStatusSuspended",
|
||||
"SubscriberStatusTerminated",
|
||||
"SubscriberStatusProvisioning"
|
||||
]
|
||||
},
|
||||
"models.UsageRecord": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"billing_cycle": {
|
||||
"type": "string"
|
||||
},
|
||||
"cost": {
|
||||
"type": "number"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"invoice_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"subscriber_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage_type": {
|
||||
"$ref": "#/definitions/models.UsageType"
|
||||
},
|
||||
"volume": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.UsageType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"data",
|
||||
"voice",
|
||||
"sms"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UsageTypeData",
|
||||
"UsageTypeVoice",
|
||||
"UsageTypeSMS"
|
||||
]
|
||||
},
|
||||
"services.CreateSubscriberRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"msisdn",
|
||||
"plan_id"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"euicc_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"msisdn": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services.ListSubscribersResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"subscribers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.Subscriber"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services.UpdateSubscriberRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.SubscriberStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
definitions:
|
||||
handlers.ErrorResponse:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
details:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
models.AMBR:
|
||||
properties:
|
||||
downlink:
|
||||
type: integer
|
||||
uplink:
|
||||
type: integer
|
||||
type: object
|
||||
models.PLMN:
|
||||
properties:
|
||||
mcc:
|
||||
type: string
|
||||
mnc:
|
||||
type: string
|
||||
type: object
|
||||
models.ProfileStatus:
|
||||
enum:
|
||||
- inactive
|
||||
- active
|
||||
- downloading
|
||||
- error
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- ProfileStatusInactive
|
||||
- ProfileStatusActive
|
||||
- ProfileStatusDownloading
|
||||
- ProfileStatusError
|
||||
models.QoSProfile:
|
||||
properties:
|
||||
arpa:
|
||||
type: integer
|
||||
gbr:
|
||||
$ref: '#/definitions/models.AMBR'
|
||||
mbr:
|
||||
$ref: '#/definitions/models.AMBR'
|
||||
priority:
|
||||
type: integer
|
||||
qci:
|
||||
type: integer
|
||||
type: object
|
||||
models.SNSSAI:
|
||||
properties:
|
||||
sd:
|
||||
type: integer
|
||||
sst:
|
||||
type: integer
|
||||
type: object
|
||||
models.ServicePlan:
|
||||
properties:
|
||||
ambr:
|
||||
$ref: '#/definitions/models.AMBR'
|
||||
arpa:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
data_limit:
|
||||
type: integer
|
||||
data_rate:
|
||||
type: number
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
monthly_fee:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
sms_limit:
|
||||
type: integer
|
||||
sms_rate:
|
||||
type: number
|
||||
updated_at:
|
||||
type: string
|
||||
voice_limit:
|
||||
type: integer
|
||||
voice_rate:
|
||||
type: number
|
||||
type: object
|
||||
models.Session:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
data_used:
|
||||
type: integer
|
||||
dnn:
|
||||
type: string
|
||||
end_time:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
pdu_address:
|
||||
type: string
|
||||
qos:
|
||||
$ref: '#/definitions/models.QoSProfile'
|
||||
session_id:
|
||||
type: string
|
||||
sms_used:
|
||||
type: integer
|
||||
snssai:
|
||||
$ref: '#/definitions/models.SNSSAI'
|
||||
start_time:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/definitions/models.SessionStatus'
|
||||
subscriber_id:
|
||||
type: integer
|
||||
updated_at:
|
||||
type: string
|
||||
voice_used:
|
||||
type: integer
|
||||
type: object
|
||||
models.SessionStatus:
|
||||
enum:
|
||||
- active
|
||||
- inactive
|
||||
- terminating
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- SessionStatusActive
|
||||
- SessionStatusInactive
|
||||
- SessionStatusTerminating
|
||||
models.Subscriber:
|
||||
properties:
|
||||
auth_key:
|
||||
type: string
|
||||
balance:
|
||||
type: number
|
||||
created_at:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
euicc_id:
|
||||
type: string
|
||||
first_name:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
imei:
|
||||
description: Device identifier
|
||||
type: string
|
||||
imsi:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
msisdn:
|
||||
description: Mobile phone number
|
||||
type: string
|
||||
opc:
|
||||
type: string
|
||||
organization_id:
|
||||
type: string
|
||||
plan:
|
||||
$ref: '#/definitions/models.ServicePlan'
|
||||
plan_id:
|
||||
type: integer
|
||||
profile_id:
|
||||
type: string
|
||||
profile_status:
|
||||
$ref: '#/definitions/models.ProfileStatus'
|
||||
serving_plmn:
|
||||
$ref: '#/definitions/models.PLMN'
|
||||
sessions:
|
||||
items:
|
||||
$ref: '#/definitions/models.Session'
|
||||
type: array
|
||||
status:
|
||||
$ref: '#/definitions/models.SubscriberStatus'
|
||||
updated_at:
|
||||
type: string
|
||||
usage_records:
|
||||
items:
|
||||
$ref: '#/definitions/models.UsageRecord'
|
||||
type: array
|
||||
type: object
|
||||
models.SubscriberStatus:
|
||||
enum:
|
||||
- active
|
||||
- inactive
|
||||
- suspended
|
||||
- terminated
|
||||
- provisioning
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- SubscriberStatusActive
|
||||
- SubscriberStatusInactive
|
||||
- SubscriberStatusSuspended
|
||||
- SubscriberStatusTerminated
|
||||
- SubscriberStatusProvisioning
|
||||
models.UsageRecord:
|
||||
properties:
|
||||
billing_cycle:
|
||||
type: string
|
||||
cost:
|
||||
type: number
|
||||
created_at:
|
||||
type: string
|
||||
end_time:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
invoice_id:
|
||||
type: integer
|
||||
rate:
|
||||
type: number
|
||||
session_id:
|
||||
type: integer
|
||||
start_time:
|
||||
type: string
|
||||
subscriber_id:
|
||||
type: integer
|
||||
updated_at:
|
||||
type: string
|
||||
usage_type:
|
||||
$ref: '#/definitions/models.UsageType'
|
||||
volume:
|
||||
type: integer
|
||||
type: object
|
||||
models.UsageType:
|
||||
enum:
|
||||
- data
|
||||
- voice
|
||||
- sms
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- UsageTypeData
|
||||
- UsageTypeVoice
|
||||
- UsageTypeSMS
|
||||
services.CreateSubscriberRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
euicc_id:
|
||||
type: string
|
||||
first_name:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
msisdn:
|
||||
type: string
|
||||
organization_id:
|
||||
type: string
|
||||
plan_id:
|
||||
type: integer
|
||||
required:
|
||||
- email
|
||||
- first_name
|
||||
- last_name
|
||||
- msisdn
|
||||
- plan_id
|
||||
type: object
|
||||
services.ListSubscribersResponse:
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
subscribers:
|
||||
items:
|
||||
$ref: '#/definitions/models.Subscriber'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type: object
|
||||
services.UpdateSubscriberRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
first_name:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
plan_id:
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/definitions/models.SubscriberStatus'
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
/api/v1/subscribers:
|
||||
get:
|
||||
description: List subscribers with pagination and filtering
|
||||
parameters:
|
||||
- default: 1
|
||||
description: Page number
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- default: 20
|
||||
description: Page size
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Subscriber status
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
- description: Organization ID
|
||||
in: query
|
||||
name: organization_id
|
||||
type: string
|
||||
- description: Search term
|
||||
in: query
|
||||
name: search
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/services.ListSubscribersResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: List subscribers
|
||||
tags:
|
||||
- subscribers
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a new subscriber with allocated IMSI
|
||||
parameters:
|
||||
- description: Subscriber data
|
||||
in: body
|
||||
name: subscriber
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/services.CreateSubscriberRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/models.Subscriber'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Create a new subscriber
|
||||
tags:
|
||||
- subscribers
|
||||
/api/v1/subscribers/{id}:
|
||||
get:
|
||||
description: Retrieve subscriber information by ID
|
||||
parameters:
|
||||
- description: Subscriber ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Subscriber'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Get subscriber by ID
|
||||
tags:
|
||||
- subscribers
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update subscriber information
|
||||
parameters:
|
||||
- description: Subscriber ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Updated subscriber data
|
||||
in: body
|
||||
name: subscriber
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/services.UpdateSubscriberRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Subscriber'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Update subscriber
|
||||
tags:
|
||||
- subscribers
|
||||
/api/v1/subscribers/{id}/suspend:
|
||||
post:
|
||||
description: Suspend a subscriber and terminate their sessions
|
||||
parameters:
|
||||
- description: Subscriber ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Suspend subscriber
|
||||
tags:
|
||||
- subscribers
|
||||
/api/v1/subscribers/{id}/terminate:
|
||||
post:
|
||||
description: Terminate a subscriber and deactivate their eSIM profile
|
||||
parameters:
|
||||
- description: Subscriber ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Terminate subscriber
|
||||
tags:
|
||||
- subscribers
|
||||
/api/v1/subscribers/imsi/{imsi}:
|
||||
get:
|
||||
description: Retrieve subscriber information by IMSI
|
||||
parameters:
|
||||
- description: IMSI
|
||||
in: path
|
||||
name: imsi
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Subscriber'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Get subscriber by IMSI
|
||||
tags:
|
||||
- subscribers
|
||||
swagger: "2.0"
|
||||
@@ -9,11 +9,12 @@ require (
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/vektah/gqlparser/v2 v2.5.32
|
||||
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
|
||||
golang.org/x/crypto v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@@ -26,20 +27,22 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v3 v3.8.1 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -64,6 +67,10 @@ require (
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
@@ -72,7 +79,6 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/serf v0.10.1 // indirect
|
||||
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
@@ -90,7 +96,6 @@ require (
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sosodev/duration v1.4.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
@@ -124,7 +129,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.89
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
|
||||
+37
-22
@@ -1,7 +1,5 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
|
||||
github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
@@ -29,31 +27,23 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83
|
||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -63,6 +53,9 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
|
||||
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -90,10 +83,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -111,16 +104,22 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 h1:ldKXSIxdVtXVCP4JW0p4ErvnPhISjbb5QSIqMnBa3ak=
|
||||
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
|
||||
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
@@ -201,6 +200,7 @@ github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
@@ -217,7 +217,15 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
@@ -293,12 +301,14 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -317,8 +327,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
@@ -332,6 +340,7 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
@@ -341,6 +350,9 @@ github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJ
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||
@@ -412,6 +424,7 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -427,16 +440,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
@@ -471,10 +480,11 @@ github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
|
||||
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -517,6 +527,7 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -560,6 +571,7 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -655,8 +667,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -2,6 +2,7 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"gorm.io/gorm"
|
||||
@@ -59,6 +60,52 @@ func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequ
|
||||
return subscribers, total, err
|
||||
}
|
||||
|
||||
// ListSubscribersCursor implements cursor-based pagination for subscribers
|
||||
func (d *Database) ListSubscribersCursor(ctx context.Context, cursor string, limit int, status, organizationID, search string) ([]models.Subscriber, string, bool, error) {
|
||||
var subscribers []models.Subscriber
|
||||
|
||||
query := d.DB.WithContext(ctx).Model(&models.Subscriber{})
|
||||
|
||||
// Apply filters
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if organizationID != "" {
|
||||
query = query.Where("organization_id = ?", organizationID)
|
||||
}
|
||||
if search != "" {
|
||||
query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ? OR msisdn ILIKE ?",
|
||||
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
// Apply cursor-based pagination (using ID as cursor)
|
||||
if cursor != "" {
|
||||
query = query.Where("id < ?", cursor)
|
||||
}
|
||||
|
||||
// Order by ID descending and apply limit
|
||||
query = query.Order("id DESC").Limit(limit + 1) // +1 to check if there are more results
|
||||
|
||||
err := query.Preload("Plan").Find(&subscribers).Error
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
|
||||
// Determine if there are more results
|
||||
hasMore := len(subscribers) > limit
|
||||
if hasMore {
|
||||
subscribers = subscribers[:limit] // Remove the extra item
|
||||
}
|
||||
|
||||
// Generate next cursor
|
||||
var nextCursor string
|
||||
if len(subscribers) > 0 {
|
||||
nextCursor = fmt.Sprintf("%d", subscribers[len(subscribers)-1].ID)
|
||||
}
|
||||
|
||||
return subscribers, nextCursor, hasMore, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetActiveSessionsByIMSI(ctx context.Context, imsi models.IMSI) ([]models.Session, error) {
|
||||
var sessions []models.Session
|
||||
err := d.DB.WithContext(ctx).Where("subscriber_id = ? AND status = ?", imsi, "active").Find(&sessions).Error
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
package graphql
|
||||
|
||||
// This file will be generated by gqlgen
|
||||
// Run: go run github.com/99designs/gqlgen generate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/vektah/gqlparser/v2/ast"
|
||||
)
|
||||
|
||||
// GraphQL type definitions
|
||||
type SubscriberStatus string
|
||||
type ProfileStatus string
|
||||
type UsageType string
|
||||
type SessionStatus string
|
||||
type PaymentMethodType string
|
||||
type InvoiceStatus string
|
||||
type AlertType string
|
||||
type AlertSeverity string
|
||||
type SubscriberSort string
|
||||
type UsagePeriod string
|
||||
type TrendDirection string
|
||||
|
||||
const (
|
||||
SubscriberStatusActive SubscriberStatus = "ACTIVE"
|
||||
SubscriberStatusInactive SubscriberStatus = "INACTIVE"
|
||||
SubscriberStatusSuspended SubscriberStatus = "SUSPENDED"
|
||||
SubscriberStatusTerminated SubscriberStatus = "TERMINATED"
|
||||
SubscriberStatusProvisioning SubscriberStatus = "PROVISIONING"
|
||||
)
|
||||
|
||||
const (
|
||||
ProfileStatusActive ProfileStatus = "ACTIVE"
|
||||
ProfileStatusInactive ProfileStatus = "INACTIVE"
|
||||
ProfileStatusDownloading ProfileStatus = "DOWNLOADING"
|
||||
ProfileStatusFailed ProfileStatus = "FAILED"
|
||||
)
|
||||
|
||||
const (
|
||||
UsageTypeData UsageType = "DATA"
|
||||
UsageTypeVoice UsageType = "VOICE"
|
||||
UsageTypeSMS UsageType = "SMS"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionStatusActive SessionStatus = "ACTIVE"
|
||||
SessionStatusCompleted SessionStatus = "COMPLETED"
|
||||
SessionStatusTerminated SessionStatus = "TERMINATED"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD"
|
||||
PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT"
|
||||
)
|
||||
|
||||
const (
|
||||
InvoiceStatusDraft InvoiceStatus = "DRAFT"
|
||||
InvoiceStatusPending InvoiceStatus = "PENDING"
|
||||
InvoiceStatusPaid InvoiceStatus = "PAID"
|
||||
InvoiceStatusOverdue InvoiceStatus = "OVERDUE"
|
||||
InvoiceStatusCancelled InvoiceStatus = "CANCELLED"
|
||||
)
|
||||
|
||||
const (
|
||||
AlertTypeLowBalance AlertType = "LOW_BALANCE"
|
||||
AlertTypeHighUsage AlertType = "HIGH_USAGE"
|
||||
AlertTypePaymentFailed AlertType = "PAYMENT_FAILED"
|
||||
AlertTypeSystemError AlertType = "SYSTEM_ERROR"
|
||||
)
|
||||
|
||||
const (
|
||||
AlertSeverityLow AlertSeverity = "LOW"
|
||||
AlertSeverityMedium AlertSeverity = "MEDIUM"
|
||||
AlertSeverityHigh AlertSeverity = "HIGH"
|
||||
AlertSeverityCritical AlertSeverity = "CRITICAL"
|
||||
)
|
||||
|
||||
const (
|
||||
SubscriberSortCreatedAtAsc SubscriberSort = "CREATED_AT_ASC"
|
||||
SubscriberSortCreatedAtDesc SubscriberSort = "CREATED_AT_DESC"
|
||||
SubscriberSortUpdatedAtAsc SubscriberSort = "UPDATED_AT_ASC"
|
||||
SubscriberSortUpdatedAtDesc SubscriberSort = "UPDATED_AT_DESC"
|
||||
SubscriberSortFirstNameAsc SubscriberSort = "FIRST_NAME_ASC"
|
||||
SubscriberSortFirstNameDesc SubscriberSort = "FIRST_NAME_DESC"
|
||||
SubscriberSortLastNameAsc SubscriberSort = "LAST_NAME_ASC"
|
||||
SubscriberSortLastNameDesc SubscriberSort = "LAST_NAME_DESC"
|
||||
SubscriberSortEmailAsc SubscriberSort = "EMAIL_ASC"
|
||||
SubscriberSortEmailDesc SubscriberSort = "EMAIL_DESC"
|
||||
SubscriberSortBalanceAsc SubscriberSort = "BALANCE_ASC"
|
||||
SubscriberSortBalanceDesc SubscriberSort = "BALANCE_DESC"
|
||||
SubscriberSortDataUsedAsc SubscriberSort = "DATA_USED_ASC"
|
||||
SubscriberSortDataUsedDesc SubscriberSort = "DATA_USED_DESC"
|
||||
)
|
||||
|
||||
const (
|
||||
UsagePeriodDaily UsagePeriod = "DAILY"
|
||||
UsagePeriodWeekly UsagePeriod = "WEEKLY"
|
||||
UsagePeriodMonthly UsagePeriod = "MONTHLY"
|
||||
UsagePeriodYearly UsagePeriod = "YEARLY"
|
||||
)
|
||||
|
||||
const (
|
||||
TrendDirectionUp TrendDirection = "UP"
|
||||
TrendDirectionDown TrendDirection = "DOWN"
|
||||
TrendDirectionStable TrendDirection = "STABLE"
|
||||
)
|
||||
|
||||
// Input types
|
||||
type SubscriberFilter struct {
|
||||
Status *SubscriberStatus `json:"status"`
|
||||
OrganizationID *string `json:"organizationId"`
|
||||
PlanID *int `json:"planId"`
|
||||
Search *string `json:"search"`
|
||||
BalanceMin *float64 `json:"balanceMin"`
|
||||
BalanceMax *float64 `json:"balanceMax"`
|
||||
DataUsageMin *float64 `json:"dataUsageMin"`
|
||||
DataUsageMax *float64 `json:"dataUsageMax"`
|
||||
}
|
||||
|
||||
type UsageEventFilter struct {
|
||||
UsageType *UsageType `json:"usageType"`
|
||||
TimestampGte *time.Time `json:"timestampGte"`
|
||||
TimestampLte *time.Time `json:"timestampLte"`
|
||||
CostMin *float64 `json:"costMin"`
|
||||
CostMax *float64 `json:"costMax"`
|
||||
}
|
||||
|
||||
type CreateSubscriberInput struct {
|
||||
MSISDN string `json:"msisdn"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
OrganizationID *string `json:"organizationId"`
|
||||
PlanID int `json:"planId"`
|
||||
EUICCID *string `json:"euiccId"`
|
||||
}
|
||||
|
||||
type UpdateSubscriberInput struct {
|
||||
FirstName *string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
Email *string `json:"email"`
|
||||
OrganizationID *string `json:"organizationId"`
|
||||
PlanID *int `json:"planId"`
|
||||
}
|
||||
|
||||
type TopUpInput struct {
|
||||
Amount float64 `json:"amount"`
|
||||
PaymentMethodID *string `json:"paymentMethodId"`
|
||||
}
|
||||
|
||||
type UsageStatsInput struct {
|
||||
IMSI string `json:"imsi"`
|
||||
Period UsagePeriod `json:"period"`
|
||||
}
|
||||
|
||||
type PaymentMethodInput struct {
|
||||
Type PaymentMethodType `json:"type"`
|
||||
Token string `json:"token"`
|
||||
IsDefault *bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
type CreateAlertInput struct {
|
||||
Type AlertType `json:"type"`
|
||||
Severity AlertSeverity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
SubscriberID *int `json:"subscriberId"`
|
||||
}
|
||||
|
||||
// Output types
|
||||
type PageInfo struct {
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
HasPreviousPage bool `json:"hasPreviousPage"`
|
||||
StartCursor *string `json:"startCursor"`
|
||||
EndCursor *string `json:"endCursor"`
|
||||
}
|
||||
|
||||
type SubscriberEdge struct {
|
||||
Node *models.Subscriber `json:"node"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
type SubscriberConnection struct {
|
||||
Edges []*SubscriberEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type UsageEventEdge struct {
|
||||
Node *models.UsageEvent `json:"node"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
type UsageEventConnection struct {
|
||||
Edges []*UsageEventEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type InvoiceEdge struct {
|
||||
Node *models.Invoice `json:"node"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
type InvoiceConnection struct {
|
||||
Edges []*InvoiceEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type AlertEdge struct {
|
||||
Node *models.Alert `json:"node"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
type AlertConnection struct {
|
||||
Edges []*AlertEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type ChargingSessionEdge struct {
|
||||
Node *ChargingSession `json:"node"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
type ChargingSessionConnection struct {
|
||||
Edges []*ChargingSessionEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type UsageTrend struct {
|
||||
Direction TrendDirection `json:"direction"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
PeriodOverPeriod float64 `json:"periodOverPeriod"`
|
||||
}
|
||||
|
||||
type UsageStats struct {
|
||||
DataUsage float64 `json:"dataUsage"`
|
||||
VoiceUsage float64 `json:"voiceUsage"`
|
||||
SmsUsage float64 `json:"smsUsage"`
|
||||
Cost float64 `json:"cost"`
|
||||
Period string `json:"period"`
|
||||
Trend *UsageTrend `json:"trend"`
|
||||
}
|
||||
|
||||
type CurrentSession struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
DataUsed float64 `json:"dataUsed"`
|
||||
VoiceUsed float64 `json:"voiceUsed"`
|
||||
SmsUsed float64 `json:"smsUsed"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
type TodayUsage struct {
|
||||
DataUsed float64 `json:"dataUsed"`
|
||||
VoiceUsed float64 `json:"voiceUsed"`
|
||||
SmsUsed float64 `json:"smsUsed"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
type RealTimeUsage struct {
|
||||
CurrentSession *CurrentSession `json:"currentSession"`
|
||||
TodayUsage *TodayUsage `json:"todayUsage"`
|
||||
}
|
||||
|
||||
type ESIMProvisionResult struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
ActivationCode string `json:"activationCode"`
|
||||
QRCode string `json:"qrCode"`
|
||||
}
|
||||
|
||||
type ChargingSession struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
IMSI string `json:"imsi"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime *time.Time `json:"endTime"`
|
||||
DataBytes float64 `json:"dataBytes"`
|
||||
VoiceSeconds float64 `json:"voiceSeconds"`
|
||||
SmsCount float64 `json:"smsCount"`
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
Status SessionStatus `json:"status"`
|
||||
}
|
||||
|
||||
// GraphQL configuration
|
||||
type Config struct {
|
||||
Resolvers ResolverRoot
|
||||
}
|
||||
|
||||
type ResolverRoot interface {
|
||||
Query() QueryResolver
|
||||
Mutation() MutationResolver
|
||||
Subscription() SubscriptionResolver
|
||||
}
|
||||
|
||||
type QueryResolver interface {
|
||||
Subscriber(ctx context.Context, id string) (*models.Subscriber, error)
|
||||
SubscriberByImsi(ctx context.Context, imsi string) (*models.Subscriber, error)
|
||||
Subscribers(ctx context.Context, first *int, after *string, filter *SubscriberFilter, sort *SubscriberSort) (*SubscriberConnection, error)
|
||||
SubscriberAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error)
|
||||
UsageEvents(ctx context.Context, first *int, after *string, imsi *string, filter *UsageEventFilter) (*UsageEventConnection, error)
|
||||
UsageStats(ctx context.Context, input UsageStatsInput) (*UsageStats, error)
|
||||
RealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error)
|
||||
Invoices(ctx context.Context, first *int, after *string, imsi *string, status *models.InvoiceStatus) (*InvoiceConnection, error)
|
||||
Invoice(ctx context.Context, id string) (*models.Invoice, error)
|
||||
RatingPlans(ctx context.Context) ([]*models.RatingPlan, error)
|
||||
RatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error)
|
||||
ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error)
|
||||
ChargingSession(ctx context.Context, sessionId string) (*ChargingSession, error)
|
||||
Alerts(ctx context.Context, first *int, after *string, subscriberId *int, severity *models.AlertSeverity, resolved *bool) (*AlertConnection, error)
|
||||
SystemStats(ctx context.Context) (*models.SystemStats, error)
|
||||
HealthStatus(ctx context.Context) (*models.HealthStatus, error)
|
||||
SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error)
|
||||
SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error)
|
||||
}
|
||||
|
||||
type MutationResolver interface {
|
||||
CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error)
|
||||
UpdateSubscriber(ctx context.Context, id string, input UpdateSubscriberInput) (*models.Subscriber, error)
|
||||
DeleteSubscriber(ctx context.Context, id string) (bool, error)
|
||||
TopUpBalance(ctx context.Context, imsi string, input TopUpInput) (*models.SubscriberAccount, error)
|
||||
SuspendSubscriber(ctx context.Context, id string) (*models.Subscriber, error)
|
||||
ActivateSubscriber(ctx context.Context, id string) (*models.Subscriber, error)
|
||||
TerminateSubscriber(ctx context.Context, id string) (*models.Subscriber, error)
|
||||
ProvisionESIM(ctx context.Context, imsi string) (*ESIMProvisionResult, error)
|
||||
ActivateESIM(ctx context.Context, imsi string, profileId string) (bool, error)
|
||||
DeactivateESIM(ctx context.Context, imsi string, profileId string) (bool, error)
|
||||
AddPaymentMethod(ctx context.Context, subscriberId int, input PaymentMethodInput) (*models.PaymentMethod, error)
|
||||
RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error)
|
||||
SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error)
|
||||
ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error)
|
||||
CreateAlert(ctx context.Context, input CreateAlertInput) (*models.Alert, error)
|
||||
TriggerSystemMaintenance(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type SubscriptionResolver interface {
|
||||
SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error)
|
||||
UsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error)
|
||||
AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error)
|
||||
SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error)
|
||||
}
|
||||
|
||||
func NewExecutableSchema(cfg Config) graphql.ExecutableSchema {
|
||||
return &executableSchema{
|
||||
resolvers: cfg.Resolvers,
|
||||
}
|
||||
}
|
||||
|
||||
type executableSchema struct {
|
||||
resolvers ResolverRoot
|
||||
}
|
||||
|
||||
func (e *executableSchema) Schema() *ast.Schema {
|
||||
// This would be generated by gqlgen
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *executableSchema) Complexity(ctx context.Context, typeName, fieldName string, childComplexity int, args map[string]any) (int, bool) {
|
||||
// Default complexity calculation
|
||||
return childComplexity, true
|
||||
}
|
||||
|
||||
func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
|
||||
// This would be generated by gqlgen
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *executableSchema) Extensions() map[string]any {
|
||||
return map[string]any{}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// Package graphql hosts the GraphQL resolver, split across multiple files:
|
||||
//
|
||||
// resolvers_subscriber.go - Subscriber queries
|
||||
// resolvers_usage.go - Usage events & stats
|
||||
// resolvers_billing.go - Invoices, rating plans, top-up
|
||||
// resolvers_mutations.go - Subscriber CRUD mutations
|
||||
// resolvers_esim.go - eSIM + payment-method mutations
|
||||
// resolvers_alerts.go - Alerts, system stats, maintenance
|
||||
// resolvers_subscriptions.go - Streaming subscriptions + charging
|
||||
//
|
||||
// This file holds only the root Resolver struct, constructor, helpers, and
|
||||
// the HTTP handler wiring.
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
|
||||
)
|
||||
|
||||
// ensure used imports remain referenced when helpers below are the only consumers
|
||||
var _ = context.Background
|
||||
var _ = base64.StdEncoding
|
||||
|
||||
// Resolver is the root GraphQL resolver.
|
||||
type Resolver struct {
|
||||
db *database.Database
|
||||
subscriber *services.SubscriberService
|
||||
charging *services.ChargingService
|
||||
es2Service *services.ES2Service
|
||||
}
|
||||
|
||||
// NewResolver creates a new GraphQL resolver with wired-up services.
|
||||
func NewResolver(db *database.Database, cfg *config.Config) *Resolver {
|
||||
return &Resolver{
|
||||
db: db,
|
||||
subscriber: services.NewSubscriberService(db, cfg),
|
||||
charging: services.NewChargingService(db, cfg),
|
||||
es2Service: services.NewES2Service(&cfg.ES2),
|
||||
}
|
||||
}
|
||||
|
||||
// parseID decodes a GraphQL "Subscriber:<n>" opaque ID to a uint.
|
||||
func parseID(id string) (uint, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(id, "Subscriber:%d", &n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid subscriber ID format")
|
||||
}
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
// parseCursorOffset reads the numeric offset from a decoded cursor string.
|
||||
func parseCursorOffset(cursor string) int {
|
||||
var offset int
|
||||
fmt.Sscanf(cursor, "%d", &offset)
|
||||
return offset
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to the given string.
|
||||
func strPtr(s string) *string { return new(s) }
|
||||
|
||||
// buildConnectionPageInfo builds a Relay-style PageInfo for a page of size count.
|
||||
func buildConnectionPageInfo(offset, count int, total int64) *PageInfo {
|
||||
var startCursor, endCursor *string
|
||||
if count > 0 {
|
||||
startCursor = strPtr(base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+1)))
|
||||
endCursor = strPtr(base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+count)))
|
||||
}
|
||||
return &PageInfo{
|
||||
HasNextPage: int64(offset+count) < total,
|
||||
HasPreviousPage: offset > 0,
|
||||
StartCursor: startCursor,
|
||||
EndCursor: endCursor,
|
||||
}
|
||||
}
|
||||
|
||||
// buildUsageEventFilter builds a SQL WHERE clause from optional filters.
|
||||
func buildUsageEventFilter(imsi *string, filter *UsageEventFilter) string {
|
||||
conditions := []string{}
|
||||
if imsi != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("imsi = '%s'", *imsi))
|
||||
}
|
||||
if filter != nil {
|
||||
if filter.UsageType != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("usage_type = '%s'", *filter.UsageType))
|
||||
}
|
||||
if filter.TimestampGte != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("timestamp >= '%s'", filter.TimestampGte.Format(time.RFC3339)))
|
||||
}
|
||||
if filter.TimestampLte != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("timestamp <= '%s'", filter.TimestampLte.Format(time.RFC3339)))
|
||||
}
|
||||
if filter.CostMin != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("cost >= %f", *filter.CostMin))
|
||||
}
|
||||
if filter.CostMax != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("cost <= %f", *filter.CostMax))
|
||||
}
|
||||
}
|
||||
if len(conditions) > 0 {
|
||||
return "WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Query returns the QueryResolver (itself).
|
||||
func (r *Resolver) Query() QueryResolver { return r }
|
||||
|
||||
// Mutation returns the MutationResolver (itself).
|
||||
func (r *Resolver) Mutation() MutationResolver { return r }
|
||||
|
||||
// Subscription returns the SubscriptionResolver (itself).
|
||||
func (r *Resolver) Subscription() SubscriptionResolver { return r }
|
||||
|
||||
// SetupGraphQLHandler wires the GraphQL HTTP endpoint and playground.
|
||||
func SetupGraphQLHandler(router *gin.Engine, resolver *Resolver) {
|
||||
graphqlHandler := handler.NewDefaultServer(
|
||||
NewExecutableSchema(Config{Resolvers: resolver}),
|
||||
)
|
||||
router.POST("/graphql", gin.WrapH(graphqlHandler))
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
router.GET("/graphql", gin.WrapH(playground.Handler("GraphQL Playground", "/graphql")))
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// Alerts resolves a paginated connection of alerts.
|
||||
func (r *Resolver) Alerts(ctx context.Context, first *int, after *string, subscriberId *int, severity *models.AlertSeverity, resolved *bool) (*AlertConnection, error) {
|
||||
limit := 50
|
||||
if first != nil {
|
||||
limit = *first
|
||||
}
|
||||
offset := 0
|
||||
if after != nil {
|
||||
cursor, err := base64.StdEncoding.DecodeString(*after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
offset = parseCursorOffset(string(cursor))
|
||||
}
|
||||
|
||||
alerts, total, err := r.subscriber.ListAlerts(ctx, limit, offset, subscriberId, severity, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edges := make([]*AlertEdge, len(alerts))
|
||||
for i, alert := range alerts {
|
||||
edges[i] = &AlertEdge{
|
||||
Node: alert,
|
||||
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
|
||||
}
|
||||
}
|
||||
|
||||
return &AlertConnection{
|
||||
Edges: edges,
|
||||
PageInfo: buildConnectionPageInfo(offset, len(alerts), total),
|
||||
TotalCount: int(total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SystemStats resolves system-wide stats.
|
||||
func (r *Resolver) SystemStats(ctx context.Context) (*models.SystemStats, error) {
|
||||
return r.charging.GetSystemStats(ctx)
|
||||
}
|
||||
|
||||
// HealthStatus resolves the platform health status.
|
||||
func (r *Resolver) HealthStatus(ctx context.Context) (*models.HealthStatus, error) {
|
||||
return r.charging.GetHealthStatus(ctx)
|
||||
}
|
||||
|
||||
// ResolveAlert marks an alert as resolved.
|
||||
func (r *Resolver) ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error) {
|
||||
return r.subscriber.ResolveAlert(ctx, alertId)
|
||||
}
|
||||
|
||||
// CreateAlert creates a new alert from GraphQL input.
|
||||
func (r *Resolver) CreateAlert(ctx context.Context, input CreateAlertInput) (*models.Alert, error) {
|
||||
return r.subscriber.CreateAlert(ctx, &models.CreateAlertRequest{
|
||||
Type: models.AlertType(input.Type),
|
||||
Severity: models.AlertSeverity(input.Severity),
|
||||
Message: input.Message,
|
||||
SubscriberID: input.SubscriberID,
|
||||
})
|
||||
}
|
||||
|
||||
// TriggerSystemMaintenance triggers a maintenance cycle.
|
||||
func (r *Resolver) TriggerSystemMaintenance(ctx context.Context) (bool, error) {
|
||||
return r.charging.TriggerMaintenance(ctx)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// Invoices resolves a paginated connection of invoices.
|
||||
func (r *Resolver) Invoices(ctx context.Context, first *int, after *string, imsi *string, status *models.InvoiceStatus) (*InvoiceConnection, error) {
|
||||
limit := 20
|
||||
if first != nil {
|
||||
limit = *first
|
||||
}
|
||||
offset := 0
|
||||
if after != nil {
|
||||
cursor, err := base64.StdEncoding.DecodeString(*after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
offset = parseCursorOffset(string(cursor))
|
||||
}
|
||||
|
||||
invoices, total, err := r.subscriber.ListInvoices(ctx, limit, offset, imsi, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edges := make([]*InvoiceEdge, len(invoices))
|
||||
for i, inv := range invoices {
|
||||
edges[i] = &InvoiceEdge{
|
||||
Node: inv,
|
||||
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
|
||||
}
|
||||
}
|
||||
|
||||
return &InvoiceConnection{
|
||||
Edges: edges,
|
||||
PageInfo: buildConnectionPageInfo(offset, len(invoices), total),
|
||||
TotalCount: int(total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Invoice resolves a single invoice by ID.
|
||||
func (r *Resolver) Invoice(ctx context.Context, id string) (*models.Invoice, error) {
|
||||
return r.subscriber.GetInvoice(ctx, id)
|
||||
}
|
||||
|
||||
// RatingPlans resolves all available rating plans.
|
||||
func (r *Resolver) RatingPlans(ctx context.Context) ([]*models.RatingPlan, error) {
|
||||
return r.subscriber.ListRatingPlans(ctx)
|
||||
}
|
||||
|
||||
// RatingPlan resolves a single rating plan by plan_id.
|
||||
func (r *Resolver) RatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error) {
|
||||
return r.subscriber.GetRatingPlan(ctx, planId)
|
||||
}
|
||||
|
||||
// TopUpBalance tops up a subscriber balance (mutation).
|
||||
func (r *Resolver) TopUpBalance(ctx context.Context, imsi string, input TopUpInput) (*models.SubscriberAccount, error) {
|
||||
return r.subscriber.TopUpBalance(ctx, imsi, &models.TopUpRequest{
|
||||
Amount: input.Amount,
|
||||
PaymentMethodID: input.PaymentMethodID,
|
||||
})
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// ProvisionESIM provisions an eSIM profile for the given IMSI.
|
||||
func (r *Resolver) ProvisionESIM(ctx context.Context, imsi string) (*ESIMProvisionResult, error) {
|
||||
sub, err := r.subscriber.GetSubscriberByIMSI(ctx, models.IMSI(imsi))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subscriber not found for IMSI %s: %w", imsi, err)
|
||||
}
|
||||
result, err := r.es2Service.ProvisionProfile(ctx, sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ESIMProvisionResult{
|
||||
ProfileID: result.ProfileID,
|
||||
ActivationCode: result.Activation.ActivationCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ActivateESIM activates the given eSIM profile.
|
||||
func (r *Resolver) ActivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) {
|
||||
err := r.es2Service.ActivateProfile(ctx, imsi, profileId)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// DeactivateESIM deactivates the given eSIM profile.
|
||||
func (r *Resolver) DeactivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) {
|
||||
err := r.es2Service.DeactivateProfile(ctx, imsi, profileId)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// AddPaymentMethod adds a payment method for the given subscriber.
|
||||
func (r *Resolver) AddPaymentMethod(ctx context.Context, subscriberId int, input PaymentMethodInput) (*models.PaymentMethod, error) {
|
||||
isDefault := false
|
||||
if input.IsDefault != nil {
|
||||
isDefault = *input.IsDefault
|
||||
}
|
||||
return r.subscriber.AddPaymentMethod(ctx, subscriberId, &models.AddPaymentMethodRequest{
|
||||
Type: models.PaymentMethodType(input.Type),
|
||||
Token: input.Token,
|
||||
IsDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// RemovePaymentMethod removes a payment method.
|
||||
func (r *Resolver) RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error) {
|
||||
return r.subscriber.RemovePaymentMethod(ctx, paymentMethodId)
|
||||
}
|
||||
|
||||
// SetDefaultPaymentMethod marks a payment method as default.
|
||||
func (r *Resolver) SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error) {
|
||||
return r.subscriber.SetDefaultPaymentMethod(ctx, paymentMethodId)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
|
||||
)
|
||||
|
||||
// CreateSubscriber creates a new subscriber from GraphQL input.
|
||||
func (r *Resolver) CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error) {
|
||||
req := &services.CreateSubscriberRequest{
|
||||
MSISDN: input.MSISDN,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Email: input.Email,
|
||||
PlanID: uint(input.PlanID),
|
||||
}
|
||||
if input.OrganizationID != nil {
|
||||
req.OrganizationID = *input.OrganizationID
|
||||
}
|
||||
if input.EUICCID != nil {
|
||||
req.EUICCID = *input.EUICCID
|
||||
}
|
||||
return r.subscriber.CreateSubscriber(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateSubscriber updates subscriber fields.
|
||||
func (r *Resolver) UpdateSubscriber(ctx context.Context, id string, input UpdateSubscriberInput) (*models.Subscriber, error) {
|
||||
sid, err := parseID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var planID *uint
|
||||
if input.PlanID != nil {
|
||||
p := uint(*input.PlanID)
|
||||
planID = &p
|
||||
}
|
||||
return r.subscriber.UpdateSubscriber(ctx, sid, &services.UpdateSubscriberRequest{
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Email: input.Email,
|
||||
PlanID: planID,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteSubscriber deletes a subscriber by GraphQL ID.
|
||||
func (r *Resolver) DeleteSubscriber(ctx context.Context, id string) (bool, error) {
|
||||
sid, err := parseID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return r.subscriber.DeleteSubscriber(ctx, sid)
|
||||
}
|
||||
|
||||
// SuspendSubscriber suspends and returns the refreshed subscriber.
|
||||
func (r *Resolver) SuspendSubscriber(ctx context.Context, id string) (*models.Subscriber, error) {
|
||||
sid, err := parseID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.subscriber.SuspendSubscriber(ctx, sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.subscriber.GetSubscriber(ctx, sid)
|
||||
}
|
||||
|
||||
// ActivateSubscriber activates and returns the subscriber.
|
||||
func (r *Resolver) ActivateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) {
|
||||
sid, err := parseID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.subscriber.ActivateSubscriber(ctx, sid)
|
||||
}
|
||||
|
||||
// TerminateSubscriber terminates and returns the refreshed subscriber.
|
||||
func (r *Resolver) TerminateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) {
|
||||
sid, err := parseID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.subscriber.TerminateSubscriber(ctx, sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.subscriber.GetSubscriber(ctx, sid)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
|
||||
)
|
||||
|
||||
// Subscriber resolves a single subscriber by GraphQL ID.
|
||||
func (r *Resolver) Subscriber(ctx context.Context, id string) (*models.Subscriber, error) {
|
||||
sid, err := parseID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.subscriber.GetSubscriber(ctx, sid)
|
||||
}
|
||||
|
||||
// SubscriberByImsi resolves a subscriber by IMSI.
|
||||
func (r *Resolver) SubscriberByImsi(ctx context.Context, imsi string) (*models.Subscriber, error) {
|
||||
return r.subscriber.GetSubscriberByIMSI(ctx, models.IMSI(imsi))
|
||||
}
|
||||
|
||||
// Subscribers resolves a paginated connection of subscribers.
|
||||
func (r *Resolver) Subscribers(ctx context.Context, first *int, after *string, filter *SubscriberFilter, sort *SubscriberSort) (*SubscriberConnection, error) {
|
||||
limit := 20
|
||||
if first != nil {
|
||||
limit = *first
|
||||
}
|
||||
offset := 0
|
||||
if after != nil {
|
||||
cursor, err := base64.StdEncoding.DecodeString(*after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
offset = parseCursorOffset(string(cursor))
|
||||
}
|
||||
|
||||
resp, err := r.subscriber.ListSubscribers(ctx, &services.ListSubscribersRequest{
|
||||
Page: offset/limit + 1,
|
||||
PageSize: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edges := make([]*SubscriberEdge, len(resp.Subscribers))
|
||||
for i, sub := range resp.Subscribers {
|
||||
s := sub
|
||||
edges[i] = &SubscriberEdge{
|
||||
Node: &s,
|
||||
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
|
||||
}
|
||||
}
|
||||
|
||||
return &SubscriberConnection{
|
||||
Edges: edges,
|
||||
PageInfo: buildConnectionPageInfo(offset, len(resp.Subscribers), resp.Total),
|
||||
TotalCount: int(resp.Total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubscriberAccount resolves a subscriber's account by IMSI.
|
||||
func (r *Resolver) SubscriberAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error) {
|
||||
return r.subscriber.GetAccount(ctx, imsi)
|
||||
}
|
||||
|
||||
// SearchSubscribers performs a subscriber search, capped at the given limit.
|
||||
func (r *Resolver) SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error) {
|
||||
return r.subscriber.SearchSubscribers(ctx, query, limit)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// SubscriberUpdates streams subscriber updates via a channel.
|
||||
func (r *Resolver) SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) {
|
||||
return r.subscriber.SubscribeToSubscriberUpdates(ctx, subscriberId)
|
||||
}
|
||||
|
||||
// UsageUpdates streams usage-event updates for an IMSI.
|
||||
func (r *Resolver) UsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error) {
|
||||
return r.charging.SubscribeToUsageUpdates(ctx, imsi)
|
||||
}
|
||||
|
||||
// AlertUpdates streams alert updates filtered by severity.
|
||||
func (r *Resolver) AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error) {
|
||||
severityStr := string(severity)
|
||||
return r.subscriber.SubscribeToAlertUpdates(ctx, &severityStr)
|
||||
}
|
||||
|
||||
// SystemStatsUpdates streams system stats updates.
|
||||
func (r *Resolver) SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) {
|
||||
return r.charging.SubscribeToSystemStatsUpdates(ctx)
|
||||
}
|
||||
|
||||
// ChargingSessions resolves a paginated connection of charging sessions.
|
||||
// Placeholder implementation until the charging query surface is finalized.
|
||||
func (r *Resolver) ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error) {
|
||||
return &ChargingSessionConnection{
|
||||
Edges: []*ChargingSessionEdge{},
|
||||
PageInfo: &PageInfo{},
|
||||
TotalCount: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChargingSession resolves a single charging session by ID.
|
||||
// Placeholder implementation until the charging query surface is finalized.
|
||||
func (r *Resolver) ChargingSession(ctx context.Context, sessionId string) (*ChargingSession, error) {
|
||||
return nil, fmt.Errorf("charging session %s not found", sessionId)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseID_Valid(t *testing.T) {
|
||||
id, err := parseID("Subscriber:42")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != 42 {
|
||||
t.Errorf("expected 42, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseID_Invalid(t *testing.T) {
|
||||
_, err := parseID("invalid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid ID format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseID_Zero(t *testing.T) {
|
||||
id, err := parseID("Subscriber:0")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != 0 {
|
||||
t.Errorf("expected 0, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCursor(t *testing.T) {
|
||||
cursor := encodeCursor(42)
|
||||
decoded, err := base64.StdEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error decoding cursor: %v", err)
|
||||
}
|
||||
if string(decoded) != "cursor:42" {
|
||||
t.Errorf("expected 'cursor:42', got '%s'", string(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCursor_Valid(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("cursor:100"))
|
||||
offset, err := decodeCursor(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if offset != 100 {
|
||||
t.Errorf("expected 100, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCursor_Invalid(t *testing.T) {
|
||||
_, err := decodeCursor("not-base64!!!")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCursor_BadFormat(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("bad:format"))
|
||||
_, err := decodeCursor(encoded)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad cursor format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsageFilterWhere_Empty(t *testing.T) {
|
||||
result := buildUsageFilterWhere(nil)
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string for nil filter, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsageFilterWhere_WithType(t *testing.T) {
|
||||
usageType := UsageTypeData
|
||||
filter := &UsageEventFilter{UsageType: &usageType}
|
||||
result := buildUsageFilterWhere(filter)
|
||||
if result == "" {
|
||||
t.Fatal("expected non-empty WHERE clause")
|
||||
}
|
||||
expected := "WHERE usage_type = 'DATA'"
|
||||
if result != expected {
|
||||
t.Errorf("expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsageFilterWhere_WithCostRange(t *testing.T) {
|
||||
min := 1.0
|
||||
max := 10.0
|
||||
filter := &UsageEventFilter{CostMin: &min, CostMax: &max}
|
||||
result := buildUsageFilterWhere(filter)
|
||||
if result == "" {
|
||||
t.Fatal("expected non-empty WHERE clause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRoot_Methods(t *testing.T) {
|
||||
r := &Resolver{}
|
||||
if r.Query() != r {
|
||||
t.Error("Query() should return receiver")
|
||||
}
|
||||
if r.Mutation() != r {
|
||||
t.Error("Mutation() should return receiver")
|
||||
}
|
||||
if r.Subscription() != r {
|
||||
t.Error("Subscription() should return receiver")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for testing (these should match the implementations in resolvers.go)
|
||||
|
||||
func encodeCursor(offset int) string {
|
||||
return base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "cursor:%d", offset))
|
||||
}
|
||||
|
||||
func decodeCursor(cursor string) (int, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid cursor encoding: %w", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(string(decoded), ":")
|
||||
if len(parts) != 2 || parts[0] != "cursor" {
|
||||
return 0, fmt.Errorf("invalid cursor format")
|
||||
}
|
||||
|
||||
return strconv.Atoi(parts[1])
|
||||
}
|
||||
|
||||
func buildUsageFilterWhere(filter *UsageEventFilter) string {
|
||||
if filter == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var conditions []string
|
||||
|
||||
if filter.UsageType != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("usage_type = '%s'", *filter.UsageType))
|
||||
}
|
||||
|
||||
if filter.CostMin != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("cost >= %f", *filter.CostMin))
|
||||
}
|
||||
|
||||
if filter.CostMax != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("cost <= %f", *filter.CostMax))
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
return "WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// UsageEvents resolves a paginated connection of usage events.
|
||||
func (r *Resolver) UsageEvents(ctx context.Context, first *int, after *string, imsi *string, filter *UsageEventFilter) (*UsageEventConnection, error) {
|
||||
limit := 50
|
||||
if first != nil {
|
||||
limit = *first
|
||||
}
|
||||
offset := 0
|
||||
if after != nil {
|
||||
cursor, err := base64.StdEncoding.DecodeString(*after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
offset = parseCursorOffset(string(cursor))
|
||||
}
|
||||
|
||||
where := buildUsageEventFilter(imsi, filter)
|
||||
events, total, err := r.charging.ListUsageEvents(ctx, limit, offset, where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edges := make([]*UsageEventEdge, len(events))
|
||||
for i, event := range events {
|
||||
edges[i] = &UsageEventEdge{
|
||||
Node: event,
|
||||
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
|
||||
}
|
||||
}
|
||||
|
||||
return &UsageEventConnection{
|
||||
Edges: edges,
|
||||
PageInfo: buildConnectionPageInfo(offset, len(events), total),
|
||||
TotalCount: int(total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UsageStats resolves aggregate usage stats for a subscriber and period.
|
||||
func (r *Resolver) UsageStats(ctx context.Context, input UsageStatsInput) (*UsageStats, error) {
|
||||
stats, err := r.charging.GetUsageStats(ctx, input.IMSI, string(input.Period))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UsageStats{
|
||||
DataUsage: stats.DataUsage,
|
||||
VoiceUsage: stats.VoiceUsage,
|
||||
SmsUsage: stats.SmsUsage,
|
||||
Cost: stats.Cost,
|
||||
Period: stats.Period,
|
||||
Trend: &UsageTrend{
|
||||
Direction: TrendDirection(stats.Trend.Direction),
|
||||
Percentage: stats.Trend.Percentage,
|
||||
PeriodOverPeriod: stats.Trend.PeriodOverPeriod,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RealTimeUsage resolves real-time usage for a subscriber by IMSI.
|
||||
func (r *Resolver) RealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error) {
|
||||
usage, err := r.charging.GetRealTimeUsage(ctx, imsi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cs *CurrentSession
|
||||
if usage.CurrentSession != nil {
|
||||
cs = &CurrentSession{
|
||||
SessionID: usage.CurrentSession.SessionID,
|
||||
StartTime: usage.CurrentSession.StartTime,
|
||||
DataUsed: usage.CurrentSession.DataUsed,
|
||||
VoiceUsed: usage.CurrentSession.VoiceUsed,
|
||||
SmsUsed: usage.CurrentSession.SmsUsed,
|
||||
Cost: usage.CurrentSession.Cost,
|
||||
}
|
||||
}
|
||||
|
||||
return &RealTimeUsage{
|
||||
CurrentSession: cs,
|
||||
TodayUsage: &TodayUsage{
|
||||
DataUsed: usage.TodayUsage.DataUsed,
|
||||
VoiceUsed: usage.TodayUsage.VoiceUsed,
|
||||
SmsUsed: usage.TodayUsage.SmsUsed,
|
||||
Cost: usage.TodayUsage.Cost,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchUsageEvents searches usage events, capped at limit.
|
||||
func (r *Resolver) SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error) {
|
||||
return r.charging.SearchUsageEvents(ctx, query, limit)
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
scalar DateTime
|
||||
scalar JSON
|
||||
|
||||
enum SubscriberStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
SUSPENDED
|
||||
TERMINATED
|
||||
PROVISIONING
|
||||
}
|
||||
|
||||
enum ProfileStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
DOWNLOADING
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum UsageType {
|
||||
DATA
|
||||
VOICE
|
||||
SMS
|
||||
}
|
||||
|
||||
enum SessionStatus {
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
TERMINATED
|
||||
}
|
||||
|
||||
enum PaymentMethodType {
|
||||
CREDIT_CARD
|
||||
BANK_ACCOUNT
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
DRAFT
|
||||
PENDING
|
||||
PAID
|
||||
OVERDUE
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum AlertType {
|
||||
LOW_BALANCE
|
||||
HIGH_USAGE
|
||||
PAYMENT_FAILED
|
||||
SYSTEM_ERROR
|
||||
}
|
||||
|
||||
enum AlertSeverity {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
type Subscriber {
|
||||
id: ID!
|
||||
imsi: String!
|
||||
msisdn: String!
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
email: String!
|
||||
organizationId: String
|
||||
planId: Int!
|
||||
euiccId: String
|
||||
profileId: String
|
||||
profileStatus: ProfileStatus!
|
||||
status: SubscriberStatus!
|
||||
balance: Float!
|
||||
dataLimit: Float!
|
||||
dataUsed: Float!
|
||||
voiceLimit: Float!
|
||||
voiceUsed: Float!
|
||||
smsLimit: Float!
|
||||
smsUsed: Float!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
|
||||
account: SubscriberAccount!
|
||||
usageEvents(first: Int, after: String, filter: UsageEventFilter): UsageEventConnection!
|
||||
invoices(first: Int, after: String, status: InvoiceStatus): InvoiceConnection!
|
||||
alerts(first: Int, after: String, severity: AlertSeverity): AlertConnection!
|
||||
ratingPlan: RatingPlan!
|
||||
}
|
||||
|
||||
type SubscriberAccount {
|
||||
imsi: String!
|
||||
balance: Float!
|
||||
dataLimit: Float!
|
||||
dataUsed: Float!
|
||||
voiceLimit: Float!
|
||||
voiceUsed: Float!
|
||||
smsLimit: Float!
|
||||
smsUsed: Float!
|
||||
status: SubscriberStatus!
|
||||
lastUpdated: DateTime!
|
||||
}
|
||||
|
||||
type UsageEvent {
|
||||
id: ID!
|
||||
imsi: String!
|
||||
sessionId: String!
|
||||
usageType: UsageType!
|
||||
volume: Float!
|
||||
timestamp: DateTime!
|
||||
rate: Float!
|
||||
cost: Float!
|
||||
|
||||
subscriber: Subscriber!
|
||||
}
|
||||
|
||||
type RatingPlan {
|
||||
planId: String!
|
||||
name: String!
|
||||
dataRate: Float!
|
||||
voiceRate: Float!
|
||||
smsRate: Float!
|
||||
monthlyFee: Float!
|
||||
dataLimit: Float!
|
||||
voiceLimit: Float!
|
||||
smsLimit: Float!
|
||||
|
||||
subscribers(first: Int, after: String, status: SubscriberStatus): SubscriberConnection!
|
||||
}
|
||||
|
||||
type ChargingSession {
|
||||
sessionId: String!
|
||||
imsi: String!
|
||||
startTime: DateTime!
|
||||
endTime: DateTime
|
||||
dataBytes: Float!
|
||||
voiceSeconds: Float!
|
||||
smsCount: Float!
|
||||
totalCost: Float!
|
||||
status: SessionStatus!
|
||||
|
||||
subscriber: Subscriber!
|
||||
usageEvents(first: Int, after: String): UsageEventConnection!
|
||||
}
|
||||
|
||||
type PaymentMethod {
|
||||
id: String!
|
||||
type: PaymentMethodType!
|
||||
last4: String!
|
||||
expiryMonth: Int!
|
||||
expiryYear: Int!
|
||||
brand: String!
|
||||
isDefault: Boolean!
|
||||
}
|
||||
|
||||
type Invoice {
|
||||
id: String!
|
||||
subscriberId: Int!
|
||||
amount: Float!
|
||||
currency: String!
|
||||
status: InvoiceStatus!
|
||||
dueDate: DateTime!
|
||||
createdAt: DateTime!
|
||||
lineItems: [InvoiceLineItem!]!
|
||||
|
||||
subscriber: Subscriber!
|
||||
}
|
||||
|
||||
type InvoiceLineItem {
|
||||
description: String!
|
||||
quantity: Int!
|
||||
unitPrice: Float!
|
||||
amount: Float!
|
||||
}
|
||||
|
||||
type Alert {
|
||||
id: String!
|
||||
type: AlertType!
|
||||
severity: AlertSeverity!
|
||||
message: String!
|
||||
subscriberId: Int
|
||||
timestamp: DateTime!
|
||||
resolved: Boolean!
|
||||
|
||||
subscriber: Subscriber
|
||||
}
|
||||
|
||||
type SystemStats {
|
||||
activeSessions: Int!
|
||||
totalAccounts: Int!
|
||||
blockedUsers: Int!
|
||||
lowBalanceAlerts: Int!
|
||||
uptime: Float!
|
||||
}
|
||||
|
||||
type HealthStatus {
|
||||
redisConnected: Boolean!
|
||||
activeSync: Boolean!
|
||||
lastSync: DateTime!
|
||||
memoryUsage: Float!
|
||||
}
|
||||
|
||||
type SubscriberConnection {
|
||||
edges: [SubscriberEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type SubscriberEdge {
|
||||
node: Subscriber!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type UsageEventConnection {
|
||||
edges: [UsageEventEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type UsageEventEdge {
|
||||
node: UsageEvent!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type InvoiceConnection {
|
||||
edges: [InvoiceEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type InvoiceEdge {
|
||||
node: Invoice!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type AlertConnection {
|
||||
edges: [AlertEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type AlertEdge {
|
||||
node: Alert!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type PageInfo {
|
||||
hasNextPage: Boolean!
|
||||
hasPreviousPage: Boolean!
|
||||
startCursor: String
|
||||
endCursor: String
|
||||
}
|
||||
|
||||
type SubscriberFilter {
|
||||
status: SubscriberStatus
|
||||
organizationId: String
|
||||
planId: Int
|
||||
search: String
|
||||
balanceMin: Float
|
||||
balanceMax: Float
|
||||
dataUsageMin: Float
|
||||
dataUsageMax: Float
|
||||
}
|
||||
|
||||
type UsageEventFilter {
|
||||
usageType: UsageType
|
||||
timestampGte: DateTime
|
||||
timestampLte: DateTime
|
||||
costMin: Float
|
||||
costMax: Float
|
||||
}
|
||||
|
||||
type CreateSubscriberInput {
|
||||
msisdn: String!
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
email: String!
|
||||
organizationId: String
|
||||
planId: Int!
|
||||
euiccId: String
|
||||
}
|
||||
|
||||
type UpdateSubscriberInput {
|
||||
firstName: String
|
||||
lastName: String
|
||||
email: String
|
||||
organizationId: String
|
||||
planId: Int
|
||||
}
|
||||
|
||||
type TopUpInput {
|
||||
amount: Float!
|
||||
paymentMethodId: String
|
||||
}
|
||||
|
||||
type UsageStatsInput {
|
||||
imsi: String!
|
||||
period: UsagePeriod!
|
||||
}
|
||||
|
||||
enum UsagePeriod {
|
||||
DAILY
|
||||
WEEKLY
|
||||
MONTHLY
|
||||
YEARLY
|
||||
}
|
||||
|
||||
type Query {
|
||||
subscriber(id: ID!): Subscriber
|
||||
subscriberByImsi(imsi: String!): Subscriber
|
||||
subscribers(
|
||||
first: Int
|
||||
after: String
|
||||
filter: SubscriberFilter
|
||||
sort: SubscriberSort
|
||||
): SubscriberConnection!
|
||||
|
||||
subscriberAccount(imsi: String!): SubscriberAccount
|
||||
|
||||
usageEvents(
|
||||
first: Int
|
||||
after: String
|
||||
imsi: String
|
||||
filter: UsageEventFilter
|
||||
): UsageEventConnection!
|
||||
usageStats(input: UsageStatsInput!): UsageStats!
|
||||
realtimeUsage(imsi: String!): RealTimeUsage!
|
||||
|
||||
invoices(
|
||||
first: Int
|
||||
after: String
|
||||
imsi: String
|
||||
status: InvoiceStatus
|
||||
): InvoiceConnection!
|
||||
invoice(id: String!): Invoice
|
||||
|
||||
ratingPlans: [RatingPlan!]!
|
||||
ratingPlan(planId: String!): RatingPlan
|
||||
|
||||
chargingSessions(
|
||||
first: Int
|
||||
after: String
|
||||
imsi: String
|
||||
status: SessionStatus
|
||||
): ChargingSessionConnection!
|
||||
chargingSession(sessionId: String!): ChargingSession
|
||||
|
||||
alerts(
|
||||
first: Int
|
||||
after: String
|
||||
subscriberId: Int
|
||||
severity: AlertSeverity
|
||||
resolved: Boolean
|
||||
): AlertConnection!
|
||||
|
||||
systemStats: SystemStats!
|
||||
healthStatus: HealthStatus!
|
||||
|
||||
searchSubscribers(query: String!, limit: Int): [Subscriber!]!
|
||||
searchUsageEvents(query: String!, limit: Int): [UsageEvent!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createSubscriber(input: CreateSubscriberInput!): Subscriber!
|
||||
updateSubscriber(id: ID!, input: UpdateSubscriberInput!): Subscriber!
|
||||
deleteSubscriber(id: ID!): Boolean!
|
||||
|
||||
topUpBalance(imsi: String!, input: TopUpInput!): SubscriberAccount!
|
||||
|
||||
suspendSubscriber(id: ID!): Subscriber!
|
||||
activateSubscriber(id: ID!): Subscriber!
|
||||
terminateSubscriber(id: ID!): Subscriber!
|
||||
|
||||
provisionESIM(imsi: String!): ESIMProvisionResult!
|
||||
activateESIM(imsi: String!, profileId: String!): Boolean!
|
||||
deactivateESIM(imsi: String!, profileId: String!): Boolean!
|
||||
|
||||
addPaymentMethod(subscriberId: Int!, input: PaymentMethodInput!): PaymentMethod!
|
||||
removePaymentMethod(paymentMethodId: String!): Boolean!
|
||||
setDefaultPaymentMethod(paymentMethodId: String!): PaymentMethod!
|
||||
addPaymentMethod(subscriberId: Int!, input: PaymentMethodInput!): PaymentMethod!
|
||||
removePaymentMethod(paymentMethodId: String!): Boolean!
|
||||
setDefaultPaymentMethod(paymentMethodId: String!): PaymentMethod!
|
||||
|
||||
resolveAlert(alertId: String!): Alert!
|
||||
createAlert(input: CreateAlertInput!): Alert!
|
||||
|
||||
triggerSystemMaintenance: Boolean!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
# Real-time subscriptions
|
||||
subscriberUpdates(subscriberId: ID!): Subscriber!
|
||||
usageUpdates(imsi: String!): UsageEvent!
|
||||
alertUpdates(severity: AlertSeverity): Alert!
|
||||
systemStatsUpdates: SystemStats!
|
||||
}
|
||||
|
||||
type UsageStats {
|
||||
dataUsage: Float!
|
||||
voiceUsage: Float!
|
||||
smsUsage: Float!
|
||||
cost: Float!
|
||||
period: String!
|
||||
trend: UsageTrend!
|
||||
}
|
||||
|
||||
type UsageTrend {
|
||||
direction: TrendDirection!
|
||||
percentage: Float!
|
||||
periodOverPeriod: Float!
|
||||
}
|
||||
|
||||
enum TrendDirection {
|
||||
UP
|
||||
DOWN
|
||||
STABLE
|
||||
}
|
||||
|
||||
type RealTimeUsage {
|
||||
currentSession: CurrentSession
|
||||
todayUsage: TodayUsage!
|
||||
}
|
||||
|
||||
type CurrentSession {
|
||||
sessionId: String!
|
||||
startTime: DateTime!
|
||||
dataUsed: Float!
|
||||
voiceUsed: Float!
|
||||
smsUsed: Float!
|
||||
cost: Float!
|
||||
}
|
||||
|
||||
type TodayUsage {
|
||||
dataUsed: Float!
|
||||
voiceUsed: Float!
|
||||
smsUsed: Float!
|
||||
cost: Float!
|
||||
}
|
||||
|
||||
type ESIMProvisionResult {
|
||||
profileId: String!
|
||||
activationCode: String!
|
||||
qrCode: String!
|
||||
}
|
||||
|
||||
type PaymentMethodInput {
|
||||
type: PaymentMethodType!
|
||||
token: String!
|
||||
isDefault: Boolean
|
||||
}
|
||||
|
||||
type CreateAlertInput {
|
||||
type: AlertType!
|
||||
severity: AlertSeverity!
|
||||
message: String!
|
||||
subscriberId: Int
|
||||
}
|
||||
|
||||
type ChargingSessionConnection {
|
||||
edges: [ChargingSessionEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type ChargingSessionEdge {
|
||||
node: ChargingSession!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
enum SubscriberSort {
|
||||
CREATED_AT_ASC
|
||||
CREATED_AT_DESC
|
||||
UPDATED_AT_ASC
|
||||
UPDATED_AT_DESC
|
||||
FIRST_NAME_ASC
|
||||
FIRST_NAME_DESC
|
||||
LAST_NAME_ASC
|
||||
LAST_NAME_DESC
|
||||
EMAIL_ASC
|
||||
EMAIL_DESC
|
||||
BALANCE_ASC
|
||||
BALANCE_DESC
|
||||
DATA_USED_ASC
|
||||
DATA_USED_DESC
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -68,7 +69,11 @@ func setupTest(t *testing.T) *TestSetup {
|
||||
|
||||
for _, user := range testUsers {
|
||||
// Hash the password properly for testing
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||
testPassword := os.Getenv("TEST_PASSWORD")
|
||||
if testPassword == "" {
|
||||
testPassword = "default_test_password_change_me"
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
user.Password = string(hashedPassword)
|
||||
err = db.Create(user).Error
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
|
||||
)
|
||||
@@ -82,6 +83,14 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize user input to prevent XSS
|
||||
policy := bluemonday.UGCPolicy()
|
||||
req.Username = policy.Sanitize(req.Username)
|
||||
req.Email = policy.Sanitize(req.Email)
|
||||
req.FirstName = policy.Sanitize(req.FirstName)
|
||||
req.LastName = policy.Sanitize(req.LastName)
|
||||
req.Role = policy.Sanitize(req.Role)
|
||||
|
||||
user, err := h.authService.Register(req.Username, req.Email, req.Password, req.FirstName, req.LastName, req.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
|
||||
)
|
||||
@@ -43,6 +44,21 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize user input to prevent XSS
|
||||
policy := bluemonday.UGCPolicy()
|
||||
if req.FirstName != "" {
|
||||
req.FirstName = policy.Sanitize(req.FirstName)
|
||||
}
|
||||
if req.LastName != "" {
|
||||
req.LastName = policy.Sanitize(req.LastName)
|
||||
}
|
||||
if req.Email != "" {
|
||||
req.Email = policy.Sanitize(req.Email)
|
||||
}
|
||||
if req.MSISDN != "" {
|
||||
req.MSISDN = policy.Sanitize(req.MSISDN)
|
||||
}
|
||||
|
||||
subscriber, err := h.subscriberService.CreateSubscriber(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||
@@ -52,7 +68,20 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, subscriber)
|
||||
// Add HATEOAS links
|
||||
baseURL := c.Request.Host
|
||||
if c.Request.TLS != nil {
|
||||
baseURL = "https://" + baseURL
|
||||
} else {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
subscriberWithLinks := models.SubscriberWithLinks{
|
||||
Subscriber: *subscriber,
|
||||
Links: models.NewSubscriberLinks(baseURL, subscriber),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, subscriberWithLinks)
|
||||
}
|
||||
|
||||
// GetSubscriber retrieves a subscriber by ID
|
||||
@@ -85,7 +114,20 @@ func (h *SubscriberHandler) GetSubscriber(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, subscriber)
|
||||
// Add HATEOAS links
|
||||
baseURL := c.Request.Host
|
||||
if c.Request.TLS != nil {
|
||||
baseURL = "https://" + baseURL
|
||||
} else {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
subscriberWithLinks := models.SubscriberWithLinks{
|
||||
Subscriber: *subscriber,
|
||||
Links: models.NewSubscriberLinks(baseURL, subscriber),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, subscriberWithLinks)
|
||||
}
|
||||
|
||||
// GetSubscriberByIMSI retrieves a subscriber by IMSI
|
||||
@@ -146,6 +188,21 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize user input to prevent XSS
|
||||
policy := bluemonday.UGCPolicy()
|
||||
if req.FirstName != nil && *req.FirstName != "" {
|
||||
sanitized := policy.Sanitize(*req.FirstName)
|
||||
req.FirstName = &sanitized
|
||||
}
|
||||
if req.LastName != nil && *req.LastName != "" {
|
||||
sanitized := policy.Sanitize(*req.LastName)
|
||||
req.LastName = &sanitized
|
||||
}
|
||||
if req.Email != nil && *req.Email != "" {
|
||||
sanitized := policy.Sanitize(*req.Email)
|
||||
req.Email = &sanitized
|
||||
}
|
||||
|
||||
subscriber, err := h.subscriberService.UpdateSubscriber(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||
@@ -172,16 +229,15 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) {
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/subscribers [get]
|
||||
func (h *SubscriberHandler) ListSubscribers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
req := &services.ListSubscribersRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Cursor: c.Query("cursor"),
|
||||
Limit: limit,
|
||||
Status: models.SubscriberStatus(c.Query("status")),
|
||||
OrganizationID: c.Query("organization_id"),
|
||||
Search: c.Query("search"),
|
||||
@@ -196,7 +252,30 @@ func (h *SubscriberHandler) ListSubscribers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
// Add HATEOAS links
|
||||
baseURL := c.Request.Host
|
||||
if c.Request.TLS != nil {
|
||||
baseURL = "https://" + baseURL
|
||||
} else {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
subscribersWithLinks := make([]models.SubscriberWithLinks, len(response.Subscribers))
|
||||
for i, subscriber := range response.Subscribers {
|
||||
subscribersWithLinks[i] = models.SubscriberWithLinks{
|
||||
Subscriber: subscriber,
|
||||
Links: models.NewSubscriberLinks(baseURL, &subscriber),
|
||||
}
|
||||
}
|
||||
|
||||
listResponse := models.SubscriberListWithLinks{
|
||||
Subscribers: subscribersWithLinks,
|
||||
NextCursor: response.NextCursor,
|
||||
HasMore: response.HasMore,
|
||||
Links: models.NewSubscriberListLinks(baseURL, response.NextCursor, req.Limit, response.HasMore),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, listResponse)
|
||||
}
|
||||
|
||||
// SuspendSubscriber suspends a subscriber
|
||||
|
||||
@@ -260,6 +260,9 @@ func SecurityHeadersMiddleware() gin.HandlerFunc {
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Link represents a HATEOAS link
|
||||
type Link struct {
|
||||
Href string `json:"href"`
|
||||
Rel string `json:"rel"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Links represents a collection of HATEOAS links
|
||||
type Links map[string]Link
|
||||
|
||||
// SubscriberWithLinks represents a subscriber with HATEOAS links
|
||||
type SubscriberWithLinks struct {
|
||||
Subscriber
|
||||
Links Links `json:"_links"`
|
||||
}
|
||||
|
||||
// SubscriberListWithLinks represents a subscriber list with HATEOAS links
|
||||
type SubscriberListWithLinks struct {
|
||||
Subscribers []SubscriberWithLinks `json:"subscribers"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Links Links `json:"_links"`
|
||||
}
|
||||
|
||||
// NewSubscriberLinks creates HATEOAS links for a subscriber
|
||||
func NewSubscriberLinks(baseURL string, subscriber *Subscriber) Links {
|
||||
return Links{
|
||||
"self": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID),
|
||||
Rel: "self",
|
||||
},
|
||||
"update": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID),
|
||||
Rel: "update",
|
||||
Type: "PUT",
|
||||
},
|
||||
"delete": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID),
|
||||
Rel: "delete",
|
||||
Type: "DELETE",
|
||||
},
|
||||
"billing": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers/%d/billing", baseURL, subscriber.ID),
|
||||
Rel: "billing",
|
||||
},
|
||||
"sessions": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers/%d/sessions", baseURL, subscriber.ID),
|
||||
Rel: "sessions",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewSubscriberListLinks creates HATEOAS links for subscriber list
|
||||
func NewSubscriberListLinks(baseURL string, cursor string, limit int, hasMore bool) Links {
|
||||
links := Links{
|
||||
"self": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers?limit=%d", baseURL, limit),
|
||||
Rel: "self",
|
||||
},
|
||||
"create": {
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers", baseURL),
|
||||
Rel: "create",
|
||||
Type: "POST",
|
||||
},
|
||||
}
|
||||
|
||||
if cursor != "" {
|
||||
links["next"] = Link{
|
||||
Href: fmt.Sprintf("%s/api/v1/subscribers?cursor=%s&limit=%d", baseURL, cursor, limit),
|
||||
Rel: "next",
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (mq *MessageQueue) DeclareQueue(name string) error {
|
||||
}
|
||||
|
||||
// Publish publishes a message to a queue
|
||||
func (mq *MessageQueue) Publish(queue string, msg Message) error {
|
||||
func (mq *MessageQueue) Publish(ctx context.Context, queue string, msg Message) error {
|
||||
msg.Timestamp = time.Now()
|
||||
|
||||
body, err := json.Marshal(msg)
|
||||
@@ -90,11 +90,12 @@ func (mq *MessageQueue) Publish(queue string, msg Message) error {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// Use provided context with timeout
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = mq.channel.PublishWithContext(
|
||||
ctx,
|
||||
timeoutCtx,
|
||||
"", // exchange
|
||||
queue, // routing key
|
||||
false, // mandatory
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
)
|
||||
@@ -28,6 +32,32 @@ func NewChargingEngineClient(cfg *config.ChargingEngineConfig) *ChargingEngineCl
|
||||
}
|
||||
}
|
||||
|
||||
// validateURL prevents SSRF by checking if URL is safe to access
|
||||
func (c *ChargingEngineClient) validateURL(urlStr string) error {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
|
||||
// Block private IPs, loopback, and link-local addresses
|
||||
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
|
||||
return errors.New("access to private IP addresses is not allowed")
|
||||
}
|
||||
|
||||
// Whitelist allowed domains (charging-engine should be internal)
|
||||
allowedDomains := []string{"localhost", "127.0.0.1", "charging-engine", "charging-engine.default.svc.cluster.local"}
|
||||
allowed := slices.Contains(allowedDomains, host)
|
||||
|
||||
if !allowed {
|
||||
return fmt.Errorf("domain %s is not in allowed list", host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAPIKeyHeader adds the X-API-Key header to a request if an API key is configured
|
||||
func (c *ChargingEngineClient) setAPIKeyHeader(req *http.Request) {
|
||||
if c.apiKey != "" {
|
||||
@@ -70,6 +100,12 @@ func (c *ChargingEngineClient) CheckCredit(ctx context.Context, ip string, bytes
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/check", c.baseURL, ip)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return nil, fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
@@ -102,6 +138,12 @@ func (c *ChargingEngineClient) DeductCredit(ctx context.Context, ip string, byte
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/deduct", c.baseURL, ip)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
@@ -129,6 +171,12 @@ func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesTo
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/add", c.baseURL, ip)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
@@ -151,6 +199,12 @@ func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesTo
|
||||
// GetBalance gets the current credit balance for a subscriber
|
||||
func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*BalanceResponse, error) {
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/balance", c.baseURL, ip)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return nil, fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
@@ -176,7 +230,13 @@ func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*Bala
|
||||
|
||||
// HealthCheck checks if the Rust charging engine is healthy
|
||||
func (c *ChargingEngineClient) HealthCheck(ctx context.Context) (*EngineHealthResponse, error) {
|
||||
url := fmt.Sprintf("%s/health", c.baseURL)
|
||||
url := fmt.Sprintf("%s/v1/health", c.baseURL)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return nil, fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// InvoiceService handles invoice generation and management
|
||||
@@ -100,8 +101,9 @@ func (s *InvoiceService) SendInvoice(ctx context.Context, invoiceID uint) error
|
||||
return fmt.Errorf("failed to update invoice status: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Send email notification
|
||||
// This would integrate with an email service
|
||||
// Email notification would be sent here via notification service
|
||||
// This is a placeholder for future integration with email service
|
||||
logrus.WithField("invoice_id", invoice.ID).Info("Invoice created - email notification pending")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,17 +53,16 @@ type UpdateSubscriberRequest struct {
|
||||
|
||||
// ListSubscribersRequest is the query-filter payload for listing subscribers.
|
||||
type ListSubscribersRequest struct {
|
||||
Page int `json:"page" query:"page"`
|
||||
PageSize int `json:"page_size" query:"page_size"`
|
||||
Cursor string `json:"cursor" query:"cursor"`
|
||||
Limit int `json:"limit" query:"limit"`
|
||||
Status models.SubscriberStatus `json:"status" query:"status"`
|
||||
OrganizationID string `json:"organization_id" query:"organization_id"`
|
||||
Search string `json:"search" query:"search"`
|
||||
}
|
||||
|
||||
// ListSubscribersResponse is the paginated subscriber list result.
|
||||
// ListSubscribersResponse is the cursor-paginated subscriber list result.
|
||||
type ListSubscribersResponse struct {
|
||||
Subscribers []models.Subscriber `json:"subscribers"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// CreateSubscriber creates a new subscriber with allocated IMSI.
|
||||
@@ -42,9 +42,10 @@ func (s *SubscriberService) CreateSubscriber(ctx context.Context, req *CreateSub
|
||||
if req.EUICCID != "" {
|
||||
subscriber.EUICCID = req.EUICCID
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
// Use derived context for goroutine to respect parent cancellation
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
if err := s.provisionESIMProfile(bgCtx, subscriber.ID); err != nil {
|
||||
fmt.Printf("Failed to provision eSIM profile for subscriber %d: %v\n", subscriber.ID, err)
|
||||
logrus.WithError(err).WithField("subscriber_id", subscriber.ID).Warn("Failed to provision eSIM profile")
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
@@ -104,24 +105,25 @@ func (s *SubscriberService) UpdateSubscriber(ctx context.Context, id uint, req *
|
||||
return subscriber, nil
|
||||
}
|
||||
|
||||
// ListSubscribers lists subscribers with pagination and filtering.
|
||||
// ListSubscribers lists subscribers with cursor-based pagination and filtering.
|
||||
func (s *SubscriberService) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) (*ListSubscribersResponse, error) {
|
||||
dbReq := &database.ListSubscribersRequest{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Status: string(req.Status),
|
||||
OrganizationID: req.OrganizationID,
|
||||
Search: req.Search,
|
||||
// Set default limit if not provided
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
subscribers, total, err := s.db.ListSubscribers(ctx, dbReq)
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
subscribers, nextCursor, hasMore, err := s.db.ListSubscribersCursor(ctx, req.Cursor, limit, string(req.Status), req.OrganizationID, req.Search)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list subscribers: %w", err)
|
||||
}
|
||||
|
||||
return &ListSubscribersResponse{
|
||||
Subscribers: subscribers,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
NextCursor: nextCursor,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// provisionESIMProfile provisions an eSIM profile for the subscriber
|
||||
@@ -47,7 +48,7 @@ func (s *SubscriberService) provisionESIMProfile(ctx context.Context, subscriber
|
||||
|
||||
// Notify AMF of new subscriber
|
||||
if err := s.amfClient.NotifySubscriberUpdate(ctx, subscriber.IMSI, models.SubscriberStatusActive); err != nil {
|
||||
fmt.Printf("Failed to notify AMF of subscriber activation: %v\n", err)
|
||||
logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to notify AMF of subscriber activation")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -81,12 +82,12 @@ func (s *SubscriberService) deactivateESIMProfile(ctx context.Context, subscribe
|
||||
|
||||
// Terminate all sessions
|
||||
if err := s.terminateSubscriberSessions(ctx, subscriber.IMSI); err != nil {
|
||||
fmt.Printf("Failed to terminate sessions: %v\n", err)
|
||||
logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to terminate sessions")
|
||||
}
|
||||
|
||||
// Notify AMF of subscriber deactivation
|
||||
if err := s.amfClient.NotifySubscriberUpdate(ctx, subscriber.IMSI, models.SubscriberStatusInactive); err != nil {
|
||||
fmt.Printf("Failed to notify AMF of subscriber deactivation: %v\n", err)
|
||||
logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to notify AMF of subscriber deactivation")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SuspendSubscriber suspends a subscriber and terminates their sessions.
|
||||
@@ -124,7 +125,7 @@ func (s *SubscriberService) terminateSubscriberSessions(ctx context.Context, ims
|
||||
}
|
||||
|
||||
if err := s.amfClient.TerminateSession(ctx, imsi, "Subscriber terminated"); err != nil {
|
||||
fmt.Printf("Failed to notify AMF for session termination: %v\n", err)
|
||||
logrus.WithError(err).WithField("imsi", imsi).Warn("Failed to notify AMF for session termination")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,13 +21,18 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.1.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -52,6 +57,7 @@ require (
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
|
||||
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -13,12 +16,20 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
||||
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 h1:ldKXSIxdVtXVCP4JW0p4ErvnPhISjbb5QSIqMnBa3ak=
|
||||
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
|
||||
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -31,9 +42,18 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
|
||||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -48,14 +68,19 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -64,11 +89,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -85,6 +113,7 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -99,6 +128,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -109,8 +139,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
|
||||
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -121,10 +154,13 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
@@ -132,8 +168,13 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package es2
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config"
|
||||
@@ -38,6 +42,32 @@ func NewES2Client(cfg *config.ES2Config) *ES2Client {
|
||||
}
|
||||
}
|
||||
|
||||
// validateURL prevents SSRF by checking if URL is safe to access
|
||||
func (c *ES2Client) validateURL(urlStr string) error {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
|
||||
// Block private IPs, loopback, and link-local addresses
|
||||
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
|
||||
return errors.New("access to private IP addresses is not allowed")
|
||||
}
|
||||
|
||||
// Whitelist allowed ES2/SMDP domains (should be production SM-DP+ servers)
|
||||
allowedDomains := []string{"smdp.example.com", "es2plus.example.com", "smdp.carrier.com"}
|
||||
allowed := slices.Contains(allowedDomains, host)
|
||||
|
||||
if !allowed {
|
||||
return fmt.Errorf("domain %s is not in allowed ES2 domains list", host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retryableError checks if an error is retryable
|
||||
func (c *ES2Client) retryableError(err error, statusCode int) bool {
|
||||
if err != nil {
|
||||
@@ -96,6 +126,11 @@ func (c *ES2Client) executeWithRetry(ctx context.Context, req *http.Request) (*h
|
||||
func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileRequest) (*DownloadProfileResponse, error) {
|
||||
url := fmt.Sprintf("%s/es2plus/downloadProfile", c.baseURL)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return nil, fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
@@ -129,6 +164,11 @@ func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileReq
|
||||
func (c *ES2Client) GetProfileStatus(ctx context.Context, req *GetProfileStatusRequest) (*GetProfileStatusResponse, error) {
|
||||
url := fmt.Sprintf("%s/es2plus/getProfileStatus", c.baseURL)
|
||||
|
||||
// Validate URL to prevent SSRF
|
||||
if err := c.validateURL(url); err != nil {
|
||||
return nil, fmt.Errorf("SSRF protection: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -125,9 +126,7 @@ func (mq *MessageQueue) PublishProfileEvent(eventType string, profileID string,
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range payload {
|
||||
msg.Payload[k] = v
|
||||
}
|
||||
maps.Copy(msg.Payload, payload)
|
||||
|
||||
return mq.Publish("profile-events", msg)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -58,8 +59,13 @@ func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) {
|
||||
}
|
||||
|
||||
// runMigrations executes Goose migrations using the goose binary
|
||||
// Changes to the repo root directory to find the migrations folder
|
||||
func runMigrations(dsn string) error {
|
||||
// Get the repo root directory (go up from apps/carrier-connector)
|
||||
repoRoot := filepath.Join("..", "..")
|
||||
|
||||
cmd := exec.Command("goose", "postgres", dsn, "up", "migrations")
|
||||
cmd.Dir = repoRoot
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose migration failed: %w, output: %s", err, string(output))
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository"
|
||||
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/webhook"
|
||||
"github.com/rs/zerolog"
|
||||
csrf "github.com/utrack/gin-csrf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -35,6 +36,17 @@ func main() {
|
||||
port := handler.GetEnv("PORT", "8080")
|
||||
|
||||
router := gin.Default()
|
||||
|
||||
// Add CSRF protection middleware
|
||||
csrfSecret := os.Getenv("CSRF_SECRET")
|
||||
if csrfSecret == "" {
|
||||
csrfSecret = "change-me-in-production-csrf-secret-32-chars-min"
|
||||
handler.Logger.Warn().Msg("Using default CSRF secret, set CSRF_SECRET environment variable")
|
||||
}
|
||||
router.Use(csrf.Middleware(csrf.Options{
|
||||
Secret: csrfSecret,
|
||||
}))
|
||||
|
||||
router.Use(gin.LoggerWithFormatter(func(p gin.LogFormatterParams) string {
|
||||
return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
|
||||
p.ClientIP, p.TimeStamp.Format(time.RFC1123),
|
||||
|
||||
@@ -25,6 +25,7 @@ serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = "2.0.18"
|
||||
dotenv = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
jsonwebtoken = "10.3.0"
|
||||
|
||||
@@ -8,12 +8,41 @@ use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
/// JWT claims for authentication tokens
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::auth::Claims;
|
||||
///
|
||||
/// let claims = Claims {
|
||||
/// sub: "user123".to_string(),
|
||||
/// exp: 1234567890,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
/// Authentication configuration for JWT and API keys
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use charging_engine::auth::AuthConfig;
|
||||
/// use std::env;
|
||||
///
|
||||
/// # fn main() -> Result<(), String> {
|
||||
/// env::set_var("JWT_SECRET", "a_very_secure_secret_at_least_32_chars");
|
||||
/// env::set_var("API_KEYS", "key1,key2,key3");
|
||||
///
|
||||
/// let config = AuthConfig::from_env()?;
|
||||
/// assert!(config.validate_api_key("key1"));
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub secret: String,
|
||||
@@ -21,8 +50,28 @@ pub struct AuthConfig {
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret_key_change_in_production".to_string());
|
||||
/// Load authentication configuration from environment variables
|
||||
///
|
||||
/// Requires `JWT_SECRET` (at least 32 characters) and optionally `API_KEYS`
|
||||
/// (comma-separated list of valid API keys).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use charging_engine::auth::AuthConfig;
|
||||
///
|
||||
/// # fn main() -> Result<(), String> {
|
||||
/// let config = AuthConfig::from_env()?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let secret = env::var("JWT_SECRET")
|
||||
.map_err(|_| "JWT_SECRET environment variable is required".to_string())?;
|
||||
|
||||
if secret.len() < 32 {
|
||||
return Err("JWT_SECRET must be at least 32 characters for security".to_string());
|
||||
}
|
||||
|
||||
// Parse API keys from comma-separated environment variable
|
||||
let api_keys_env = env::var("API_KEYS").unwrap_or_else(|_| String::new());
|
||||
@@ -32,7 +81,7 @@ impl AuthConfig {
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
Self { secret, api_keys }
|
||||
Ok(Self { secret, api_keys })
|
||||
}
|
||||
|
||||
pub fn validate_api_key(&self, api_key: &str) -> bool {
|
||||
@@ -43,7 +92,7 @@ impl AuthConfig {
|
||||
pub fn create_token(user_id: &str, config: &AuthConfig) -> Result<String, StatusCode> {
|
||||
let expiration = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::hours(24))
|
||||
.expect("valid timestamp")
|
||||
.ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
|
||||
@@ -100,7 +100,7 @@ impl crate::charging::ChargingEngine {
|
||||
let blocked: Option<String> = redis::AsyncCommands::get(&mut conn, &key).await
|
||||
.map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?;
|
||||
|
||||
if blocked.is_some() {
|
||||
if let Some(_blocked) = blocked {
|
||||
return Err(ChargingError::UsageBlocked(format!("User IP {} is already blocked", ip)));
|
||||
}
|
||||
|
||||
@@ -127,12 +127,21 @@ impl crate::charging::ChargingEngine {
|
||||
let mut conn = self.redis_client.get_multiplexed_async_connection().await
|
||||
.map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?;
|
||||
|
||||
let key = format!("block:{}", ip);
|
||||
let _: () = redis::AsyncCommands::del(&mut conn, &key).await
|
||||
// Check if user is blocked
|
||||
let blocked_key = format!("blocked:{}", ip);
|
||||
let blocked: Option<String> = redis::AsyncCommands::get(&mut conn, &blocked_key).await
|
||||
.map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?;
|
||||
|
||||
if let Some(_blocked) = blocked {
|
||||
|
||||
info!("User IP: {} has been unblocked", ip);
|
||||
Ok(())
|
||||
let _: () = redis::AsyncCommands::del(&mut conn, &blocked_key).await
|
||||
.map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?;
|
||||
|
||||
info!("User IP: {} has been unblocked", ip);
|
||||
Ok(())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}).await.map_err(|e| match e {
|
||||
CircuitBreakerError::Open => ChargingError::RedisConnection("Circuit breaker is open".to_string()),
|
||||
CircuitBreakerError::Inner(e) => e,
|
||||
|
||||
@@ -9,6 +9,19 @@ use crate::errors::ChargingResult;
|
||||
|
||||
/// ChargingEngine coordinates Redis-backed hot-path state (balances, sessions)
|
||||
/// and Postgres-backed configuration (rating plans) via `RatingPlansRepo`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use charging_engine::charging::engine::ChargingEngine;
|
||||
/// use charging_engine::charging::rating_plans_repo::RatingPlansRepo;
|
||||
///
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let plans = RatingPlansRepo::connect("postgres://localhost/db").await?;
|
||||
/// let engine = ChargingEngine::new("redis://localhost", plans, 60)?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct ChargingEngine {
|
||||
pub(crate) redis_client: redis::Client,
|
||||
pub(crate) plans: RatingPlansRepo,
|
||||
|
||||
@@ -7,8 +7,7 @@ use crate::errors::{ChargingError, ChargingResult, validate_amount};
|
||||
impl super::ChargingEngine {
|
||||
pub async fn calculate_usage_cost(&self, event: &UsageEvent) -> ChargingResult<f64> {
|
||||
// Get subscriber account to determine rating plan
|
||||
let account = self.get_subscriber_account(&event.imsi).await?;
|
||||
let _account = account.ok_or_else(|| ChargingError::SubscriberNotFound(event.imsi.clone()))?;
|
||||
let _account = self.get_subscriber_account(&event.imsi).await?;
|
||||
|
||||
// Get rating plan from Postgres (simplified - defaults to basic).
|
||||
let plan = self
|
||||
@@ -63,7 +62,7 @@ impl super::ChargingEngine {
|
||||
}
|
||||
|
||||
pub async fn apply_charging_rules(&self, event: &UsageEvent) -> ChargingResult<Vec<ChargingRule>> {
|
||||
let mut applied_rules = Vec::new();
|
||||
let mut applied_rules = Vec::with_capacity(6); // Max ~6 rules can be applied
|
||||
|
||||
// Rule 1: Check if user has sufficient credit
|
||||
if !self.check_credit(&event.imsi, event.volume).await? {
|
||||
@@ -160,7 +159,7 @@ impl super::ChargingEngine {
|
||||
.map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?;
|
||||
|
||||
let pattern = format!("usage:{}:*", imsi);
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
let mut keys: Vec<String> = Vec::with_capacity(100); // SCAN batch size
|
||||
|
||||
// Use SCAN instead of KEYS to avoid blocking Redis
|
||||
let mut cursor = 0u64;
|
||||
@@ -217,7 +216,7 @@ impl super::ChargingEngine {
|
||||
.map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?;
|
||||
|
||||
let pattern = "account:*".to_string();
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
let mut keys: Vec<String> = Vec::with_capacity(100); // SCAN batch size
|
||||
|
||||
// Use SCAN instead of KEYS to avoid blocking Redis
|
||||
let mut cursor = 0u64;
|
||||
|
||||
@@ -125,9 +125,13 @@ impl RatingPlansRepo {
|
||||
.await
|
||||
.map_err(|e| crate::errors::ChargingError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut out = HashMap::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let plan = row_to_plan(row);
|
||||
let plans = rows
|
||||
.into_iter()
|
||||
.map(|row| row_to_plan(row))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut out = HashMap::new();
|
||||
for plan in plans {
|
||||
out.insert(plan.plan_id.clone(), plan);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -236,8 +240,19 @@ fn row_to_plan(row: sqlx::postgres::PgRow) -> RatingPlan {
|
||||
|
||||
/// Run Goose migrations by calling the goose binary.
|
||||
/// This ensures both Go and Rust services use the same migration system.
|
||||
/// Changes to the repo root directory to find the migrations folder.
|
||||
async fn run_migrations(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::process::Command;
|
||||
|
||||
// Change to repo root to find migrations directory
|
||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let repo_root = manifest_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or("Failed to determine repo root")?;
|
||||
|
||||
let output = Command::new("goose")
|
||||
.current_dir(&repo_root)
|
||||
.args(["postgres", database_url, "up", "migrations"])
|
||||
.output()?;
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use redis::{FromRedisValue, ToRedisArgs, ToSingleRedisArg};
|
||||
|
||||
/// Charging rule for usage authorization
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::ChargingRule;
|
||||
///
|
||||
/// let rule = ChargingRule::Allowed;
|
||||
/// assert_eq!(rule.as_str(), "ALLOWED");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ChargingRule {
|
||||
Allowed,
|
||||
@@ -14,6 +24,16 @@ pub enum ChargingRule {
|
||||
}
|
||||
|
||||
impl ChargingRule {
|
||||
/// Convert charging rule to string representation
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::ChargingRule;
|
||||
///
|
||||
/// let rule = ChargingRule::Allowed;
|
||||
/// assert_eq!(rule.as_str(), "ALLOWED");
|
||||
/// ```
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ChargingRule::Allowed => "ALLOWED",
|
||||
@@ -27,6 +47,27 @@ impl ChargingRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscriber account information
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::{SubscriberAccount, AccountStatus};
|
||||
/// use chrono::Utc;
|
||||
///
|
||||
/// let account = SubscriberAccount {
|
||||
/// imsi: "1234567890".to_string(),
|
||||
/// balance: 1000,
|
||||
/// data_limit: 1000000000,
|
||||
/// data_used: 0,
|
||||
/// voice_limit: 1000,
|
||||
/// voice_used: 0,
|
||||
/// sms_limit: 100,
|
||||
/// sms_used: 0,
|
||||
/// status: AccountStatus::Active,
|
||||
/// last_updated: Utc::now(),
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubscriberAccount {
|
||||
pub imsi: String,
|
||||
@@ -41,6 +82,15 @@ pub struct SubscriberAccount {
|
||||
pub last_updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Account status
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::AccountStatus;
|
||||
///
|
||||
/// let status = AccountStatus::Active;
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AccountStatus {
|
||||
Active,
|
||||
@@ -49,6 +99,24 @@ pub enum AccountStatus {
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// Usage event for charging
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::{UsageEvent, UsageType};
|
||||
/// use chrono::Utc;
|
||||
///
|
||||
/// let event = UsageEvent {
|
||||
/// imsi: "1234567890".to_string(),
|
||||
/// session_id: "session123".to_string(),
|
||||
/// usage_type: UsageType::Data,
|
||||
/// volume: 1000,
|
||||
/// timestamp: Utc::now(),
|
||||
/// rate: 0.01,
|
||||
/// cost: 10.0,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageEvent {
|
||||
pub imsi: String,
|
||||
@@ -60,6 +128,15 @@ pub struct UsageEvent {
|
||||
pub cost: f64,
|
||||
}
|
||||
|
||||
/// Usage type classification
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::UsageType;
|
||||
///
|
||||
/// let usage_type = UsageType::Data;
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum UsageType {
|
||||
Data,
|
||||
@@ -67,6 +144,25 @@ pub enum UsageType {
|
||||
SMS,
|
||||
}
|
||||
|
||||
/// Rating plan for subscriber billing
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::charging::types::RatingPlan;
|
||||
///
|
||||
/// let plan = RatingPlan {
|
||||
/// plan_id: "plan1".to_string(),
|
||||
/// name: "Basic Plan".to_string(),
|
||||
/// data_rate: 0.01,
|
||||
/// voice_rate: 0.05,
|
||||
/// sms_rate: 0.1,
|
||||
/// monthly_fee: 10.0,
|
||||
/// data_limit: 1000000000,
|
||||
/// voice_limit: 1000,
|
||||
/// sms_limit: 100,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RatingPlan {
|
||||
pub plan_id: String,
|
||||
@@ -96,7 +192,10 @@ impl ToRedisArgs for SubscriberAccount {
|
||||
W: redis::RedisWrite + ?Sized,
|
||||
{
|
||||
let json = serde_json::to_string(self)
|
||||
.expect("Failed to serialize SubscriberAccount");
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback to empty JSON if serialization fails
|
||||
r#"{"imsi":"","balance":0,"data_limit":0,"data_used":0,"voice_limit":0,"voice_used":0,"sms_limit":0,"sms_used":0,"status":"Active","last_updated":"1970-01-01T00:00:00Z"}"#.to_string()
|
||||
});
|
||||
json.write_redis_args(out)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +217,10 @@ impl ToRedisArgs for UsageEvent {
|
||||
W: redis::RedisWrite + ?Sized,
|
||||
{
|
||||
let json = serde_json::to_string(self)
|
||||
.expect("Failed to serialize UsageEvent");
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback to empty JSON if serialization fails
|
||||
r#"{"imsi":"","session_id":"","usage_type":"Data","volume":0,"timestamp":"1970-01-01T00:00:00Z","rate":0.0,"cost":0.0}"#.to_string()
|
||||
});
|
||||
json.write_redis_args(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Circuit breaker state
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::circuit_breaker::CircuitState;
|
||||
///
|
||||
/// let state = CircuitState::Closed;
|
||||
/// assert_eq!(state, CircuitState::Closed);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Copy)]
|
||||
pub enum CircuitState {
|
||||
Closed,
|
||||
@@ -9,6 +19,15 @@ pub enum CircuitState {
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
/// Circuit breaker error type
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::circuit_breaker::CircuitBreakerError;
|
||||
///
|
||||
/// let error: CircuitBreakerError<&str> = CircuitBreakerError::Open;
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub enum CircuitBreakerError<E> {
|
||||
Open,
|
||||
@@ -26,6 +45,16 @@ impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
|
||||
|
||||
impl<E: std::error::Error + Send + Sync + 'static> std::error::Error for CircuitBreakerError<E> {}
|
||||
|
||||
/// Circuit breaker for fault tolerance
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use charging_engine::circuit_breaker::CircuitBreaker;
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// let breaker = CircuitBreaker::new(5, Duration::from_secs(60));
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct CircuitBreaker {
|
||||
state: Arc<RwLock<CircuitState>>,
|
||||
@@ -36,6 +65,21 @@ pub struct CircuitBreaker {
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
/// Create a new circuit breaker
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `failure_threshold` - Number of failures before opening the circuit
|
||||
/// * `timeout` - Duration to wait before attempting to close the circuit
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use charging_engine::circuit_breaker::CircuitBreaker;
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// let breaker = CircuitBreaker::new(5, Duration::from_secs(60));
|
||||
/// ```
|
||||
pub fn new(failure_threshold: u32, timeout: Duration) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Error, Clone)]
|
||||
pub enum ChargingError {
|
||||
#[error("Redis connection error: {0}")]
|
||||
RedisConnection(String),
|
||||
|
||||
#[error("Redis operation error: {0}")]
|
||||
RedisOperation(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("Subscriber not found: {0}")]
|
||||
SubscriberNotFound(String),
|
||||
|
||||
#[error("Rating plan not found: {0}")]
|
||||
RatingPlanNotFound(String),
|
||||
|
||||
#[error("Insufficient credit: available={available}, requested={requested}")]
|
||||
InsufficientCredit { available: u64, requested: u64 },
|
||||
|
||||
#[error("Usage blocked: {0}")]
|
||||
UsageBlocked(String),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ChargingError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ChargingError::RedisConnection(msg) => write!(f, "Redis connection error: {}", msg),
|
||||
ChargingError::RedisOperation(msg) => write!(f, "Redis operation error: {}", msg),
|
||||
ChargingError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
|
||||
ChargingError::SubscriberNotFound(imsi) => write!(f, "Subscriber not found: {}", imsi),
|
||||
ChargingError::RatingPlanNotFound(plan_id) => write!(f, "Rating plan not found: {}", plan_id),
|
||||
ChargingError::InsufficientCredit { available, requested } => {
|
||||
write!(f, "Insufficient credit: available={}, requested={}", available, requested)
|
||||
}
|
||||
ChargingError::UsageBlocked(reason) => write!(f, "Usage blocked: {}", reason),
|
||||
ChargingError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
|
||||
ChargingError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
|
||||
ChargingError::InternalError(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChargingError {}
|
||||
|
||||
impl IntoResponse for ChargingError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
|
||||
@@ -66,12 +66,13 @@ pub async fn add_rating_plan(
|
||||
sms_limit: req.get("sms_limit").and_then(|v| v.as_u64()).unwrap_or(100),
|
||||
};
|
||||
|
||||
state.charging_engine.add_rating_plan(plan.clone()).await
|
||||
let plan_id = plan.plan_id.clone();
|
||||
state.charging_engine.add_rating_plan(plan).await
|
||||
.with_context("Failed to add rating plan")?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "added",
|
||||
"plan_id": plan.plan_id,
|
||||
"plan_id": plan_id,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
//! # Charging Engine
|
||||
//!
|
||||
//! A high-performance telecom charging engine for real-time usage-based billing.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - Real-time rating and billing
|
||||
//! - Circuit breaker for fault tolerance
|
||||
//! - Rate limiting for API protection
|
||||
//! - Comprehensive monitoring and metrics
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use charging_engine::ChargingEngine;
|
||||
//! use charging_engine::charging::types::UsageEvent;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let engine = ChargingEngine::new("redis://localhost").await?;
|
||||
//!
|
||||
//! let event = UsageEvent {
|
||||
//! imsi: "1234567890".to_string(),
|
||||
//! volume: 1000,
|
||||
//! timestamp: chrono::Utc::now(),
|
||||
//! };
|
||||
//!
|
||||
//! let cost = engine.calculate_cost(&event).await?;
|
||||
//! println!("Cost: {}", cost);
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod charging;
|
||||
pub mod circuit_breaker;
|
||||
|
||||
@@ -29,7 +29,7 @@ async fn main() -> Result<()> {
|
||||
// Initialize charging engine
|
||||
let redis_url = std::env::var("REDIS_URI").unwrap_or_else(|_| "redis://127.0.0.1/".to_string());
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL is required (for rating plans)");
|
||||
.map_err(|_| anyhow::anyhow!("DATABASE_URL environment variable is required (for rating plans)"))?;
|
||||
let sync_interval = std::env::var("SYNC_INTERVAL")
|
||||
.unwrap_or_else(|_| "1".to_string())
|
||||
.parse::<u64>()
|
||||
@@ -47,7 +47,8 @@ async fn main() -> Result<()> {
|
||||
info!("Connected to Redis successfully");
|
||||
|
||||
// Create application state
|
||||
let auth_config = AuthConfig::from_env();
|
||||
let auth_config = AuthConfig::from_env()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize auth config: {}", e))?;
|
||||
let state = crate::models::AppState {
|
||||
charging_engine,
|
||||
auth_config,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod sync;
|
||||
pub mod types;
|
||||
pub mod monitoring;
|
||||
pub mod monitoring_impl;
|
||||
|
||||
+4
-6
@@ -37,13 +37,11 @@ impl crate::charging::ChargingEngine {
|
||||
let info: String = redis::cmd("INFO").query_async(&mut conn).await.unwrap_or_default();
|
||||
|
||||
for line in info.lines() {
|
||||
if line.starts_with("instantaneous_ops_per_sec:") {
|
||||
if let Some(rps_str) = line.split(':').nth(1) {
|
||||
if let Ok(rps) = rps_str.parse::<f64>() {
|
||||
return Ok(rps);
|
||||
}
|
||||
if line.starts_with("instantaneous_ops_per_sec:")
|
||||
&& let Some(rps_str) = line.split(':').nth(1)
|
||||
&& let Ok(rps) = rps_str.parse::<f64>() {
|
||||
return Ok(rps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_commands = self.extract_metric(&info, "total_commands_processed");
|
||||
@@ -221,12 +221,10 @@ impl crate::charging::ChargingEngine {
|
||||
// Try to read from /proc/self/status for Linux systems
|
||||
if let Ok(status) = fs::read_to_string("/proc/self/status") {
|
||||
for line in status.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
if let Some(memory_str) = line.split_whitespace().nth(1) {
|
||||
if let Ok(memory_kb) = memory_str.parse::<u64>() {
|
||||
return Ok(memory_kb * 1024); // Convert KB to bytes
|
||||
}
|
||||
}
|
||||
if line.starts_with("VmRSS:")
|
||||
&& let Some(memory_str) = line.split_whitespace().nth(1)
|
||||
&& let Ok(memory_kb) = memory_str.parse::<u64>() {
|
||||
return Ok(memory_kb * 1024); // Convert KB to bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,13 +252,11 @@ impl crate::charging::ChargingEngine {
|
||||
|
||||
// Parse memory usage from Redis info
|
||||
for line in info.lines() {
|
||||
if line.starts_with("used_memory:") {
|
||||
if let Some(memory_str) = line.split(':').nth(1) {
|
||||
if let Ok(memory_bytes) = memory_str.parse::<u64>() {
|
||||
return Ok(memory_bytes);
|
||||
}
|
||||
if line.starts_with("used_memory:")
|
||||
&& let Some(memory_str) = line.split(':').nth(1)
|
||||
&& let Ok(memory_bytes) = memory_str.parse::<u64>() {
|
||||
return Ok(memory_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to estimate
|
||||
|
||||
@@ -33,11 +33,18 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
|
||||
// Build governor config, fallback to default if None
|
||||
let governor_conf = GovernorConfigBuilder::default()
|
||||
.per_second(requests_per_second as u64)
|
||||
.burst_size(burst_size)
|
||||
.finish()
|
||||
.unwrap();
|
||||
.unwrap_or_else(|| {
|
||||
GovernorConfigBuilder::default()
|
||||
.per_second(10)
|
||||
.burst_size(20)
|
||||
.finish()
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
Router::new()
|
||||
// Public routes (no auth required - for packet-gateway integration)
|
||||
|
||||
@@ -12,7 +12,7 @@ fn test_validate_bytes_zero() {
|
||||
|
||||
#[test]
|
||||
fn test_validate_bytes_exceeds_limit() {
|
||||
std::env::set_var("MAX_BYTES_LIMIT", "1000000000000");
|
||||
unsafe { std::env::set_var("MAX_BYTES_LIMIT", "1000000000000"); }
|
||||
let result = validate_bytes(2_000_000_000_000); // 2TB
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
@@ -75,7 +75,7 @@ fn test_validate_amount_negative() {
|
||||
|
||||
#[test]
|
||||
fn test_validate_amount_exceeds_limit() {
|
||||
std::env::set_var("MAX_AMOUNT_LIMIT", "10000");
|
||||
unsafe { std::env::set_var("MAX_AMOUNT_LIMIT", "10000"); }
|
||||
let result = validate_amount(20_000.0);
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
|
||||
@@ -28,7 +28,7 @@ type CreditDeductRequest struct {
|
||||
func (c *Client) CheckCredit(ip string, bytesRequested uint64) (*CreditCheckResponse, error) {
|
||||
var resp CreditCheckResponse
|
||||
req := CreditCheckRequest{BytesRequested: bytesRequested}
|
||||
if err := c.doPostJSON("/v1/charging/credit/"+ip+"/check", req, &resp); err != nil {
|
||||
if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/check", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
@@ -37,7 +37,7 @@ func (c *Client) CheckCredit(ip string, bytesRequested uint64) (*CreditCheckResp
|
||||
// GetBalance gets the current credit balance for a subscriber
|
||||
func (c *Client) GetBalance(ip string) (*CreditBalanceResponse, error) {
|
||||
var resp CreditBalanceResponse
|
||||
if err := c.doGetJSON("/v1/charging/credit/"+ip+"/balance", &resp); err != nil {
|
||||
if err := c.doGetJSON("/api/v1/charging/credit/"+ip+"/balance", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
@@ -47,7 +47,7 @@ func (c *Client) GetBalance(ip string) (*CreditBalanceResponse, error) {
|
||||
func (c *Client) AddCredit(ip string, bytesToAdd uint64) (*CreditBalanceResponse, error) {
|
||||
var resp CreditBalanceResponse
|
||||
req := CreditAddRequest{BytesToAdd: bytesToAdd}
|
||||
if err := c.doPostJSON("/v1/charging/credit/"+ip+"/add", req, &resp); err != nil {
|
||||
if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/add", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
@@ -57,7 +57,7 @@ func (c *Client) AddCredit(ip string, bytesToAdd uint64) (*CreditBalanceResponse
|
||||
func (c *Client) DeductCredit(ip string, bytesUsed uint64) (*CreditBalanceResponse, error) {
|
||||
var resp CreditBalanceResponse
|
||||
req := CreditDeductRequest{BytesUsed: bytesUsed}
|
||||
if err := c.doPostJSON("/v1/charging/credit/"+ip+"/deduct", req, &resp); err != nil {
|
||||
if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/deduct", req, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
|
||||
@@ -94,7 +94,7 @@ async fn main() -> Result<()> {
|
||||
#[cfg(feature = "ebpf")]
|
||||
let (ebpf_manager, ebpf_attached) = {
|
||||
// Initialize eBPF manager
|
||||
let mut ebpf_manager = EbpfManager::new(args.interface.clone()).await
|
||||
let mut ebpf_manager = EbpfManager::new(&args.interface).await
|
||||
.context("Failed to initialize eBPF manager")?;
|
||||
|
||||
// Load and attach XDP program
|
||||
@@ -123,7 +123,7 @@ async fn main() -> Result<()> {
|
||||
info!("Packet gateway running. Press Ctrl+C to exit.");
|
||||
|
||||
// Start HTTP health check server
|
||||
let redis_client_arc = Arc::new(redis_client.clone());
|
||||
let redis_client_arc = Arc::new(redis_client);
|
||||
let ebpf_attached_clone = Arc::clone(&ebpf_attached);
|
||||
|
||||
#[cfg(feature = "ebpf")]
|
||||
@@ -133,9 +133,9 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Create config state
|
||||
let config = PacketGatewayConfig {
|
||||
interface: args.interface.clone(),
|
||||
redis_url: args.redis_url.clone(),
|
||||
charging_engine_url: args.charging_engine_url.clone(),
|
||||
interface: args.interface,
|
||||
redis_url: args.redis_url,
|
||||
charging_engine_url: args.charging_engine_url,
|
||||
sync_interval: args.sync_interval,
|
||||
health_port: args.health_port,
|
||||
};
|
||||
@@ -215,7 +215,7 @@ async fn main() -> Result<()> {
|
||||
s.ip & 0xFF
|
||||
);
|
||||
let session_id = format!("session-{}", ip_str);
|
||||
(ip_str.clone(), session_id, s.bytes)
|
||||
(ip_str, session_id, s.bytes)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
+87
-5
@@ -1,4 +1,26 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: taas-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--configFile=/traefik/traefik.yml"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080" # Traefik dashboard
|
||||
volumes:
|
||||
- ./traefik:/traefik
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
JWT_SECRET: "${JWT_SECRET:-change-me-in-production-jwt-secret-32-chars-min}"
|
||||
networks:
|
||||
- taas-network
|
||||
depends_on:
|
||||
- api-server
|
||||
- charging-engine
|
||||
- carrier-connector
|
||||
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
container_name: taas-mongodb
|
||||
@@ -185,8 +207,8 @@ services:
|
||||
dockerfile: deployments/docker/api-server.Dockerfile
|
||||
container_name: taas-api-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
MONGODB_URI: mongodb://admin:changeme@mongodb:27017/free5gc
|
||||
REDIS_URI: redis://redis:6379
|
||||
@@ -197,6 +219,17 @@ services:
|
||||
- redis
|
||||
networks:
|
||||
- taas-network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.api-server.rule=Host(`api.telecom.com`) && PathPrefix(`/api/v1`)"
|
||||
- "traefik.http.routers.api-server.entrypoints=websecure"
|
||||
- "traefik.http.routers.api-server.tls=true"
|
||||
- "traefik.http.routers.api-server.middlewares=api-ratelimit,secure-headers,compression"
|
||||
- "traefik.http.services.api-server.loadbalancer.server.port=8000"
|
||||
- "traefik.http.routers.api-server-docs.rule=Host(`api.telecom.com`) && Path(`/api/v1/swagger`) || Path(`/api/v1/swagger/*`)"
|
||||
- "traefik.http.routers.api-server-docs.entrypoints=websecure"
|
||||
- "traefik.http.routers.api-server-docs.tls=true"
|
||||
- "traefik.http.routers.api-server-docs.middlewares=secure-headers,compression"
|
||||
|
||||
carrier-connector:
|
||||
build:
|
||||
@@ -204,11 +237,20 @@ services:
|
||||
dockerfile: deployments/docker/carrier-connector.Dockerfile
|
||||
container_name: taas-carrier-connector
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8081"
|
||||
environment:
|
||||
SMDP_URL: https://smdp.example.com
|
||||
SMDP_API_KEY: your-api-key
|
||||
networks:
|
||||
- taas-network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.carrier-connector.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/es2`) || PathPrefix(`/v1/carrier`)"
|
||||
- "traefik.http.routers.carrier-connector.entrypoints=websecure"
|
||||
- "traefik.http.routers.carrier-connector.tls=true"
|
||||
- "traefik.http.routers.carrier-connector.middlewares=carrier-ratelimit,secure-headers,compression,retry-attempts"
|
||||
- "traefik.http.services.carrier-connector.loadbalancer.server.port=8081"
|
||||
|
||||
charging-engine:
|
||||
build:
|
||||
@@ -216,8 +258,8 @@ services:
|
||||
dockerfile: deployments/docker/charging-engine.Dockerfile
|
||||
container_name: taas-charging-engine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
REDIS_URI: redis://redis:6379
|
||||
SERVER_PORT: 8080
|
||||
@@ -226,6 +268,46 @@ services:
|
||||
- redis
|
||||
networks:
|
||||
- taas-network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.charging-engine.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/credit`) || PathPrefix(`/v1/usage`) || PathPrefix(`/v1/rating-plans`)"
|
||||
- "traefik.http.routers.charging-engine.entrypoints=websecure"
|
||||
- "traefik.http.routers.charging-engine.tls=true"
|
||||
- "traefik.http.routers.charging-engine.middlewares=charging-ratelimit,secure-headers,compression,retry-attempts"
|
||||
- "traefik.http.services.charging-engine.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.charging-engine-health.rule=Host(`api.telecom.com`) && Path(`/v1/health`)"
|
||||
- "traefik.http.routers.charging-engine-health.entrypoints=websecure"
|
||||
- "traefik.http.routers.charging-engine-health.tls=true"
|
||||
- "traefik.http.routers.charging-engine-health.middlewares=secure-headers"
|
||||
|
||||
packet-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deployments/docker/packet-gateway.Dockerfile
|
||||
container_name: taas-packet-gateway
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8083"
|
||||
environment:
|
||||
REDIS_URL: redis://redis:6379
|
||||
CHARGING_ENGINE_URL: http://charging-engine:8080
|
||||
HEALTH_PORT: 8083
|
||||
depends_on:
|
||||
- redis
|
||||
- charging-engine
|
||||
networks:
|
||||
- taas-network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.packet-gateway.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/packet`) || PathPrefix(`/v1/gateway`)"
|
||||
- "traefik.http.routers.packet-gateway.entrypoints=websecure"
|
||||
- "traefik.http.routers.packet-gateway.tls=true"
|
||||
- "traefik.http.routers.packet-gateway.middlewares=charging-ratelimit,secure-headers,compression,retry-attempts"
|
||||
- "traefik.http.services.packet-gateway.loadbalancer.server.port=8083"
|
||||
- "traefik.http.routers.packet-gateway-health.rule=Host(`api.telecom.com`) && Path(`/health`)"
|
||||
- "traefik.http.routers.packet-gateway-health.entrypoints=websecure"
|
||||
- "traefik.http.routers.packet-gateway-health.tls=true"
|
||||
- "traefik.http.routers.packet-gateway-health.middlewares=secure-headers"
|
||||
|
||||
web-dashboard:
|
||||
build:
|
||||
@@ -236,7 +318,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://api-server:8000
|
||||
NEXT_PUBLIC_API_URL: https://api.telecom.com
|
||||
depends_on:
|
||||
- api-server
|
||||
networks:
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Telecom Platform API Gateway Startup Script
|
||||
# This script starts the platform with Traefik as the API Gateway
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting Telecom Platform with API Gateway..."
|
||||
|
||||
# Check for required environment variables
|
||||
if [ -z "$JWT_SECRET" ]; then
|
||||
echo "WARNING: JWT_SECRET not set, using default for development only"
|
||||
export JWT_SECRET="change-me-in-production-jwt-secret-32-chars-min"
|
||||
fi
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p traefik/dynamic
|
||||
|
||||
# Stop any existing containers
|
||||
echo "Stopping existing containers..."
|
||||
docker-compose down
|
||||
|
||||
# Build and start services
|
||||
echo "Building and starting services with API Gateway..."
|
||||
docker-compose up --build -d
|
||||
|
||||
# Wait for services to be ready
|
||||
echo "Waiting for services to be ready..."
|
||||
sleep 10
|
||||
|
||||
# Check service health
|
||||
echo "Checking service health..."
|
||||
|
||||
# Check Traefik
|
||||
if curl -f http://localhost:8080/ping >/dev/null 2>&1; then
|
||||
echo "Traefik: OK"
|
||||
else
|
||||
echo "Traefik: FAILED"
|
||||
fi
|
||||
|
||||
# Check API Server through gateway
|
||||
if curl -f http://localhost/api/v1/health >/dev/null 2>&1; then
|
||||
echo "API Server: OK"
|
||||
else
|
||||
echo "API Server: FAILED"
|
||||
fi
|
||||
|
||||
# Check Charging Engine through gateway
|
||||
if curl -f http://localhost/v1/health >/dev/null 2>&1; then
|
||||
echo "Charging Engine: OK"
|
||||
else
|
||||
echo "Charging Engine: FAILED"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "API Gateway is running!"
|
||||
echo ""
|
||||
echo "Services:"
|
||||
echo " - Traefik Dashboard: http://localhost:8080"
|
||||
echo " - API Server: https://api.telecom.com/api/v1"
|
||||
echo " - Charging Engine: https://api.telecom.com/v1/credit"
|
||||
echo " - Carrier Connector: https://api.telecom.com/v1/es2"
|
||||
echo " - Packet Gateway: https://api.telecom.com/v1/packet"
|
||||
echo " - Web Dashboard: http://localhost:3000"
|
||||
echo ""
|
||||
echo "To add api.telecom.com to your hosts file:"
|
||||
echo " echo '127.0.0.1 api.telecom.com' | sudo tee -a /etc/hosts"
|
||||
echo ""
|
||||
echo "To view logs:"
|
||||
echo " docker-compose logs -f traefik"
|
||||
echo " docker-compose logs -f api-server"
|
||||
echo " docker-compose logs -f charging-engine"
|
||||
@@ -0,0 +1,56 @@
|
||||
http:
|
||||
middlewares:
|
||||
secure-headers:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Proto: "https"
|
||||
customResponseHeaders:
|
||||
X-Content-Type-Options: "nosniff"
|
||||
X-Frame-Options: "DENY"
|
||||
X-XSS-Protection: "1; mode=block"
|
||||
Referrer-Policy: "strict-origin-when-cross-origin"
|
||||
Permissions-Policy: "camera=(), microphone=(), geolocation=()"
|
||||
Content-Security-Policy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
|
||||
|
||||
# Rate Limiting Middleware (API endpoints)
|
||||
api-ratelimit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
period: "1m"
|
||||
burst: 200
|
||||
|
||||
# Rate Limiting Middleware (Charging endpoints - more restrictive)
|
||||
charging-ratelimit:
|
||||
rateLimit:
|
||||
average: 1000
|
||||
period: "1m"
|
||||
burst: 2000
|
||||
|
||||
# Rate Limiting Middleware (Carrier endpoints - very restrictive)
|
||||
carrier-ratelimit:
|
||||
rateLimit:
|
||||
average: 50
|
||||
period: "1m"
|
||||
burst: 100
|
||||
|
||||
# JWT Authentication Middleware
|
||||
jwt-auth:
|
||||
plugin:
|
||||
JWTAuth:
|
||||
secret: "${JWT_SECRET}"
|
||||
authHeaderName: "Authorization"
|
||||
responseHeaderName: "X-Auth-User"
|
||||
forwardAuth: true
|
||||
|
||||
# Compression Middleware
|
||||
compression:
|
||||
compress:
|
||||
excludedContentTypes:
|
||||
- "text/event-stream"
|
||||
- "application/grpc"
|
||||
|
||||
# Retry Middleware
|
||||
retry-attempts:
|
||||
retry:
|
||||
attempts: 3
|
||||
initialInterval: "100ms"
|
||||
@@ -0,0 +1,38 @@
|
||||
api:
|
||||
dashboard: true
|
||||
insecure: true
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
websecure:
|
||||
address: ":443"
|
||||
http:
|
||||
middlewares:
|
||||
- secure-headers@file
|
||||
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
network: taas-network
|
||||
file:
|
||||
directory: /traefik/dynamic
|
||||
watch: true
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
format: json
|
||||
|
||||
accessLog: {}
|
||||
|
||||
metrics:
|
||||
prometheus:
|
||||
addEntryPointsLabels: true
|
||||
addServicesLabels: true
|
||||
|
||||
ping: {}
|
||||
Reference in New Issue
Block a user